├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── config-template.js ├── manifest.json ├── package.json ├── rollup.config.js ├── src ├── algorithms.ts ├── algorithms │ ├── anki.ts │ ├── leitner.ts │ └── supermemo.ts ├── commands.ts ├── data.ts ├── main.ts ├── modals │ ├── confirm.ts │ └── info.ts ├── selection.ts ├── settings.ts ├── utils.ts └── view.ts ├── styles.css ├── tsconfig.json └── versions.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: martin-jw 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | 13 | config.js 14 | *.swp 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Martin Jirlow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Recall - Spaced Repetition System in Obsidian! 2 | This plugin for [Obsidian](https://obsidian.md/) implements a spaced repetition system for reviewing information, with any SRS algorithm. 3 | 4 | See [planned features](https://github.com/martin-jw/obsidian-recall#planned-features) for upcoming updates. To request a feature that isn't already planned, or to report a bug, please [raise an issue](https://github.com/martin-jw/obsidian-recall/issues). 5 | 6 | ## Quick Guide 7 | 8 | 1. [install](https://github.com/martin-jw/obsidian-recall#installation) the plugin. 9 | 10 | 2. Select the [algorithm](https://github.com/martin-jw/obsidian-recall#algorithms) you want to use. 11 | 12 | 3. Start [tracking notes](https://github.com/martin-jw/obsidian-recall#tracking-notes). 13 | 14 | 4. [Review](https://github.com/martin-jw/obsidian-recall#review) them! 15 | 16 | ## Installation 17 | The plugin is not yet available in Obsidian's community plugin section, so until then the plugin has to be installed manually. 18 | 19 | ### Manual installation 20 | In your vault, navigate to `.obsidian/plugins` and create a folder called `obsidian-recall`. Add the `main.js`, `manifest.json` and `styles.css` files from the [latest release](https://github.com/martin-jw/obsidian-recall/releases) to the folder. 21 | 22 | ## Tracking notes 23 | This plugin tracks notes for review in a separate file called `tracked_files.json` in a configurable location. This means that you don't need to make any changes to a note that you want to review. To track a note, either right-click a note in the file explorer and click `Track Note`, or run the command `SRS: Track Note` to track the currently active file. 24 | 25 | You can also recursively track all notes in a folder by right-clicking a folder in the explorer and pressing `Track All Notes`. 26 | 27 | **Currently, the top-most header of the file will be taken as the question of the note.** If there are no headers the file name will be used. This will most likely be changed very soon. 28 | 29 | ### Untracking notes 30 | 31 | Untracking notes is done the same way, simply right-click a note in the explorer and click `Untrack Note`, or run the command `SRS: Untrack Note`. 32 | 33 | You can also recursively remove all notes in a folder from the SRS by right-clicking the folder in the explorer and pressing `Untrack All Notes`. 34 | 35 | Note that untracking a note removes all information regarding the note from the system, and any progress will therefore be reset. 36 | 37 | ### The status bar 38 | 39 | This plugin adds a status to the status bar of Obsidian. This status changes depending on which note is being viewed: 40 | - When viewing a tracked note, it shows when that note is next to be reviewed. 41 | - When viewing an untracked note, it shows the number of notes currently in the queue. 42 | - When in Review, it shows the number of items remaining in the review. 43 | 44 | ## Reviewing Items 45 | To review due items, run the `SRS: Review` command. This will build the queue and open up the review view, and any items due for review will be shown. 46 | 47 | ### Adding hotkeys 48 | Any of the `SRS:` commands can be bound to hotkeys in the Obsidian `Hotkeys` section of the settings. There are currently not hotkeys for the different responses of the review view, however it is planned. 49 | 50 | ## Algorithms 51 | 52 | This plugin uses a modular way of adding algorithms. This means that you can choose which algorithm to use for reviews depending on your needs. Currently, only a few algorithms are implemented. If you want to request a specific algorithm to be added please file a [feature request](https://github.com/martin-jw/obsidian-recall/issues). If you feel like an algorithm is behaving incorrectly or is missing something, plase [report a bug](https://github.com/martin-jw/obsidian-recall/issues). 53 | 54 | ### Changing algorithms 55 | 56 | Since SRS algorithms can be quite different, an algorithm can define it's own data to use track with the repetition items. This means that different algorithms could have conflicting data, and as such switching algorithms when you already have existing items could set back, reset or alter the review intervals for existing items. 57 | 58 | Because of this, switching algorithms currently requires a reload of the plugin. 59 | 60 | ## Currently available algorithms 61 | 62 | ### Anki 63 | 64 | This is an implementation of the [Anki algorithm](https://faqs.ankiweb.net/what-spaced-repetition-algorithm.html). 65 | 66 | It uses the same data structure as the SM2 algorithm, and as such you can switch between them without losing data. 67 | 68 | #### Settings 69 | 70 | For more details of the settings available see [Anki's documentation](https://docs.ankiweb.net/#/deck-options). 71 | 72 | ### SM2 73 | 74 | An implementation of SuperMemo's algorithm, [SM2](https://www.supermemo.com/en/archives1990-2015/english/ol/sm2). This is the algorithm that Anki's algorithm is based on. 75 | 76 | This algorithm currently exposes no settings. It uses the same data structure as the Anki algorithm, and as such you can switch between them without losing data. 77 | 78 | ### Leitner 79 | 80 | This is an implementation of the [Leitner System](https://www.wikiwand.com/en/Leitner_system), also known as the shoebox method. Items are separated into "boxes" (called stages in the settings) and each box has a set interval of time between reviews. 81 | 82 | When an item is marked as correct it graduates to the next stage. If an item is marked as wrong it is returned to the first stage. 83 | 84 | #### Settings 85 | 86 | **Stages** - The number of stages. Changing this updates the maximum number of stages available to the system. 87 | 88 | **Reset When Incorrect** - Whether or not to move back to the initial stage when incorrect, or simply move back one stage. 89 | 90 | **Timings** - The timings of each stage. 91 | 92 | # Planned Features 93 | 94 | These are features currently planned, without any inherent order of priority: 95 | 96 | - [ ] Multiple items per note. 97 | - [ ] Extract separate headings as separate questions and SRS items. 98 | - [ ] More ways to identify repetition items 99 | - [ ] Flashcard style: `question::answer` 100 | - [ ] Different levels of headings 101 | - [ ] Dividers 102 | - [ ] Cloze deletions? 103 | - [ ] Custom queues and reviews. 104 | - [ ] Leverage Obsidian's search filters to specify which notes to review, and review notes without updating their status. 105 | - [ ] Expose more of the SRS data to the user. 106 | - [ ] Show lists of all the current items in the SRS. 107 | - [ ] Expose the data of items and allow the user to change it manually. 108 | -------------------------------------------------------------------------------- /config-template.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "vault-path": "/PATH/TO/VAULT/.obsidian/plugins/obsidian-recall" 3 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-recall", 3 | "name": "Recall", 4 | "version": "0.1.1", 5 | "minAppVersion": "0.9.12", 6 | "description": "A flexible and configurable Spaced Repetition System built into Obsidian.", 7 | "author": "Martin Jirlow", 8 | "authorUrl": "https://github.com/martin-jw/obsidian-recall", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-recall", 3 | "version": "0.1.0", 4 | "description": "Flexible and configurable spaced repetition plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@rollup/plugin-commonjs": "^15.1.0", 15 | "@rollup/plugin-node-resolve": "^9.0.0", 16 | "@rollup/plugin-typescript": "^6.0.0", 17 | "@types/node": "^14.14.41", 18 | "obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master", 19 | "prettier": "^2.2.1", 20 | "rollup": "^2.45.2", 21 | "rollup-plugin-copy": "^3.4.0", 22 | "tslib": "^2.2.0", 23 | "typescript": "^4.2.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | import copy from "rollup-plugin-copy"; 5 | 6 | import config from './config'; 7 | 8 | export default { 9 | input: "src/main.ts", 10 | output: { 11 | dir: ".", 12 | sourcemap: "inline", 13 | format: "cjs", 14 | exports: "default", 15 | }, 16 | external: ["obsidian"], 17 | plugins: [ 18 | typescript(), 19 | nodeResolve({ browser: true }), 20 | commonjs(), 21 | copy({ 22 | targets: [ 23 | { 24 | src: "manifest.json", 25 | dest: config['vault-path'], 26 | }, 27 | { 28 | src: "styles.css", 29 | dest: config['vault-path'], 30 | }, 31 | { 32 | src: "main.js", 33 | dest: config['vault-path'], 34 | }, 35 | ], 36 | }), 37 | ], 38 | }; 39 | -------------------------------------------------------------------------------- /src/algorithms.ts: -------------------------------------------------------------------------------- 1 | import { RepetitionItem, ReviewResult } from "./data"; 2 | import { MiscUtils } from "./utils"; 3 | 4 | export default abstract class SrsAlgorithm { 5 | settings: any; 6 | 7 | updateSettings(settings: any) { 8 | this.settings = MiscUtils.assignOnly( 9 | this.defaultSettings(), 10 | settings 11 | ); 12 | } 13 | 14 | abstract defaultSettings(): any; 15 | abstract defaultData(): any; 16 | abstract onSelection( 17 | item: RepetitionItem, 18 | option: string, 19 | repeat: boolean 20 | ): ReviewResult; 21 | abstract srsOptions(): String[]; 22 | abstract displaySettings( 23 | containerEl: HTMLElement, 24 | update: (settings: any) => void 25 | ): void; 26 | } 27 | -------------------------------------------------------------------------------- /src/algorithms/anki.ts: -------------------------------------------------------------------------------- 1 | import { Setting, Notice } from "obsidian"; 2 | import { DateUtils } from "src/utils"; 3 | import SrsAlgorithm from "./../algorithms"; 4 | import { RepetitionItem, ReviewResult } from "./../data"; 5 | 6 | interface AnkiData { 7 | ease: number; 8 | lastInterval: number; 9 | iteration: number; 10 | } 11 | 12 | interface AnkiSettings { 13 | easyBonus: number; 14 | startingEase: number; 15 | lapseInterval: number; 16 | graduatingInterval: number; 17 | easyInterval: number; 18 | } 19 | 20 | const AnkiOptions: string[] = ["Again", "Hard", "Good", "Easy"]; 21 | 22 | /** 23 | * This is an implementation of the Anki algorithm as described in 24 | * https://faqs.ankiweb.net/what-spaced-repetition-algorithm.html 25 | */ 26 | export class AnkiAlgorithm extends SrsAlgorithm { 27 | defaultSettings(): AnkiSettings { 28 | return { 29 | easyBonus: 1.3, 30 | startingEase: 2.5, 31 | lapseInterval: 0.5, 32 | graduatingInterval: 1, 33 | easyInterval: 4, 34 | }; 35 | } 36 | 37 | defaultData(): AnkiData { 38 | return { 39 | ease: this.settings.startingEase, 40 | lastInterval: 0, 41 | iteration: 1, 42 | }; 43 | } 44 | 45 | srsOptions(): String[] { 46 | return AnkiOptions; 47 | } 48 | 49 | onSelection( 50 | item: RepetitionItem, 51 | optionStr: string, 52 | repeat: boolean 53 | ): ReviewResult { 54 | const data = item.data as AnkiData; 55 | const response = AnkiOptions.indexOf(optionStr); 56 | 57 | let correct = true; 58 | let nextInterval = 0; 59 | if (repeat) { 60 | if (response == 0) { 61 | correct = false; 62 | } 63 | 64 | return { 65 | correct, 66 | nextReview: -1, 67 | }; 68 | } 69 | 70 | if (response == 0) { 71 | // Again 72 | data.ease = Math.max(1.3, data.ease - 0.2); 73 | nextInterval = data.lastInterval * this.settings.lapseInterval; 74 | correct = false; 75 | } else if (response == 1) { 76 | // Hard 77 | data.ease = Math.max(1.3, data.ease - 0.15); 78 | nextInterval = data.lastInterval * 1.2; 79 | if (nextInterval - data.lastInterval < 1) 80 | nextInterval = data.lastInterval + 1; 81 | } else if (response == 2) { 82 | // Good 83 | if (data.iteration == 1) { 84 | // Graduation! 85 | nextInterval = this.settings.graduatingInterval; 86 | } else { 87 | nextInterval = data.lastInterval * data.ease; 88 | if (nextInterval - data.lastInterval < 1) 89 | nextInterval = data.lastInterval + 1; 90 | } 91 | } else if (response == 3) { 92 | data.ease += 0.15; 93 | if (data.iteration == 1) { 94 | // Graduation! 95 | nextInterval = this.settings.easyInterval; 96 | } else { 97 | nextInterval = 98 | data.lastInterval * data.ease * this.settings.easyBonus; 99 | } 100 | } 101 | 102 | data.iteration += 1; 103 | data.lastInterval = nextInterval; 104 | 105 | return { 106 | correct, 107 | nextReview: nextInterval * DateUtils.DAYS_TO_MILLIS, 108 | }; 109 | } 110 | 111 | displaySettings(containerEl: HTMLElement, update: (settings: any) => void) { 112 | new Setting(containerEl) 113 | .setName("Starting Ease") 114 | .setDesc("The initial ease given to an item.") 115 | .addText((text) => 116 | text 117 | .setPlaceholder("Starting Ease") 118 | .setValue(this.settings.startingEase.toString()) 119 | .onChange((newValue) => { 120 | const ease = Number(newValue); 121 | 122 | if (isNaN(ease) || ease < 0) { 123 | new Notice( 124 | "Starting ease must be a positive number." 125 | ); 126 | return; 127 | } 128 | 129 | if (ease < 1.3) { 130 | new Notice( 131 | "Starting ease lower than 1.3 is not recommended." 132 | ); 133 | } 134 | 135 | this.settings.startingEase = ease; 136 | update(this.settings); 137 | }) 138 | ); 139 | 140 | new Setting(containerEl) 141 | .setName("Easy Bonus") 142 | .setDesc("A bonus multiplier for items reviewed as easy.") 143 | .addText((text) => 144 | text 145 | .setPlaceholder("Easy Bonus") 146 | .setValue(this.settings.easyBonus.toString()) 147 | .onChange((newValue) => { 148 | const bonus = Number(newValue); 149 | 150 | if (isNaN(bonus) || bonus < 1) { 151 | new Notice( 152 | "Easy bonus must be a number greater than or equal to 1." 153 | ); 154 | return; 155 | } 156 | 157 | this.settings.easyBonus = bonus; 158 | update(this.settings); 159 | }) 160 | ); 161 | 162 | new Setting(containerEl) 163 | .setName("Lapse Interval Modifier") 164 | .setDesc( 165 | "A factor to modify the review interval with when an item is reviewed as wrong." 166 | ) 167 | .addText((text) => 168 | text 169 | .setPlaceholder("Lapse Interval") 170 | .setValue(this.settings.lapseInterval.toString()) 171 | .onChange((newValue) => { 172 | const lapse = Number(newValue); 173 | 174 | if (isNaN(lapse) || lapse <= 0) { 175 | new Notice( 176 | "Lapse interval must be a positive number." 177 | ); 178 | return; 179 | } 180 | 181 | this.settings.lapseInterval = lapse; 182 | update(this.settings); 183 | }) 184 | ); 185 | 186 | new Setting(containerEl) 187 | .setName("Graduating Interval") 188 | .setDesc( 189 | "The interval (in days) to the next review after reviewing a new item as 'Good'." 190 | ) 191 | .addText((text) => 192 | text 193 | .setPlaceholder("Graduating Interval") 194 | .setValue(this.settings.graduatingInterval.toString()) 195 | .onChange((newValue) => { 196 | const interval = Number(newValue); 197 | 198 | if (isNaN(interval) || interval <= 0) { 199 | new Notice("Interval must be a positive number."); 200 | return; 201 | } 202 | 203 | this.settings.graduatingInterval = interval; 204 | update(this.settings); 205 | }) 206 | ); 207 | 208 | new Setting(containerEl) 209 | .setName("Easy Interval") 210 | .setDesc( 211 | "The interval (in days) to the next review after reviewing a new item as 'Easy'." 212 | ) 213 | .addText((text) => 214 | text 215 | .setPlaceholder("Easy Interval") 216 | .setValue(this.settings.easyInterval.toString()) 217 | .onChange((newValue) => { 218 | const interval = Number(newValue); 219 | 220 | if (isNaN(interval) || interval <= 0) { 221 | new Notice("Interval must be a positive number."); 222 | return; 223 | } 224 | 225 | this.settings.easyInterval = interval; 226 | update(this.settings); 227 | }) 228 | ); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/algorithms/leitner.ts: -------------------------------------------------------------------------------- 1 | import SrsAlgorithm from "./../algorithms"; 2 | import { ReviewResult, RepetitionItem } from "./../data"; 3 | import { DateUtils, MiscUtils } from "./../utils"; 4 | 5 | import { Setting, Notice, TextComponent } from "obsidian"; 6 | 7 | interface LeitnerSettings { 8 | stages: number; 9 | resetOnIncorrect: boolean; 10 | timings: number[]; 11 | } 12 | 13 | interface LeitnerData { 14 | stage: number; 15 | } 16 | 17 | export class LeitnerAlgorithm extends SrsAlgorithm { 18 | settings: LeitnerSettings; 19 | timingsList: HTMLDivElement; 20 | 21 | defaultSettings(): LeitnerSettings { 22 | return { 23 | stages: 6, 24 | resetOnIncorrect: true, 25 | timings: [1, 3, 7, 14, 30, 180], 26 | }; 27 | } 28 | 29 | defaultData(): LeitnerData { 30 | return { 31 | stage: 0, 32 | }; 33 | } 34 | 35 | srsOptions(): String[] { 36 | return ["Wrong", "Correct"]; 37 | } 38 | 39 | onSelection( 40 | item: RepetitionItem, 41 | option: String, 42 | repeat: boolean 43 | ): ReviewResult { 44 | const data = item.data; 45 | 46 | if (data.stage === "undefined") { 47 | data.stage = 0; 48 | } 49 | 50 | if (option == "Correct") { 51 | if (repeat) { 52 | return { correct: true, nextReview: -1 }; 53 | } 54 | data.stage += 1; 55 | 56 | if (data.stage > this.settings.stages) { 57 | data.stage = this.settings.stages; 58 | } 59 | 60 | return { 61 | correct: true, 62 | nextReview: 63 | this.settings.timings[data.stage - 1] * 64 | DateUtils.DAYS_TO_MILLIS, 65 | }; 66 | } else { 67 | if (repeat) { 68 | return { correct: false, nextReview: -1 }; 69 | } 70 | 71 | if (this.settings.resetOnIncorrect) { 72 | data.stage = 1; 73 | } else { 74 | data.stage = Math.max(1, data.stage - 1); 75 | } 76 | return { 77 | correct: false, 78 | nextReview: 79 | this.settings.timings[data.stage - 1] * 80 | DateUtils.DAYS_TO_MILLIS, 81 | }; 82 | } 83 | } 84 | 85 | displaySettings( 86 | containerEl: HTMLElement, 87 | update: (settings: any) => void 88 | ): void { 89 | new Setting(containerEl) 90 | .setName("Stages") 91 | .setDesc("The number of SRS stages.") 92 | .addText((text) => 93 | text 94 | .setPlaceholder("Stages") 95 | .setValue(this.settings.stages.toString()) 96 | .onChange((newValue) => { 97 | const stages = Number(newValue); 98 | 99 | if (isNaN(stages)) { 100 | new Notice("Stages must be a number."); 101 | return; 102 | } 103 | 104 | if (!Number.isInteger(stages) || stages < 1) { 105 | new Notice( 106 | "Stages must be an integer larger than 0." 107 | ); 108 | return; 109 | } 110 | 111 | const old = this.settings.stages; 112 | this.settings.stages = stages; 113 | 114 | if (old < stages) { 115 | this.settings.timings.push( 116 | ...new Array(stages - old).fill(0) 117 | ); 118 | } else if (old > stages) { 119 | this.settings.timings = this.settings.timings.slice( 120 | 0, 121 | stages 122 | ); 123 | } 124 | 125 | this.updateTimingsList(update); 126 | update(this.settings); 127 | }) 128 | ); 129 | 130 | new Setting(containerEl) 131 | .setName("Reset When Incorrect") 132 | .setDesc( 133 | "If true, a review item is moved back to the first stage when marked as incorrect. Otherwise it simply moves back to the previous stage." 134 | ) 135 | .addToggle((toggle) => { 136 | toggle.setValue(this.settings.resetOnIncorrect); 137 | toggle.onChange((val) => { 138 | this.settings.resetOnIncorrect = val; 139 | update(this.settings); 140 | }); 141 | }); 142 | 143 | const timingsDiv = containerEl.createDiv( 144 | "timings-setting-item setting-item" 145 | ); 146 | timingsDiv.createDiv("setting-item-info", (div) => { 147 | div.createDiv("setting-item-name").innerText = "Timings"; 148 | div.createDiv("setting-item-description").innerText = 149 | "The timings (in days) of each SRS stage."; 150 | }); 151 | this.timingsList = timingsDiv.createDiv("setting-item-control"); 152 | this.updateTimingsList(update); 153 | } 154 | 155 | updateTimingsList(update: (settings: any) => void) { 156 | this.timingsList.empty(); 157 | this.settings.timings.forEach((val, ind) => { 158 | new TextComponent(this.timingsList) 159 | .setPlaceholder(ind.toString()) 160 | .setValue(val.toString()) 161 | .onChange((newValue) => { 162 | const num = Number(newValue); 163 | 164 | if (isNaN(num)) { 165 | new Notice("Timing must be a number."); 166 | return; 167 | } 168 | 169 | if (!Number.isInteger(num) || num < 1) { 170 | new Notice("Stages must be an integer larger than 0."); 171 | return; 172 | } 173 | 174 | this.settings.timings[ind] = num; 175 | update(this.settings); 176 | }); 177 | }); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/algorithms/supermemo.ts: -------------------------------------------------------------------------------- 1 | import { DateUtils } from "src/utils"; 2 | import SrsAlgorithm from "./../algorithms"; 3 | import { RepetitionItem, ReviewResult } from "./../data"; 4 | 5 | interface Sm2Data { 6 | ease: number; 7 | lastInterval: number; 8 | iteration: number; 9 | } 10 | 11 | const Sm2Options: string[] = [ 12 | "Blackout", 13 | "Incorrect", 14 | "Incorrect (Easy)", 15 | "Hard", 16 | "Medium", 17 | "Easy", 18 | ]; 19 | 20 | /** 21 | * Implementation of the SM2 algorithm as described at 22 | * https://www.supermemo.com/en/archives1990-2015/english/ol/sm2 23 | */ 24 | export class Sm2Algorithm extends SrsAlgorithm { 25 | defaultSettings(): any { 26 | return {}; 27 | } 28 | 29 | defaultData(): Sm2Data { 30 | return { 31 | ease: 2.5, 32 | lastInterval: 0, 33 | iteration: 1, 34 | }; 35 | } 36 | 37 | srsOptions(): String[] { 38 | return Sm2Options; 39 | } 40 | 41 | onSelection( 42 | item: RepetitionItem, 43 | optionStr: string, 44 | repeat: boolean 45 | ): ReviewResult { 46 | const data = item.data as Sm2Data; 47 | 48 | const interval = function (n: number): number { 49 | if (n === 1) { 50 | return 1; 51 | } else if (n === 2) { 52 | return 6; 53 | } else { 54 | return Math.round(data.lastInterval * data.ease); 55 | } 56 | }; 57 | 58 | const q = Sm2Options.indexOf(optionStr); 59 | 60 | if (repeat) { 61 | if (q < 3) { 62 | return { correct: false, nextReview: -1 }; 63 | } else { 64 | return { correct: true, nextReview: -1 }; 65 | } 66 | } 67 | 68 | if (q < 3) { 69 | data.iteration = 1; 70 | const nextReview = interval(data.iteration); 71 | data.lastInterval = nextReview; 72 | return { 73 | correct: false, 74 | nextReview: nextReview * DateUtils.DAYS_TO_MILLIS, 75 | }; 76 | } else { 77 | const nextReview = interval(data.iteration); 78 | data.iteration += 1; 79 | data.ease = data.ease + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)); 80 | if (data.ease < 1.3) { 81 | data.ease = 1.3; 82 | } 83 | 84 | data.lastInterval = nextReview; 85 | 86 | return { 87 | correct: true, 88 | nextReview: nextReview * DateUtils.DAYS_TO_MILLIS, 89 | }; 90 | } 91 | } 92 | 93 | displaySettings( 94 | containerEl: HTMLElement, 95 | update: (settings: any) => void 96 | ): void {} 97 | } 98 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import ObsidianSrsPlugin from "./main"; 2 | import { ItemInfoModal } from "./modals/info"; 3 | 4 | export default class Commands { 5 | plugin: ObsidianSrsPlugin; 6 | 7 | constructor(plugin: ObsidianSrsPlugin) { 8 | this.plugin = plugin; 9 | } 10 | 11 | addCommands() { 12 | const plugin = this.plugin; 13 | 14 | // plugin.addCommand({ 15 | // id: "view-item-info", 16 | // name: "Item Info", 17 | // checkCallback: (checking: boolean) => { 18 | // let file = plugin.app.workspace.getActiveFile(); 19 | // if (file) { 20 | // if (plugin.store.isTracked(file.path)) { 21 | // if (!checking) { 22 | // new ItemInfoModal(plugin, file).open(); 23 | // } 24 | // return true; 25 | // } 26 | // } 27 | // return false; 28 | // }, 29 | // }); 30 | 31 | plugin.addCommand({ 32 | id: "track-file", 33 | name: "Track Note", 34 | checkCallback: (checking: boolean) => { 35 | let file = plugin.app.workspace.getActiveFile(); 36 | if (file != null) { 37 | if (!plugin.store.isTracked(file.path)) { 38 | if (!checking) { 39 | plugin.store.trackFile(file.path); 40 | plugin.updateStatusBar(); 41 | } 42 | return true; 43 | } 44 | } 45 | return false; 46 | }, 47 | }); 48 | 49 | plugin.addCommand({ 50 | id: "untrack-file", 51 | name: "Untrack Note", 52 | checkCallback: (checking: boolean) => { 53 | let file = plugin.app.workspace.getActiveFile(); 54 | if (file != null) { 55 | if (plugin.store.isTracked(file.path)) { 56 | if (!checking) { 57 | plugin.store.untrackFile(file.path); 58 | plugin.updateStatusBar(); 59 | } 60 | return true; 61 | } 62 | } 63 | return false; 64 | }, 65 | }); 66 | 67 | plugin.addCommand({ 68 | id: "update-file", 69 | name: "Update Note", 70 | checkCallback: (checking: boolean) => { 71 | let file = plugin.app.workspace.getActiveFile(); 72 | if (file != null) { 73 | if (plugin.store.isTracked(file.path)) { 74 | if (!checking) { 75 | plugin.store.updateItems(file.path); 76 | plugin.updateStatusBar(); 77 | } 78 | return true; 79 | } 80 | } 81 | return false; 82 | }, 83 | }); 84 | 85 | plugin.addCommand({ 86 | id: "build-queue", 87 | name: "Build Queue", 88 | callback: () => { 89 | plugin.store.buildQueue(); 90 | }, 91 | }); 92 | 93 | plugin.addCommand({ 94 | id: "review-view", 95 | name: "Review", 96 | callback: () => { 97 | plugin.store.buildQueue(); 98 | const item = plugin.store.getNext(); 99 | const state: any = { mode: "empty" }; 100 | if (item != null) { 101 | const path = plugin.store.getFilePath(item); 102 | if (path != null) { 103 | state.file = path; 104 | state.item = plugin.store.getNextId(); 105 | state.mode = "question"; 106 | } 107 | } 108 | const leaf = plugin.app.workspace.getUnpinnedLeaf(); 109 | leaf.setViewState({ 110 | type: "store-review-view", 111 | state: state, 112 | }); 113 | leaf.setPinned(true); 114 | plugin.app.workspace.setActiveLeaf(leaf); 115 | }, 116 | }); 117 | } 118 | 119 | addDebugCommands() { 120 | console.log("Injecting debug commands..."); 121 | const plugin = this.plugin; 122 | 123 | plugin.addCommand({ 124 | id: "debug-print-view-state", 125 | name: "Print View State", 126 | callback: () => { 127 | console.log(plugin.app.workspace.activeLeaf.getViewState()); 128 | }, 129 | }); 130 | 131 | plugin.addCommand({ 132 | id: "debug-print-eph-state", 133 | name: "Print Ephemeral State", 134 | callback: () => { 135 | console.log( 136 | plugin.app.workspace.activeLeaf.getEphemeralState() 137 | ); 138 | }, 139 | }); 140 | 141 | plugin.addCommand({ 142 | id: "debug-print-queue", 143 | name: "Print Queue", 144 | callback: () => { 145 | console.log(plugin.store.data.queue); 146 | console.log( 147 | "There are " + 148 | plugin.store.data.queue.length + 149 | " items in queue." 150 | ); 151 | console.log( 152 | plugin.store.data.newAdded + " new where added to today." 153 | ); 154 | }, 155 | }); 156 | 157 | plugin.addCommand({ 158 | id: "debug-clear-queue", 159 | name: "Clear Queue", 160 | callback: () => { 161 | plugin.store.data.queue = []; 162 | }, 163 | }); 164 | 165 | plugin.addCommand({ 166 | id: "debug-queue-all", 167 | name: "Queue All", 168 | callback: () => { 169 | plugin.store.data.queue = []; 170 | for (let i = 0; i < plugin.store.data.items.length; i++) { 171 | if (plugin.store.data.items[i] != null) { 172 | plugin.store.data.queue.push(i); 173 | } 174 | } 175 | console.log("Queue Size: " + plugin.store.queueSize()); 176 | }, 177 | }); 178 | 179 | plugin.addCommand({ 180 | id: "debug-print-data", 181 | name: "Print Data", 182 | callback: () => { 183 | console.log(plugin.store.data); 184 | }, 185 | }); 186 | 187 | plugin.addCommand({ 188 | id: "debug-reset-data", 189 | name: "Reset Data", 190 | callback: () => { 191 | console.log("Resetting data..."); 192 | plugin.store.resetData(); 193 | console.log(plugin.store.data); 194 | }, 195 | }); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/data.ts: -------------------------------------------------------------------------------- 1 | import ObsidianSrsPlugin from "./main"; 2 | import { DateUtils, MiscUtils } from "./utils"; 3 | import { DataLocation } from "./settings"; 4 | 5 | import { TFile, TFolder, Notice } from "obsidian"; 6 | 7 | const ROOT_DATA_PATH: string = "./tracked_files.json"; 8 | const PLUGIN_DATA_PATH: string = "./.obsidian/plugins/obsidian-recall/tracked_files.json"; 9 | 10 | /** 11 | * SrsData. 12 | */ 13 | interface SrsData { 14 | /** 15 | * @type {number[]} 16 | */ 17 | queue: number[]; 18 | /** 19 | * @type {number[]} 20 | */ 21 | repeatQueue: number[]; 22 | /** 23 | * @type {RepetitionItem[]} 24 | */ 25 | items: RepetitionItem[]; 26 | /** 27 | * @type {TrackedFile[]} 28 | */ 29 | trackedFiles: TrackedFile[]; 30 | /** 31 | * @type {number} 32 | */ 33 | lastQueue: number; 34 | /** 35 | * @type {0} 36 | */ 37 | newAdded: 0; 38 | } 39 | 40 | /** 41 | * RepetitionItem. 42 | */ 43 | export interface RepetitionItem { 44 | /** 45 | * @type {number} 46 | */ 47 | nextReview: number; 48 | /** 49 | * @type {number} 50 | */ 51 | fileIndex: number; 52 | /** 53 | * @type {number} 54 | */ 55 | timesReviewed: number; 56 | /** 57 | * @type {number} 58 | */ 59 | timesCorrect: number; 60 | /** 61 | * @type {number} 62 | */ 63 | errorStreak: number; // Needed to calculate leeches later on. 64 | /** 65 | * @type {any} 66 | */ 67 | data: any; // Additional data, determined by the selected algorithm. 68 | } 69 | 70 | /** 71 | * TrackedFile. 72 | */ 73 | interface TrackedFile { 74 | /** 75 | * @type {string} 76 | */ 77 | path: string; 78 | /** 79 | * @type {Record} 80 | */ 81 | items: Record; 82 | } 83 | 84 | /** 85 | * ReviewResult. 86 | */ 87 | export interface ReviewResult { 88 | /** 89 | * @type {boolean} 90 | */ 91 | correct: boolean; 92 | /** 93 | * @type {number} 94 | */ 95 | nextReview: number; 96 | } 97 | 98 | const DEFAULT_SRS_DATA: SrsData = { 99 | queue: [], 100 | repeatQueue: [], 101 | items: [], 102 | trackedFiles: [], 103 | lastQueue: 0, 104 | newAdded: 0, 105 | }; 106 | 107 | const NEW_ITEM: RepetitionItem = { 108 | nextReview: 0, 109 | fileIndex: -1, 110 | timesReviewed: 0, 111 | timesCorrect: 0, 112 | errorStreak: 0, 113 | data: {}, 114 | }; 115 | 116 | /** 117 | * DataStore. 118 | */ 119 | export class DataStore { 120 | /** 121 | * @type {SrsData} 122 | */ 123 | data: SrsData; 124 | /** 125 | * @type {ObsidianSrsPlugin} 126 | */ 127 | plugin: ObsidianSrsPlugin; 128 | /** 129 | * @type {string} 130 | */ 131 | dataPath: string; 132 | 133 | /** 134 | * constructor. 135 | * 136 | * @param {ObsidianSrsPlugin} plugin 137 | */ 138 | constructor(plugin: ObsidianSrsPlugin) { 139 | this.plugin = plugin; 140 | this.dataPath = this.getStorePath(); 141 | } 142 | 143 | /** 144 | * getStorePath. 145 | * 146 | * @returns {string} 147 | */ 148 | getStorePath(): string { 149 | const dataLocation = this.plugin.settings.dataLocation; 150 | if (dataLocation == DataLocation.PluginFolder) { 151 | return PLUGIN_DATA_PATH; 152 | } else if (dataLocation == DataLocation.RootFolder) { 153 | return ROOT_DATA_PATH; 154 | } 155 | } 156 | 157 | 158 | /** 159 | * moveStoreLocation. 160 | * 161 | * @returns {boolean} 162 | */ 163 | moveStoreLocation(): boolean { 164 | // TODO: Validate folder 165 | const adapter = this.plugin.app.vault.adapter; 166 | 167 | let newPath = this.getStorePath(); 168 | if (newPath === this.dataPath) { 169 | return false; 170 | } 171 | 172 | try { 173 | this.save(); 174 | adapter.remove(this.dataPath).then(() => { 175 | this.dataPath = newPath; 176 | new Notice("Successfully moved data file!"); 177 | return true; 178 | }, (e) => { 179 | this.dataPath = newPath; 180 | new Notice("Unable to delete old data file, please delete it manually."); 181 | console.log(e); 182 | return true; 183 | }) 184 | } catch (e) { 185 | new Notice("Unable to move data file!"); 186 | console.log(e); 187 | return false; 188 | } 189 | 190 | } 191 | 192 | /** 193 | * load. 194 | */ 195 | async load() { 196 | let adapter = this.plugin.app.vault.adapter; 197 | 198 | if (await adapter.exists(this.dataPath)) { 199 | let data = await adapter.read(this.dataPath); 200 | if (data == null) { 201 | console.log("Unable to read SRS data!"); 202 | this.data = Object.assign({}, DEFAULT_SRS_DATA); 203 | } else { 204 | console.log("Reading tracked files..."); 205 | this.data = Object.assign( 206 | Object.assign({}, DEFAULT_SRS_DATA), 207 | JSON.parse(data) 208 | ); 209 | } 210 | } else { 211 | console.log("Tracked files not found! Creating new file..."); 212 | this.data = Object.assign({}, DEFAULT_SRS_DATA); 213 | await this.plugin.app.vault.adapter.write( 214 | this.dataPath, 215 | JSON.stringify(this.data) 216 | ); 217 | } 218 | } 219 | 220 | /** 221 | * save. 222 | */ 223 | async save() { 224 | await this.plugin.app.vault.adapter.write( 225 | this.dataPath, 226 | JSON.stringify(this.data) 227 | ); 228 | } 229 | 230 | /** 231 | * Returns total number of items tracked by the SRS. 232 | */ 233 | /** 234 | * items. 235 | * 236 | * @returns {number} 237 | */ 238 | items(): number { 239 | return this.data.items.length; 240 | } 241 | 242 | /** 243 | * Returns the size of the current queue. 244 | */ 245 | /** 246 | * queueSize. 247 | * 248 | * @returns {number} 249 | */ 250 | queueSize(): number { 251 | return this.data.queue.length; 252 | } 253 | 254 | /** 255 | * repeatQueueSize. 256 | * 257 | * @returns {number} 258 | */ 259 | repeatQueueSize(): number { 260 | return this.data.repeatQueue.length; 261 | } 262 | 263 | /** 264 | * getFileIndex. 265 | * 266 | * @param {string} path 267 | * @returns {number} 268 | */ 269 | getFileIndex(path: string): number { 270 | return this.data.trackedFiles.findIndex((val, ind, obj) => { 271 | return val != null && val.path == path; 272 | }); 273 | } 274 | 275 | /** 276 | * Returns whether or not the given file path is tracked by the SRS. 277 | * @param path The path of the file. 278 | */ 279 | /** 280 | * isTracked. 281 | * 282 | * @param {string} path 283 | * @returns {boolean} 284 | */ 285 | isTracked(path: string): boolean { 286 | return this.getFileIndex(path) >= 0; 287 | } 288 | 289 | /** 290 | * isQueued. 291 | * 292 | * @param {number} item 293 | * @returns {boolean} 294 | */ 295 | isQueued(item: number): boolean { 296 | return this.data.queue.includes(item); 297 | } 298 | 299 | /** 300 | * isInRepeatQueue. 301 | * 302 | * @param {number} item 303 | * @returns {boolean} 304 | */ 305 | isInRepeatQueue(item: number): boolean { 306 | return this.data.repeatQueue.includes(item); 307 | } 308 | 309 | /** 310 | * Returns when the given item is reviewed next (in hours). 311 | */ 312 | /** 313 | * nextReview. 314 | * 315 | * @param {number} itemId 316 | * @returns {number} 317 | */ 318 | nextReview(itemId: number): number { 319 | const item = this.data.items[itemId]; 320 | if (item == null) { 321 | return -1; 322 | } 323 | 324 | const now: Date = new Date(); 325 | return (item.nextReview - now.getTime()) / (1000 * 60 * 60); 326 | } 327 | 328 | /** 329 | * getItemsOfFile. 330 | * 331 | * @param {string} path 332 | * @returns {RepetitionItem[]} 333 | */ 334 | getItemsOfFile(path: string): RepetitionItem[] { 335 | let result: RepetitionItem[] = []; 336 | const file = this.data.trackedFiles[this.getFileIndex(path)]; 337 | Object.values(file.items).forEach((item) => { 338 | result.push(this.data.items[item]); 339 | }); 340 | return result; 341 | } 342 | 343 | getFileForItem(item: RepetitionItem): TrackedFile { 344 | if (item != null) { 345 | return this.data.trackedFiles[item.fileIndex]; 346 | } 347 | return null; 348 | } 349 | 350 | /** 351 | * getNext. 352 | * 353 | * @returns {RepetitionItem | null} 354 | */ 355 | getNext(): RepetitionItem | null { 356 | const id = this.getNextId(); 357 | if (id != null) { 358 | return this.data.items[id]; 359 | } 360 | 361 | return null; 362 | } 363 | 364 | /** 365 | * getNextId. 366 | * 367 | * @returns {number | null} 368 | */ 369 | getNextId(): number | null { 370 | if (this.queueSize() > 0) { 371 | return this.data.queue[0]; 372 | } else if (this.data.repeatQueue.length > 0) { 373 | return this.data.repeatQueue[0]; 374 | } else { 375 | return null; 376 | } 377 | } 378 | 379 | /** 380 | * getFilePath. 381 | * 382 | * @param {RepetitionItem} item 383 | * @returns {string | null} 384 | */ 385 | getFilePath(item: RepetitionItem): string | null { 386 | return this.data.trackedFiles[item.fileIndex].path; 387 | } 388 | 389 | /** 390 | * reviewId. 391 | * 392 | * @param {number} itemId 393 | * @param {string} option 394 | */ 395 | reviewId(itemId: number, option: string) { 396 | const item = this.data.items[itemId]; 397 | if (item == null) { 398 | return -1; 399 | } 400 | 401 | if (this.isInRepeatQueue(itemId)) { 402 | let result = this.plugin.algorithm.onSelection(item, option, true); 403 | 404 | this.data.repeatQueue.remove(itemId); 405 | if (!result.correct) { 406 | this.data.repeatQueue.push(itemId); // Re-add until correct. 407 | } 408 | } else { 409 | let result = this.plugin.algorithm.onSelection(item, option, false); 410 | 411 | item.nextReview = DateUtils.fromNow(result.nextReview).getTime(); 412 | item.timesReviewed += 1; 413 | this.data.queue.remove(itemId); 414 | if (result.correct) { 415 | item.timesCorrect += 1; 416 | item.errorStreak = 0; 417 | } else { 418 | item.errorStreak += 1; 419 | 420 | if (this.plugin.settings.repeatItems) { 421 | this.data.repeatQueue.push(itemId); 422 | } 423 | } 424 | } 425 | } 426 | 427 | /** 428 | * untrackFilesInFolderPath. 429 | * 430 | * @param {string} path 431 | * @param {boolean} recursive 432 | */ 433 | untrackFilesInFolderPath(path: string, recursive?: boolean) { 434 | const folder: TFolder = this.plugin.app.vault.getAbstractFileByPath( 435 | path 436 | ) as TFolder; 437 | 438 | if (folder != null) { 439 | this.untrackFilesInFolder(folder, recursive); 440 | } 441 | } 442 | 443 | /** 444 | * untrackFilesInFolder. 445 | * 446 | * @param {TFolder} folder 447 | * @param {boolean} recursive 448 | */ 449 | untrackFilesInFolder(folder: TFolder, recursive?: boolean) { 450 | if (recursive == null) recursive = true; 451 | 452 | let totalRemoved: number = 0; 453 | folder.children.forEach((child) => { 454 | if (child instanceof TFolder) { 455 | if (recursive) { 456 | this.untrackFilesInFolder(child, recursive); 457 | } 458 | } else if (child instanceof TFile) { 459 | if (this.isTracked(child.path)) { 460 | let removed = this.untrackFile(child.path, false); 461 | totalRemoved += removed; 462 | } 463 | } 464 | }); 465 | } 466 | 467 | /** 468 | * trackFilesInFolderPath. 469 | * 470 | * @param {string} path 471 | * @param {boolean} recursive 472 | */ 473 | trackFilesInFolderPath(path: string, recursive?: boolean) { 474 | const folder: TFolder = this.plugin.app.vault.getAbstractFileByPath( 475 | path 476 | ) as TFolder; 477 | 478 | if (folder != null) { 479 | this.trackFilesInFolder(folder, recursive); 480 | } 481 | } 482 | 483 | /** 484 | * trackFilesInFolder. 485 | * 486 | * @param {TFolder} folder 487 | * @param {boolean} recursive 488 | */ 489 | trackFilesInFolder(folder: TFolder, recursive?: boolean) { 490 | if (recursive == null) recursive = true; 491 | 492 | let totalAdded: number = 0; 493 | let totalRemoved: number = 0; 494 | folder.children.forEach((child) => { 495 | if (child instanceof TFolder) { 496 | if (recursive) { 497 | this.trackFilesInFolder(child, recursive); 498 | } 499 | } else if (child instanceof TFile) { 500 | if (!this.isTracked(child.path)) { 501 | let { added, removed } = this.trackFile(child.path, false); 502 | totalAdded += added; 503 | totalRemoved += removed; 504 | } 505 | } 506 | }); 507 | 508 | new Notice( 509 | "Added " + 510 | totalAdded + 511 | " new items, removed " + 512 | totalRemoved + 513 | " items." 514 | ); 515 | } 516 | 517 | /** 518 | * trackFile. 519 | * 520 | * @param {string} path 521 | * @param {boolean} notice 522 | * @returns {{ added: number; removed: number } | null} 523 | */ 524 | trackFile( 525 | path: string, 526 | notice?: boolean 527 | ): { added: number; removed: number } | null { 528 | this.data.trackedFiles.push({ 529 | path: path, 530 | items: {}, 531 | }); 532 | let data = this.updateItems(path, notice); 533 | console.log("Tracked: " + path); 534 | this.plugin.updateStatusBar(); 535 | return data; 536 | } 537 | 538 | /** 539 | * untrackFile. 540 | * 541 | * @param {string} path 542 | * @param {boolean} notice 543 | * @returns {number} 544 | */ 545 | untrackFile(path: string, notice?: boolean): number { 546 | if (notice == null) notice = true; 547 | 548 | const index = this.getFileIndex(path); 549 | 550 | if (index == -1) { 551 | return; 552 | } 553 | 554 | const trackedFile = this.data.trackedFiles[index]; 555 | const numItems = Object.keys(trackedFile.items).length; 556 | 557 | for (let key in trackedFile.items) { 558 | const ind = trackedFile.items[key]; 559 | if (this.isQueued(ind)) { 560 | this.data.queue.remove(ind); 561 | } 562 | if (this.isInRepeatQueue(ind)) { 563 | this.data.repeatQueue.remove(ind); 564 | } 565 | this.data.items[ind] = null; 566 | } 567 | 568 | if (notice) { 569 | new Notice("Untracked " + numItems + " items!"); 570 | } 571 | 572 | this.data.trackedFiles[index] = null; 573 | this.plugin.updateStatusBar(); 574 | console.log("Untracked: " + path); 575 | } 576 | 577 | /** 578 | * updateItems. 579 | * 580 | * @param {string} path 581 | * @param {boolean} notice 582 | * @returns {{ added: number; removed: number } | null} 583 | */ 584 | updateItems( 585 | path: string, 586 | notice?: boolean 587 | ): { added: number; removed: number } | null { 588 | if (notice == null) notice = true; 589 | 590 | const ind = this.getFileIndex(path); 591 | if (ind == -1) { 592 | console.log("Attempt to update untracked file: " + path); 593 | return; 594 | } 595 | const trackedFile = this.data.trackedFiles[ind]; 596 | 597 | const file = this.plugin.app.vault.getAbstractFileByPath(path) as TFile; 598 | if (!file) { 599 | console.log("Could not find file: " + path); 600 | return; 601 | } 602 | 603 | let added = 0; 604 | let removed = 0; 605 | 606 | let newItems: Record = {}; 607 | if ("file" in trackedFile.items) { 608 | newItems["file"] = trackedFile.items["file"]; 609 | } else { 610 | let newItem: RepetitionItem = Object.assign({}, NEW_ITEM); 611 | newItem.data = Object.assign(this.plugin.algorithm.defaultData()); 612 | newItem.fileIndex = ind; 613 | newItems["file"] = this.data.items.push(newItem) - 1; 614 | added += 1; 615 | } 616 | 617 | for (let key in trackedFile.items) { 618 | if (!(key in newItems)) { 619 | const itemInd = trackedFile.items[key]; 620 | if (this.isQueued(itemInd)) { 621 | this.data.queue.remove(itemInd); 622 | } 623 | if (this.isInRepeatQueue(itemInd)) { 624 | this.data.repeatQueue.remove(itemInd); 625 | } 626 | this.data.items[ind] = null; 627 | removed += 1; 628 | } 629 | } 630 | trackedFile.items = newItems; 631 | 632 | if (notice) { 633 | new Notice( 634 | "Added " + added + " new items, removed " + removed + " items." 635 | ); 636 | } 637 | return { added, removed }; 638 | } 639 | 640 | /** 641 | * renameTrackedFile. 642 | * 643 | * @param {string} old 644 | * @param {string} newPath 645 | */ 646 | renameTrackedFile(old: string, newPath: string) { 647 | const index = this.getFileIndex(old); 648 | // Sanity check 649 | if (index == -1) { 650 | console.log("Renamed file is not tracked!"); 651 | return; 652 | } 653 | 654 | const fileData = this.data.trackedFiles[index]; 655 | fileData.path = newPath; 656 | this.data.trackedFiles[index] = fileData; 657 | 658 | console.log("Updated tracking: " + old + " -> " + newPath); 659 | } 660 | 661 | /** 662 | * buildQueue. 663 | */ 664 | async buildQueue() { 665 | console.log("Building queue..."); 666 | const data = this.data; 667 | const maxNew = this.plugin.settings.maxNewPerDay; 668 | const now: Date = new Date(); 669 | 670 | if (now.getDate() != new Date(this.data.lastQueue).getDate()) { 671 | this.data.newAdded = 0; 672 | } 673 | 674 | let oldAdd = 0; 675 | let newAdd = 0; 676 | 677 | let untrackedFiles = 0; 678 | let removedItems = 0; 679 | 680 | await Promise.all(this.data.items.map((item, id) => { 681 | if (item != null) { 682 | let file = this.getFileForItem(item); 683 | return this.verify(file).then((exists) => { 684 | if (!exists) { 685 | removedItems += this.untrackFile(file.path, false); 686 | untrackedFiles += 1; 687 | } 688 | else { 689 | if (item.nextReview == 0) { 690 | // This is a new item. 691 | if (maxNew == -1 || data.newAdded < maxNew) { 692 | item.nextReview = now.getTime(); 693 | data.newAdded += 1; 694 | data.queue.push(id); 695 | newAdd += 1; 696 | } 697 | } else if (item.nextReview <= now.getTime()) { 698 | if (this.isInRepeatQueue(id)) { 699 | data.repeatQueue.remove(id); 700 | } 701 | if (!this.isQueued(id)) { 702 | data.queue.push(id); 703 | oldAdd += 1; 704 | } 705 | } 706 | } 707 | }); 708 | } 709 | })); 710 | 711 | this.data.lastQueue = now.getTime(); 712 | if (this.plugin.settings.shuffleQueue && oldAdd + newAdd > 0) { 713 | MiscUtils.shuffle(data.queue); 714 | } 715 | 716 | console.log( 717 | "Added " + 718 | (oldAdd + newAdd) + 719 | " files to review queue, with " + 720 | newAdd + 721 | " new!" 722 | ); 723 | 724 | if (untrackedFiles > 0) { 725 | new Notice("Recall: Untracked " + untrackedFiles + " files with a total of " + removedItems + " items while building queue!"); 726 | } 727 | } 728 | 729 | /** 730 | * Verify that the file of this item still exists. 731 | * 732 | * @param {number} itemId 733 | */ 734 | verify(file: TrackedFile): Promise { 735 | const adapter = this.plugin.app.vault.adapter; 736 | if (file != null) { 737 | return adapter.exists(file.path).catch( 738 | (reason) => { 739 | console.error("Unable to verify file: ", file.path); 740 | return false; 741 | } 742 | ); 743 | } 744 | } 745 | 746 | /** 747 | * resetData. 748 | */ 749 | resetData() { 750 | this.data = Object.assign({}, DEFAULT_SRS_DATA); 751 | } 752 | } 753 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { TFolder, TFile, Plugin } from "obsidian"; 2 | import { DataStore } from "./data"; 3 | import { ReviewView } from "./view"; 4 | import SrsAlgorithm from "./algorithms"; 5 | import SrsSettingTab from "./settings"; 6 | import { SrsPluginSettings, DEFAULT_SETTINGS, algorithms } from "./settings"; 7 | import Commands from "./commands"; 8 | import { ItemSelector, SelectorType } from './selection'; 9 | 10 | const DEBUG: boolean = true; 11 | 12 | export default class ObsidianSrsPlugin extends Plugin { 13 | settings: SrsPluginSettings; 14 | store: DataStore; 15 | algorithm: SrsAlgorithm; 16 | 17 | commands: Commands; 18 | 19 | barItem: HTMLElement; 20 | 21 | async onload() { 22 | console.log("Loading Obsidian Recall..."); 23 | if (DEBUG) console.log("DEBUG"); 24 | 25 | await this.loadSettings(); 26 | 27 | this.algorithm = algorithms[this.settings.algorithm]; 28 | this.algorithm.updateSettings(this.settings.algorithmSettings); 29 | 30 | this.store = new DataStore(this); 31 | await this.store.load(); 32 | this.store.buildQueue(); 33 | 34 | this.commands = new Commands(this); 35 | this.commands.addCommands(); 36 | if (DEBUG) { 37 | this.commands.addDebugCommands(); 38 | } 39 | 40 | this.barItem = this.addStatusBarItem(); 41 | this.updateStatusBar(); 42 | 43 | this.addSettingTab(new SrsSettingTab(this.app, this)); 44 | 45 | this.registerEvents(); 46 | 47 | this.registerView("store-review-view", (leaf) => { 48 | return new ReviewView(leaf, this); 49 | }); 50 | 51 | this.registerInterval( 52 | window.setInterval(() => this.store.save(), 5 * 60 * 1000) 53 | ); 54 | } 55 | 56 | onunload() { 57 | console.log("Unloading Obsidian Recall. Saving tracked files..."); 58 | this.store.save(); 59 | } 60 | 61 | async loadSettings() { 62 | this.settings = Object.assign(DEFAULT_SETTINGS, await this.loadData()); 63 | 64 | // Cast selectors to actual selector instances for suitable methods. 65 | // There's probably some better way to do this, maybe? 66 | let selectors: ItemSelector[] = []; 67 | this.settings.itemSelectors.forEach((selector) => { 68 | selectors.push(Object.assign(new ItemSelector(), selector)); 69 | }); 70 | 71 | this.settings.itemSelectors = selectors; 72 | if (DEBUG) { 73 | console.log("Loaded settings: ", this.settings); 74 | } 75 | } 76 | 77 | async saveSettings() { 78 | await this.saveData(this.settings); 79 | } 80 | 81 | updateStatusBar() { 82 | let view = this.app.workspace.getActiveViewOfType(ReviewView); 83 | this.barItem.removeClasses(["srs-bar-tracked"]); 84 | if (view) { 85 | let text = 86 | "Remaining: " + 87 | (this.store.queueSize() + this.store.repeatQueueSize()); 88 | 89 | this.barItem.setText(text); 90 | } else { 91 | let file = this.app.workspace.getActiveFile(); 92 | let text = "Queue: " + this.store.queueSize(); 93 | 94 | if (file == null) { 95 | this.barItem.setText(text); 96 | } else { 97 | if (this.store.isTracked(file.path)) { 98 | const items = this.store.getItemsOfFile(file.path); 99 | let mostRecent = Number.MAX_SAFE_INTEGER; 100 | items.forEach((item) => { 101 | if (item.nextReview < mostRecent) { 102 | mostRecent = item.nextReview; 103 | } 104 | }); 105 | 106 | const now = new Date(); 107 | let diff = (mostRecent - now.getTime()) / (1000 * 60 * 60); 108 | if (diff <= 0) { 109 | text = "Next Review: Now!"; 110 | } else { 111 | if (diff >= 24) { 112 | diff /= 24; 113 | text = "Next Review: " + diff.toFixed(1) + " days"; 114 | } else { 115 | text = "Next Review: " + diff.toFixed(1) + " hours"; 116 | } 117 | } 118 | 119 | this.barItem.setText(text); 120 | this.barItem.addClass("srs-bar-tracked"); 121 | } else { 122 | this.barItem.setText(text); 123 | } 124 | } 125 | } 126 | } 127 | 128 | registerEvents() { 129 | this.registerEvent( 130 | this.app.workspace.on("file-open", (f) => { 131 | this.updateStatusBar(); 132 | }) 133 | ); 134 | 135 | this.registerEvent( 136 | this.app.workspace.on("file-menu", (menu, file, source, leaf) => { 137 | if (file instanceof TFolder) { 138 | const folder = file as TFolder; 139 | 140 | menu.addItem((item) => { 141 | item.setIcon("plus-with-circle"); 142 | item.setTitle("Track All Notes"); 143 | item.onClick((evt) => { 144 | this.store.trackFilesInFolder(folder); 145 | }); 146 | }); 147 | 148 | menu.addItem((item) => { 149 | item.setIcon("minus-with-circle"); 150 | item.setTitle("Untrack All Notes"); 151 | item.onClick((evt) => { 152 | this.store.untrackFilesInFolder(folder); 153 | }); 154 | }); 155 | } else if (file instanceof TFile) { 156 | if (this.store.isTracked(file.path)) { 157 | menu.addItem((item) => { 158 | item.setIcon("minus-with-circle"); 159 | item.setTitle("Untrack Note"); 160 | item.onClick((evt) => { 161 | this.store.untrackFile(file.path); 162 | }); 163 | }); 164 | } else { 165 | menu.addItem((item) => { 166 | item.setIcon("plus-with-circle"); 167 | item.setTitle("Track Note"); 168 | item.onClick((evt) => { 169 | this.store.trackFile(file.path); 170 | }); 171 | }); 172 | } 173 | } 174 | }) 175 | ); 176 | 177 | this.registerEvent( 178 | this.app.vault.on("rename", (file, old) => { 179 | this.store.renameTrackedFile(old, file.path); 180 | }) 181 | ); 182 | 183 | this.registerEvent( 184 | this.app.vault.on("delete", (file) => { 185 | this.store.untrackFile(file.path); 186 | }) 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/modals/confirm.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, ButtonComponent } from "obsidian"; 2 | import { DataStore } from "../data"; 3 | 4 | type ConfirmCallback = (confirmed: boolean) => void; 5 | 6 | export default class ConfirmModal extends Modal { 7 | message: string; 8 | callback: ConfirmCallback; 9 | 10 | constructor(app: App, message: string, callback: ConfirmCallback) { 11 | super(app); 12 | this.message = message; 13 | this.callback = callback; 14 | } 15 | 16 | onOpen() { 17 | let { contentEl } = this; 18 | 19 | contentEl.createEl("p").setText(this.message); 20 | 21 | const buttonDiv = contentEl.createDiv("srs-flex-row"); 22 | 23 | new ButtonComponent(buttonDiv) 24 | .setButtonText("Confirm") 25 | .onClick(() => { 26 | this.callback(true); 27 | this.close(); 28 | }) 29 | .setCta(); 30 | 31 | new ButtonComponent(buttonDiv).setButtonText("Cancel").onClick(() => { 32 | this.callback(false); 33 | this.close(); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/modals/info.ts: -------------------------------------------------------------------------------- 1 | import { Modal, TFile } from "obsidian"; 2 | import ObsidianSrsPlugin from "src/main"; 3 | import { DataStore } from "../data"; 4 | 5 | export class ItemInfoModal extends Modal { 6 | plugin: ObsidianSrsPlugin; 7 | file: TFile; 8 | 9 | constructor(plugin: ObsidianSrsPlugin, file: TFile) { 10 | super(plugin.app); 11 | this.plugin = plugin; 12 | this.file = file; 13 | } 14 | 15 | onOpen() { 16 | const { contentEl, plugin } = this; 17 | //TODO: Implement Item info. 18 | } 19 | 20 | onClose() { 21 | let { contentEl } = this; 22 | contentEl.empty(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/selection.ts: -------------------------------------------------------------------------------- 1 | import {Loc, Pos, SectionCache, CachedMetadata, Notice} from 'obsidian'; 2 | import { BlockUtils, MiscUtils } from './utils'; 3 | 4 | export interface IdInsert { 5 | id: string, 6 | pos: Loc 7 | } 8 | 9 | export interface ItemContent { 10 | question: string; 11 | answer: string; 12 | } 13 | 14 | export enum SelectorType { 15 | Split, 16 | SingleRegExp, 17 | SeparateRegExp, 18 | NextBlock, 19 | Until, 20 | UntilNot 21 | } 22 | 23 | type BlockType = 'paragraph' | 'heading' | 'yaml' | 'thematicBreak' | 'list' | 'blockquote' | 'code'; 24 | 25 | type SplitData = { 26 | selectorType: SelectorType.Split; 27 | markerBlocks: BlockType[]; 28 | splitString: string; 29 | } 30 | 31 | type SingleRegExpData = { 32 | selectorType: SelectorType.SingleRegExp; 33 | markerBlocks: BlockType[]; 34 | regExp: RegExp; 35 | } 36 | 37 | type SeparateRegExpData = { 38 | selectorType: SelectorType.SeparateRegExp; 39 | markerBlocks: BlockType[]; 40 | questionRegExp: RegExp; 41 | answerRegExp: RegExp; 42 | } 43 | 44 | type NextBlockData = { 45 | selectorType: SelectorType.NextBlock; 46 | markerBlocks: BlockType[]; 47 | } 48 | 49 | type UntilData = { 50 | selectorType: SelectorType.Until; 51 | markerBlocks: BlockType[]; 52 | answerBlocks: BlockType[]; 53 | } 54 | 55 | type UntilNotData = { 56 | selectorType: SelectorType.UntilNot; 57 | markerBlocks: BlockType[]; 58 | answerBlocks: BlockType[]; 59 | } 60 | 61 | type SingleBlockData = SplitData | SingleRegExpData | SeparateRegExpData; 62 | type MultipleBlocksData = NextBlockData | UntilData | UntilNotData; 63 | type SelectorData = SingleBlockData | MultipleBlocksData; 64 | 65 | type ProcessorResult = {item: ItemContent, usedSections: number[] } | null; 66 | type Processor = (data: SelectorData, index: number, s: SectionCache[], content: string, metadata: CachedMetadata) => ProcessorResult; 67 | 68 | export class ItemSelector { 69 | 70 | private data: SelectorData; 71 | private processors: Record = { 72 | [SelectorType.NextBlock]: processNextBlock, 73 | [SelectorType.Until]: processUntil, 74 | [SelectorType.UntilNot]: processUntilNot, 75 | [SelectorType.Split]: processSplit, 76 | [SelectorType.SeparateRegExp]: processSeparateRegExp, 77 | [SelectorType.SingleRegExp]: processSingleRegExp, 78 | }; 79 | 80 | constructor() { 81 | this.data = { 82 | markerBlocks: ['heading'] 83 | }; 84 | } 85 | 86 | public process(sections: SectionCache[], inserts: IdInsert[], content: string, metadata: CachedMetadata): Record { 87 | // Write general logic here, rename question/matched blocks to marker blocks 88 | // and let processors handle each detection. 89 | let items: Record = {}; 90 | let usedSections: number[] = []; 91 | 92 | for (let i = 0; i < sections.length; i++) { 93 | let section: SectionCache = sections[i]; 94 | 95 | if (this.data.markerBlocks.contains(section.type)) { 96 | let result = this.processors[this.data.selectorType](this.data, sections, content, metadata); 97 | if (result != null) { 98 | i = result.usedSections.sort((a, b) => b - a)[0]; 99 | } 100 | } 101 | } 102 | 103 | usedSections.sort((a, b) => b - a); 104 | usedSections.forEach((i) => sections.splice(i, 1)); 105 | 106 | return items; 107 | } 108 | 109 | public processSingle(sections: SectionCache[], inserts: IdInsert[], content: string, metadata: CachedMetadata): Record { 110 | 111 | 112 | 113 | } 114 | } 115 | /* 116 | export enum MultipleBlockAnswerType { 117 | NextBlock, 118 | Until, 119 | UntilNot, 120 | } 121 | 122 | 123 | export class MultipleBlockSelector extends ItemSelector { 124 | 125 | 126 | constructor() { 127 | super(SelectorType.MultipleBlocks); 128 | this.answerType = MultipleBlockAnswerType.NextBlock; 129 | this.markerBlocks = ['heading']; 130 | } 131 | 132 | 133 | public setQuestionBlocks(...blocks: BlockType[]): MultipleBlockSelector { 134 | this.markerBlocks = blocks; 135 | return this; 136 | } 137 | 138 | public setModeNextBlock(): MultipleBlockSelector { 139 | this.answerType = MultipleBlockAnswerType.NextBlock; 140 | return this; 141 | } 142 | 143 | public setModeUntil(...answerBlocks: BlockType[]): MultipleBlockSelector { 144 | this.answerType = MultipleBlockAnswerType.Until; 145 | this.answerBlocks = answerBlocks; 146 | return this; 147 | } 148 | 149 | public setModeUntilNot(...answerBlocks: BlockType[]): MultipleBlockSelector { 150 | this.answerType = MultipleBlockAnswerType.UntilNot; 151 | this.answerBlocks = answerBlocks; 152 | return this; 153 | } 154 | } 155 | 156 | export enum SingleBlockQuestionType { 157 | Split, 158 | SingleRegExp, 159 | SeparateRegExp 160 | } 161 | 162 | 163 | export class SingleBlockSelector extends ItemSelector { 164 | 165 | private markerBlocks: BlockType[]; 166 | private questionType: SingleBlockQuestionType; 167 | private splitString: string | undefined; 168 | private qaRegExp: RegExp | undefined; 169 | private questionRegExp: RegExp | undefined; 170 | private answerRegExp: RegExp | undefined; 171 | 172 | constructor() { 173 | super(SelectorType.MultipleBlocks); 174 | this.markerBlocks = ['paragraph', 'heading', 'blockquote']; 175 | this.questionType = SingleBlockQuestionType.Split; 176 | this.splitString = "::"; 177 | } 178 | 179 | 180 | public setMatchedBlocks(...blocks: BlockType[]): SingleBlockSelector { 181 | this.markerBlocks = blocks; 182 | return this; 183 | } 184 | 185 | public setSplit(splitStr: string): SingleBlockSelector { 186 | this.questionType = SingleBlockQuestionType.Split; 187 | this.splitString = splitStr; 188 | return this; 189 | } 190 | 191 | public setSingleRegExp(exp: RegExp): SingleBlockSelector { 192 | 193 | if (MiscUtils.getRegExpGroups(exp) >= 2) { 194 | this.questionType = SingleBlockQuestionType.SingleRegExp; 195 | this.qaRegExp = exp; 196 | } else { 197 | new Notice("Single block regular expression needs at least 2 capturing groups!"); 198 | } 199 | 200 | return this; 201 | } 202 | 203 | public setMultipleRegExp(questionExp: RegExp, answerExp: RegExp): SingleBlockSelector { 204 | 205 | if (MiscUtils.getRegExpGroups(questionExp) < 1) { 206 | new Notice("Question regular expression must have at least one capturing group!"); 207 | return this; 208 | } 209 | if (MiscUtils.getRegExpGroups(answerExp) < 1) { 210 | new Notice("Answer regular expression must have at least one capturing group!"); 211 | return this; 212 | } 213 | 214 | this.questionType = SingleBlockQuestionType.SeparateRegExp; 215 | this.questionRegExp = questionExp; 216 | this.answerRegExp = answerExp; 217 | 218 | return this; 219 | } 220 | } 221 | */ 222 | 223 | function processNextBlock(rawData: SelectorData, index: number, sections: SectionCache[], content: string, metadata: CachedMetadata): ProcessorResult { 224 | 225 | let data: NextBlockData = rawData; 226 | let section = sections[index]; 227 | let usedSections: number[] = [index, index + 1]; 228 | let questionContent = content.slice(section.position.start.offset, section.position.end.offset); 229 | 230 | if (index + 1 < sections.length) { 231 | let nextBlock = sections[index + 1]; 232 | let answerContent = content.slice(nextBlock.position.start.offset, nextBlock.position.end.offset); 233 | 234 | let itemContent = { 235 | question: questionContent, 236 | answer: answerContent 237 | }; 238 | 239 | return { 240 | item: itemContent, 241 | usedSections: usedSections 242 | } 243 | } 244 | 245 | return null; 246 | } 247 | 248 | function processUntil(rawData: SelectorData, index: number, sections: SectionCache[], content: string, metadata: CachedMetadata): ProcessorResult { 249 | 250 | let data: UntilData = rawData; 251 | let section = sections[index]; 252 | let questionContent = content.slice(section.position.start.offset, section.position.end.offset); 253 | 254 | let start: Loc | null = null; 255 | let end: Loc | null = null; 256 | let used: number[] = [index]; 257 | for (let j = index + 1; j < sections.length; j++) { 258 | let block = sections[j]; 259 | if (data.answerBlocks.contains(block.type)) { 260 | break; 261 | } 262 | else { 263 | if (start === null) { 264 | start = block.position.start; 265 | } 266 | end = block.position.end; 267 | used.push(j); 268 | } 269 | } 270 | 271 | if (start !== null) { 272 | let answerContent = content.slice(start.offset, end.offset); 273 | 274 | let itemContent = { 275 | question: questionContent, 276 | answer: answerContent 277 | }; 278 | 279 | return { 280 | item: itemContent, 281 | usedSections: used, 282 | } 283 | } 284 | 285 | return null; 286 | } 287 | 288 | function processUntilNot(rawData: SelectorData, index: number, sections: SectionCache[], content: string, metadata: CachedMetadata): ProcessorResult { 289 | 290 | let data: UntilNotData = rawData; 291 | let section = sections[index]; 292 | let questionContent = content.slice(section.position.start.offset, section.position.end.offset); 293 | 294 | let start: Loc | null = null; 295 | let end: Loc | null = null; 296 | let used: number[] = [index]; 297 | for (let j = index + 1; j < sections.length; j++) { 298 | let block = sections[j]; 299 | if (!data.answerBlocks.contains(block.type)) { 300 | break; 301 | } 302 | else { 303 | if (start === null) { 304 | start = block.position.start; 305 | } 306 | end = block.position.end; 307 | used.push(j); 308 | } 309 | } 310 | 311 | if (start !== null) { 312 | let answerContent = content.slice(start.offset, end.offset); 313 | 314 | let itemContent = { 315 | question: questionContent, 316 | answer: answerContent 317 | }; 318 | 319 | return { 320 | item: itemContent, 321 | usedSections: used, 322 | } 323 | } 324 | 325 | return null; 326 | } 327 | 328 | function processSplit(rawData: SelectorData, sections: SectionCache[], inserts: IdInsert[], content: string, metadata: CachedMetadata): Record { 329 | let data: SplitData = rawData; 330 | let items: Record = {}; 331 | let usedSections: number[] = []; 332 | 333 | for (let i = 0; i < sections.length; i++) { 334 | let section: SectionCache = sections[i]; 335 | 336 | if (data.markerBlocks.contains(section.type)) { 337 | let sectionContent = content.slice(section.position.start.offset, section.position.end.offset); 338 | let split = sectionContent.split(data.splitString, 2); 339 | if (split.length == 2) { 340 | let itemContent = { 341 | question: split[0], 342 | answer: split[1] 343 | } 344 | 345 | let id = section.id; 346 | if (id === undefined) { 347 | id = BlockUtils.generateBlockId(); 348 | inserts.push({id: id, pos: section.position.end}); 349 | } 350 | items[id] = itemContent; 351 | usedSections.push(i); 352 | } 353 | } 354 | } 355 | 356 | return {} // TODO 357 | } 358 | 359 | function processSeparateRegExp(rawData: SelectorData, sections: SectionCache[], inserts: IdInsert[], content: string, metadata: CachedMetadata): Record { 360 | let data: SeparateRegExpData = rawData; 361 | let items: Record = {}; 362 | let usedSections: number[] = []; 363 | 364 | for (let i = 0; i < sections.length; i++) { 365 | let section: SectionCache = sections[i]; 366 | 367 | if (data.markerBlocks.contains(section.type)) { 368 | let sectionContent = content.slice(section.position.start.offset, section.position.end.offset); 369 | 370 | let questionMatch = sectionContent.match(data.questionRegExp); 371 | let answerMatch = sectionContent.match(data.answerRegExp); 372 | if (questionMatch !== null && answerMatch !== null) { 373 | let itemContent = { 374 | question: questionMatch[1], 375 | answer: answerMatch[1], 376 | } 377 | 378 | let id = section.id; 379 | if (id === undefined) { 380 | id = BlockUtils.generateBlockId(); 381 | inserts.push({id: id, pos: section.position.end}); 382 | } 383 | items[id] = itemContent; 384 | usedSections.push(i); 385 | } 386 | 387 | return {} // TODO 388 | } 389 | 390 | function processSingleRegExp(rawData: SelectorData, sections: SectionCache[], inserts: IdInsert[], content: string, metadata: CachedMetadata): Record { 391 | let data: SingleRegExpData = rawData; 392 | let items: Record = {}; 393 | let usedSections: number[] = []; 394 | 395 | for (let i = 0; i < sections.length; i++) { 396 | let section: SectionCache = sections[i]; 397 | 398 | if (data.markerBlocks.contains(section.type)) { 399 | let sectionContent = content.slice(section.position.start.offset, section.position.end.offset); 400 | let match = sectionContent.match(data.qaRegExp); 401 | if (match !== null) { 402 | let itemContent = { 403 | question: match[1], 404 | answer: match[2], 405 | } 406 | 407 | let id = section.id; 408 | if (id === undefined) { 409 | id = BlockUtils.generateBlockId(); 410 | inserts.push({id: id, pos: section.position.end}); 411 | } 412 | items[id] = itemContent; 413 | usedSections.push(i); 414 | } 415 | 416 | return {} // TODO 417 | } 418 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { Modal, App, Setting, Notice, PluginSettingTab, ButtonComponent, DropdownComponent } from "obsidian"; 2 | import ObsidianSrsPlugin from "./main"; 3 | 4 | import SrsAlgorithm from "./algorithms"; 5 | import { LeitnerAlgorithm } from "./algorithms/leitner"; 6 | import { Sm2Algorithm } from "./algorithms/supermemo"; 7 | import { AnkiAlgorithm } from "./algorithms/anki"; 8 | import { SelectorType, ItemSelector } from "./selection"; 9 | 10 | import ConfirmModal from "./modals/confirm"; 11 | 12 | export const algorithms: Record = { 13 | Anki: new AnkiAlgorithm(), 14 | SM2: new Sm2Algorithm(), 15 | Leitner: new LeitnerAlgorithm(), 16 | }; 17 | 18 | export enum DataLocation { 19 | PluginFolder = "In Plugin Folder", 20 | RootFolder = "In Vault Folder" 21 | } 22 | 23 | const locationMap: Record = { 24 | "In Vault Folder": DataLocation.RootFolder, 25 | "In Plugin Folder": DataLocation.PluginFolder, 26 | }; 27 | 28 | 29 | export interface SrsPluginSettings { 30 | maxNewPerDay: number; 31 | repeatItems: boolean; 32 | shuffleQueue: boolean; 33 | dataLocation: DataLocation; 34 | locationPath: string; 35 | algorithm: string; 36 | algorithmSettings: any; 37 | itemSelectors: ItemSelector[]; 38 | } 39 | 40 | export const DEFAULT_SETTINGS: SrsPluginSettings = { 41 | maxNewPerDay: 20, 42 | repeatItems: true, 43 | shuffleQueue: true, 44 | dataLocation: DataLocation.RootFolder, 45 | locationPath: "", 46 | algorithm: Object.keys(algorithms)[0], 47 | algorithmSettings: Object.values(algorithms)[0].settings, 48 | itemSelectors: [], 49 | }; 50 | 51 | export default class SrsSettingTab extends PluginSettingTab { 52 | plugin: ObsidianSrsPlugin; 53 | 54 | constructor(app: App, plugin: ObsidianSrsPlugin) { 55 | super(app, plugin); 56 | this.plugin = plugin; 57 | } 58 | 59 | display(): void { 60 | const plugin = this.plugin; 61 | let { containerEl } = this; 62 | 63 | containerEl.empty(); 64 | 65 | this.addNewPerDaySetting(containerEl); 66 | this.addRepeatItemsSetting(containerEl); 67 | this.addShuffleSetting(containerEl); 68 | this.addDataLocationSettings(containerEl); 69 | this.addItemSelectionSetting(containerEl); 70 | this.addAlgorithmSetting(containerEl); 71 | 72 | containerEl.createEl("h1").innerText = "Algorithm Settings"; 73 | 74 | // Add algorithm specific settings 75 | plugin.algorithm.displaySettings(containerEl, (settings: any) => { 76 | plugin.settings.algorithmSettings = settings; 77 | plugin.saveData(plugin.settings); 78 | }); 79 | } 80 | 81 | addDataLocationSettings(containerEl: HTMLElement) { 82 | const plugin = this.plugin; 83 | 84 | new Setting(containerEl) 85 | .setName("Data Location") 86 | .setDesc("Where to store the data file for spaced repetition items.") 87 | .addDropdown((dropdown) => { 88 | Object.values(DataLocation).forEach((val) => { 89 | dropdown.addOption(val, val); 90 | }) 91 | dropdown.setValue(plugin.settings.dataLocation); 92 | 93 | dropdown.onChange((val) => { 94 | const loc = locationMap[val]; 95 | plugin.settings.dataLocation = loc; 96 | plugin.store.moveStoreLocation(); 97 | plugin.saveData(plugin.settings); 98 | }); 99 | }); 100 | } 101 | 102 | addRepeatItemsSetting(containerEl: HTMLElement) { 103 | const plugin = this.plugin; 104 | new Setting(containerEl) 105 | .setName("Repeat Items") 106 | .setDesc( 107 | "Should items marked as incorrect be repeated until correct?" 108 | ) 109 | .addToggle((toggle) => { 110 | toggle.setValue(plugin.settings.repeatItems); 111 | toggle.onChange((value) => { 112 | plugin.settings.repeatItems = value; 113 | plugin.saveData(plugin.settings); 114 | }); 115 | }); 116 | } 117 | 118 | addAlgorithmSetting(containerEl: HTMLElement) { 119 | const plugin = this.plugin; 120 | 121 | new Setting(containerEl) 122 | .setName("Algorithm") 123 | .addDropdown((dropdown) => { 124 | Object.keys(algorithms).forEach((val) => { 125 | dropdown.addOption(val, val); 126 | }); 127 | dropdown.setValue(plugin.settings.algorithm); 128 | dropdown.onChange((newValue) => { 129 | if (newValue != plugin.settings.algorithm) { 130 | new ConfirmModal( 131 | plugin.app, 132 | `Switching algorithms might reset or impact review timings on existing items. 133 | This change is irreversible. Changing algorithms only takes effect after a restart 134 | or a plugin reload. Are you sure you want to switch algorithms? 135 | `, 136 | (confirmed) => { 137 | if (confirmed) { 138 | plugin.settings.algorithm = newValue; 139 | plugin.saveData(plugin.settings); 140 | } else { 141 | dropdown.setValue( 142 | plugin.settings.algorithm 143 | ); 144 | } 145 | } 146 | ).open(); 147 | } 148 | }); 149 | }) 150 | .settingEl.querySelector(".setting-item-description").innerHTML = 151 | 'The algorithm used for spaced repetition. For more information see algorithms.'; 152 | } 153 | 154 | addNewPerDaySetting(containerEl: HTMLElement) { 155 | const plugin = this.plugin; 156 | 157 | new Setting(containerEl) 158 | .setName("New Per Day") 159 | .setDesc( 160 | "Maximum number of new (unreviewed) notes to add to the queue each day." 161 | ) 162 | .addText((text) => 163 | text 164 | .setPlaceholder("New Per Day") 165 | .setValue(plugin.settings.maxNewPerDay.toString()) 166 | .onChange((newValue) => { 167 | let newPerDay = Number(newValue); 168 | 169 | if (isNaN(newPerDay)) { 170 | new Notice("Timeout must be a number"); 171 | return; 172 | } 173 | 174 | if (newPerDay < -1) { 175 | new Notice("New per day must be -1 or greater."); 176 | return; 177 | } 178 | 179 | plugin.settings.maxNewPerDay = newPerDay; 180 | plugin.saveData(plugin.settings); 181 | }) 182 | ); 183 | } 184 | 185 | addShuffleSetting(containerEl: HTMLElement) { 186 | const plugin = this.plugin; 187 | 188 | new Setting(containerEl) 189 | .setName("Shuffle Queue") 190 | .setDesc( 191 | "Whether or not the review queue order should be shuffled. If not the queue is in the order the items where added to the SRS.") 192 | .addToggle((toggle) => { 193 | toggle.setValue(plugin.settings.shuffleQueue) 194 | .onChange((newValue) => { 195 | plugin.settings.shuffleQueue = newValue; 196 | plugin.saveData(plugin.settings); 197 | }) 198 | }); 199 | } 200 | 201 | addItemSelectionSetting(containerEl: HTMLElement) { 202 | const plugin = this.plugin; 203 | 204 | new Setting(containerEl) 205 | .setName("Item Settings") 206 | .setDesc("Settings for how to extract items from tracked notes.") 207 | .addButton((button) => { 208 | button.setButtonText("Open Settings"); 209 | button.setCta(); 210 | button.onClick((evt) => { 211 | new ItemSettingsModal(this.app, this.plugin).open(); 212 | }); 213 | }); 214 | } 215 | } 216 | 217 | class ItemSettingsModal extends Modal { 218 | private plugin: ObsidianSrsPlugin; 219 | private selectorList: SelectorSettings[]; 220 | 221 | constructor(app: App, plugin: ObsidianSrsPlugin) { 222 | super(app); 223 | this.plugin = plugin; 224 | } 225 | 226 | onOpen() { 227 | let {titleEl, contentEl, plugin} = this; 228 | 229 | titleEl.createEl("h3").innerText = "Selector Settings"; 230 | 231 | // TODO: Show all item selector settings 232 | this.selectorList = new Array(plugin.settings.itemSelectors.length); 233 | plugin.settings.itemSelectors.forEach((selector, i) => { 234 | this.selectorList[i] = new SelectorSettings(contentEl, selector); 235 | }); 236 | 237 | // TODO: Add button for adding item selector 238 | new ButtonComponent(contentEl) 239 | .setButtonText("Add Selector") 240 | .setCta(); 241 | 242 | // TODO: Default behavior 243 | } 244 | 245 | onClose() { 246 | let {titleEl, contentEl} = this; 247 | contentEl.empty(); 248 | titleEl.empty(); 249 | } 250 | 251 | } 252 | 253 | class SelectorSettings { 254 | 255 | private parentEl: HTMLElement; 256 | private selector: ItemSelector; 257 | private mainDiv: HTMLDivElement; 258 | 259 | constructor(containerEl: HTMLElement, selector: ItemSelector) { 260 | this.parentEl = containerEl; 261 | this.selector = selector; 262 | this.mainDiv = this.parentEl.createDiv('selector-settings-div'); 263 | this.build(); 264 | } 265 | 266 | private build() { 267 | let {mainDiv} = this; 268 | 269 | mainDiv.empty(); 270 | 271 | let paragraph = mainDiv.createEl('p'); 272 | paragraph.innerText = "Select "; 273 | 274 | let selectorTypes: Record = { 275 | "a single block": SelectorType.SingleBlock, 276 | "multiple blocks": SelectorType.MultipleBlocks, 277 | } 278 | 279 | let dropdown = new DropdownComponent(mainDiv); 280 | for (let key in selectorTypes) { 281 | let value = selectorTypes[key].toString(); 282 | console.log("Added: ", value, ", ", key); 283 | dropdown.addOption(value, key); 284 | } 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export class DateUtils { 2 | static addTime(date: Date, time: number): Date { 3 | return new Date(date.getTime() + time); 4 | } 5 | 6 | static fromNow(time: number): Date { 7 | return this.addTime(new Date(), time); 8 | } 9 | 10 | static DAYS_TO_MILLIS = 86400000; 11 | } 12 | 13 | const characters: string = "abcdefghijklmnopqrstuvwxyz0123456789"; 14 | export class BlockUtils { 15 | 16 | static generateBlockId(length?: number): string { 17 | if (length === undefined) length = 6; 18 | var hash = ""; 19 | for (let i = 0; i < length; i++) 20 | { 21 | hash += characters.charAt( 22 | Math.floor(Math.random() * characters.length) 23 | ); 24 | } 25 | 26 | return hash; 27 | } 28 | } 29 | 30 | export class MiscUtils { 31 | /** 32 | * Creates a copy of obj, and copies values from source into 33 | * the copy, but only if there already is a property with the 34 | * matching name. 35 | * 36 | * @param obj 37 | * @param source 38 | */ 39 | static assignOnly(obj: any, source: any): any { 40 | let newObj = Object.assign(obj); 41 | if (source != undefined) { 42 | Object.keys(obj).forEach((key) => { 43 | if (key in source) { 44 | newObj[key] = source[key]; 45 | } 46 | }); 47 | } 48 | return newObj; 49 | } 50 | 51 | /** 52 | * getRegExpGroups. Counts the number of capturing groups in the provided regular 53 | * expression. 54 | * 55 | * @param {RegExp} exp 56 | * @returns {number} 57 | */ 58 | static getRegExpGroups(exp: RegExp): number { 59 | // Count capturing groups in RegExp, source: https://stackoverflow.com/questions/16046620/regex-to-count-the-number-of-capturing-groups-in-a-regex 60 | return (new RegExp(exp.source + "|")).exec('').length - 1; 61 | } 62 | 63 | /** 64 | * shuffle. Shuffles the given array in place into a random order 65 | * using Durstenfeld shuffle. 66 | * 67 | * @param {any[]} array 68 | */ 69 | static shuffle(array: any[]) { 70 | for (let i = array.length - 1; i > 0; i--) { 71 | const j = Math.floor(Math.random() * (i + 1)); 72 | [array[i], array[j]] = [array[j], array[i]]; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/view.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FileView, 3 | WorkspaceLeaf, 4 | ViewStateResult, 5 | ButtonComponent, 6 | MarkdownRenderer, 7 | TFile, 8 | } from "obsidian"; 9 | import ObsidianSrsPlugin from "./main"; 10 | import { IdInsert, ItemContent } from './selection'; 11 | 12 | export type ReviewMode = "question" | "answer" | "empty"; 13 | 14 | export class ReviewView extends FileView { 15 | plugin: ObsidianSrsPlugin; 16 | 17 | wrapperEl: HTMLElement; 18 | 19 | questionSubView: ReviewQuestionView; 20 | answerSubView: ReviewAnswerView; 21 | emptySubView: ReviewEmptyView; 22 | 23 | currentSubView: ReviewSubView; 24 | mode: ReviewMode; 25 | item: number; 26 | 27 | constructor(leaf: WorkspaceLeaf, plugin: ObsidianSrsPlugin) { 28 | super(leaf); 29 | 30 | this.plugin = plugin; 31 | 32 | let contentEl = this.containerEl.querySelector( 33 | ".view-content" 34 | ) as HTMLElement; 35 | this.wrapperEl = contentEl.createDiv("srs-review-wrapper"); 36 | 37 | this.questionSubView = new ReviewQuestionView(this); 38 | this.answerSubView = new ReviewAnswerView(this); 39 | this.emptySubView = new ReviewEmptyView(this); 40 | 41 | this.currentSubView = this.emptySubView; 42 | } 43 | 44 | async setState(state: any, result: ViewStateResult): Promise { 45 | this.mode = state.mode as ReviewMode; 46 | this.item = state.item; 47 | await super.setState(state, result); 48 | 49 | if (!this.file) { 50 | this.mode = "empty"; 51 | } 52 | 53 | if (this.mode == null || this.mode == "empty") { 54 | this.currentSubView.hide(); 55 | this.currentSubView = this.emptySubView; 56 | this.currentSubView.show(); 57 | return; 58 | } 59 | 60 | this.currentSubView.hide(); 61 | 62 | if (this.mode == "question") { 63 | this.currentSubView = this.questionSubView; 64 | this.currentSubView.show(); 65 | } else if (this.mode == "answer") { 66 | this.currentSubView = this.answerSubView; 67 | this.currentSubView.show(); 68 | } 69 | 70 | console.log("Loading item " + this.item + "..."); 71 | 72 | this.app.vault.cachedRead(this.file).then( 73 | (content) => { 74 | console.log(content); 75 | let question: string = this.file.basename; 76 | let answer: string = content.trim(); 77 | const metadata = this.app.metadataCache.getFileCache(this.file); 78 | 79 | if (metadata) { 80 | if (metadata.sections) { 81 | 82 | let sections = [...metadata.sections]; 83 | let idInserts: IdInsert[] = []; 84 | let items: Record = {}; 85 | this.plugin.settings.itemSelectors.forEach((selector) => { 86 | let newItems = selector.process(sections, idInserts, content, metadata); 87 | items = {...items, ...newItems}; 88 | }); 89 | 90 | console.log(sections); 91 | console.log(idInserts); 92 | console.log("Found ", Object.keys(items).length, " items!"); 93 | console.log(items); 94 | 95 | //let selector = new SingleBlockSelector(); 96 | //console.log(idInserts); 97 | //console.log(sections); 98 | //console.log(metadata.sections); 99 | 100 | //// Now insert IDs 101 | //idInserts.sort((a, b) => b.pos.offset - a.pos.offset); 102 | //let newContent = content; 103 | //idInserts.forEach((insert) => { 104 | // newContent = [ 105 | // newContent.slice(0, insert.pos.offset), 106 | // ' ^' + insert.id, 107 | // newContent.slice(insert.pos.offset) 108 | // ].join(''); 109 | //}); 110 | 111 | //this.app.vault.adapter.write(this.file.path, newContent); 112 | } 113 | if (metadata.headings && metadata.headings.length > 0) { 114 | question = metadata.headings[0].heading; 115 | answer = content 116 | .substr( 117 | metadata.headings[0].position.end.offset + 1 118 | ) 119 | .trim(); 120 | } 121 | } 122 | this.currentSubView.set(question, answer, this.file); 123 | }, 124 | (err) => { 125 | console.log("Unable to read item: " + err); 126 | } 127 | ); 128 | } 129 | 130 | getState(): any { 131 | let state = super.getState(); 132 | state.mode = this.mode; 133 | return state; 134 | } 135 | 136 | getViewType(): string { 137 | return "srs-review-view"; 138 | } 139 | } 140 | 141 | export interface ReviewSubView { 142 | set(question: string, answer: string, file: TFile): void; 143 | 144 | show(): void; 145 | hide(): void; 146 | } 147 | 148 | export class ReviewEmptyView implements ReviewSubView { 149 | containerEl: HTMLElement; 150 | 151 | constructor(view: ReviewView) { 152 | this.containerEl = view.wrapperEl.createDiv("srs-review-empty"); 153 | this.containerEl.hidden = true; 154 | 155 | this.containerEl.innerText = "Your queue is empty!"; 156 | } 157 | 158 | set(question: string, answer: string, file: TFile) {} 159 | 160 | show() { 161 | this.containerEl.hidden = false; 162 | } 163 | 164 | hide() { 165 | this.containerEl.hidden = true; 166 | } 167 | } 168 | 169 | export class ReviewQuestionView implements ReviewSubView { 170 | containerEl: HTMLElement; 171 | 172 | questionEl: HTMLElement; 173 | 174 | constructor(view: ReviewView) { 175 | let answerClick = (view: ReviewView) => { 176 | view.leaf.setViewState({ 177 | type: "srs-review-view", 178 | state: { 179 | file: view.file.path, 180 | mode: "answer", 181 | item: view.item, 182 | }, 183 | }); 184 | }; 185 | 186 | this.containerEl = view.wrapperEl.createDiv("srs-review-question"); 187 | this.containerEl.hidden = true; 188 | 189 | this.questionEl = this.containerEl.createDiv("srs-question-content"); 190 | 191 | let buttonDiv = this.containerEl.createDiv("srs-button-div"); 192 | 193 | let buttonRow = buttonDiv.createDiv("srs-flex-row"); 194 | let openFileRow = buttonDiv.createDiv("srs-flex-row"); 195 | 196 | new ButtonComponent(buttonRow) 197 | .setButtonText("Show Answer") 198 | .setCta() 199 | .onClick(() => answerClick(view)); 200 | 201 | new ButtonComponent(openFileRow) 202 | .setButtonText("Open File") 203 | .onClick(() => { 204 | const leaf = view.app.workspace.getUnpinnedLeaf(); 205 | leaf.setViewState({ 206 | type: "markdown", 207 | state: { 208 | file: view.file.path, 209 | }, 210 | }); 211 | view.app.workspace.setActiveLeaf(leaf); 212 | }) 213 | .setClass("srs-review-button"); 214 | } 215 | 216 | set(question: string, answer: string, file: TFile) { 217 | this.questionEl.empty(); 218 | 219 | MarkdownRenderer.renderMarkdown( 220 | "# " + question, 221 | this.questionEl, 222 | file.path, 223 | null 224 | ); 225 | } 226 | 227 | show() { 228 | this.containerEl.hidden = false; 229 | } 230 | 231 | hide() { 232 | this.containerEl.hidden = true; 233 | } 234 | } 235 | 236 | export class ReviewAnswerView implements ReviewSubView { 237 | containerEl: HTMLElement; 238 | 239 | questionEl: HTMLElement; 240 | answerEl: HTMLElement; 241 | buttons: ButtonComponent[]; 242 | 243 | constructor(view: ReviewView) { 244 | let buttonClick = (view: ReviewView, s: string) => { 245 | view.plugin.store.reviewId(view.item, s); 246 | const item = view.plugin.store.getNext(); 247 | const state: any = { mode: "empty" }; 248 | if (item != null) { 249 | const path = view.plugin.store.getFilePath(item); 250 | if (path != null) { 251 | state.file = path; 252 | state.item = view.plugin.store.getNextId(); 253 | state.mode = "question"; 254 | } 255 | } 256 | view.leaf.setViewState({ 257 | type: "srs-review-view", 258 | state: state, 259 | }); 260 | }; 261 | this.containerEl = view.wrapperEl.createDiv("srs-review-answer"); 262 | this.containerEl.hidden = true; 263 | 264 | let wrapperEl = this.containerEl.createDiv('srs-qa-wrapper'); 265 | 266 | this.questionEl = wrapperEl.createDiv("srs-question-content"); 267 | this.answerEl = wrapperEl.createDiv("srs-answer-content"); 268 | 269 | let buttonDiv = this.containerEl.createDiv("srs-button-div"); 270 | 271 | let buttonRow = buttonDiv.createDiv("srs-flex-row"); 272 | let openFileRow = buttonDiv.createDiv("srs-flex-row"); 273 | 274 | this.buttons = []; 275 | view.plugin.algorithm.srsOptions().forEach((s: string) => { 276 | this.buttons.push( 277 | new ButtonComponent(buttonRow) 278 | .setButtonText(s) 279 | .setCta() 280 | .onClick(() => buttonClick(view, s)) 281 | // .setTooltip("Hotkey: " + (this.buttons.length + 1)) 282 | .setClass("srs-review-button") 283 | ); 284 | }); 285 | 286 | new ButtonComponent(openFileRow) 287 | .setButtonText("Open File") 288 | .onClick(() => { 289 | const leaf = view.app.workspace.getUnpinnedLeaf(); 290 | leaf.setViewState({ 291 | type: "markdown", 292 | state: { 293 | file: view.file.path, 294 | }, 295 | }); 296 | view.app.workspace.setActiveLeaf(leaf); 297 | }) 298 | .setClass("srs-review-button"); 299 | } 300 | 301 | set(question: string, answer: string, file: TFile) { 302 | this.questionEl.empty(); 303 | this.answerEl.empty(); 304 | 305 | MarkdownRenderer.renderMarkdown( 306 | "# " + question, 307 | this.questionEl, 308 | file.path, 309 | null 310 | ); 311 | MarkdownRenderer.renderMarkdown(answer, this.answerEl, file.path, null); 312 | } 313 | 314 | show() { 315 | this.containerEl.hidden = false; 316 | } 317 | 318 | hide() { 319 | this.containerEl.hidden = true; 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .srs-bar-tracked { 2 | color: green; 3 | } 4 | 5 | .srs-qa-wrapper { 6 | position: absolute; 7 | height: 67%; 8 | overflow: auto; 9 | } 10 | 11 | .srs-button-div { 12 | position: absolute; 13 | bottom: 0; 14 | width: 100%; 15 | 16 | height: 33%; 17 | max-height: 400px; 18 | display: flex; 19 | flex-flow: column wrap; 20 | justify-content: flex-start; 21 | align-items: flex-start; 22 | 23 | padding-top: 20px; 24 | 25 | border-top-width: 3px; 26 | border-top-style: solid; 27 | border-top-color: var(--background-modifier-border); 28 | } 29 | 30 | .srs-flex-row { 31 | display: flex; 32 | width: 100%; 33 | flex-flow: row wrap; 34 | justify-content: space-evenly; 35 | align-items: flex-start; 36 | padding-top: 10px; 37 | } 38 | 39 | .srs-review-wrapper { 40 | position: relative; 41 | max-width: 700px; 42 | min-height: 100%; 43 | margin-left: auto; 44 | margin-right: auto; 45 | } 46 | 47 | .timings-setting-item { 48 | display: flex; 49 | align-items: flex-start; 50 | padding: 15px 0 18px 0; 51 | } 52 | 53 | .timings-setting-item .setting-item-control { 54 | flex-wrap: wrap; 55 | flex-direction: column; 56 | flex-shrink: 1; 57 | flex-grow: 0; 58 | text-align: right; 59 | display: flex; 60 | justify-content: flex-end; 61 | align-items: center; 62 | } 63 | 64 | .timings-setting-item input { 65 | margin-top: 3px; 66 | } 67 | 68 | .srs-answer-content { 69 | margin-top: 12px; 70 | padding-top: 12px; 71 | 72 | border-top-width: 1px; 73 | border-top-style: solid; 74 | border-top-color: var(--background-modifier-border); 75 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es5", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "lib": [ 13 | "dom", 14 | "es5", 15 | "scripthost", 16 | "es2015" 17 | ] 18 | }, 19 | "include": [ 20 | "**/*.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.1": "0.9.12", 3 | "0.1.0": "0.9.12" 4 | } 5 | --------------------------------------------------------------------------------