├── .prettierrc.json ├── src ├── helpers │ ├── priority-utils.ts │ ├── obsidian-utils-base.ts │ ├── str-utils.ts │ ├── number-utils.ts │ ├── parse-date.ts │ ├── date-utils.ts │ ├── link-utils.ts │ ├── file-utils.ts │ └── block-utils.ts ├── views │ ├── modal-base.ts │ ├── fuzzy-note-adder.ts │ ├── date-suggest.ts │ ├── queue-modal.ts │ ├── status-bar.ts │ ├── file-suggest.ts │ ├── next-rep-schedule.ts │ ├── edit-data.ts │ ├── create-queue.ts │ ├── suggest.ts │ ├── bulk-adding.ts │ ├── modals.ts │ └── settings-tab.ts ├── logger.ts ├── settings.ts ├── scheduler.ts ├── queue.ts ├── markdown.ts └── main.ts ├── .prettierignore ├── .gitignore ├── manifest.json ├── tsconfig.json ├── rollup.config.js ├── data.json ├── package.json ├── LICENSE └── README.md /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/helpers/priority-utils.ts: -------------------------------------------------------------------------------- 1 | export class PriorityUtils { 2 | static getPriorityBetween(pMin: number, pMax: number) { 3 | return Math.random() * (pMax - pMin) + pMin; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/obsidian-utils-base.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | 3 | export abstract class ObsidianUtilsBase { 4 | protected app: App; 5 | 6 | constructor(app: App) { 7 | this.app = app; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | 3 | # npm 4 | node_modules 5 | package-lock.json 6 | 7 | # Outputs 8 | main.js 9 | *.js.map 10 | 11 | # Comment one of the following lock file in your plugin! 12 | pnpm-lock.yaml 13 | npm-lock.yaml 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | 3 | copy_to_testing.sh 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # Outputs 10 | main.js 11 | *.js.map 12 | 13 | # Comment one of the following lock file in your plugin! 14 | pnpm-lock.yaml 15 | npm-lock.yaml 16 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-incremental-writing", 3 | "name": "Incremental Writing", 4 | "description": "Incrementally review notes and blocks over time.", 5 | "isDesktopOnly": true, 6 | "version": "0.4.1", 7 | "author": "Experimental Learning", 8 | "authorUrl": "https://github.com/bjsi/incremental-writing", 9 | "js": "main.js" 10 | } 11 | -------------------------------------------------------------------------------- /src/views/modal-base.ts: -------------------------------------------------------------------------------- 1 | import { Modal } from "obsidian"; 2 | import "../helpers/date-utils"; 3 | import IW from "../main"; 4 | 5 | export abstract class ModalBase extends Modal { 6 | protected plugin: IW; 7 | 8 | constructor(plugin: IW) { 9 | super(plugin.app); 10 | this.plugin = plugin; 11 | } 12 | 13 | onClose() { 14 | let { contentEl } = this; 15 | contentEl.empty(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "esModuleInterop": true, 7 | "module": "ESNext", 8 | "target": "ES2018", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "lib": ["dom", "es5", "scripthost", "es2015"] 14 | }, 15 | "include": ["**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /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 | 5 | export default { 6 | input: "./src/main.ts", 7 | output: { 8 | dir: ".", 9 | sourcemap: "inline", 10 | format: "cjs", 11 | exports: "default", 12 | }, 13 | external: ["obsidian"], 14 | plugins: [typescript(), nodeResolve({ browser: true }), commonjs()], 15 | }; 16 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from "obsidian"; 2 | 3 | export class LogTo { 4 | static getTime() { 5 | return new Date().toTimeString().substr(0, 8); 6 | } 7 | 8 | static Debug(message: string, notify: boolean = false) { 9 | console.debug(`[${LogTo.getTime()}] (IW Plugin): ${message}`); 10 | if (notify) new Notice(message); 11 | } 12 | 13 | static Console(message: string, notify: boolean = false) { 14 | console.log(`[${LogTo.getTime()}] (IW Plugin): ${message}`); 15 | if (notify) new Notice(message); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/helpers/str-utils.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface String { 3 | withExtension(extension: string): string; 4 | rtrim(chars: string): string; 5 | } 6 | } 7 | 8 | String.prototype.withExtension = function (extension: string): string { 9 | return String(this).rtrim(extension) + extension; 10 | }; 11 | 12 | String.prototype.rtrim = function (chars: string): string { 13 | const from = String(this); 14 | let end = from.length - 1; 15 | while (chars.indexOf(from[end]) >= 0) { 16 | end -= 1; 17 | } 18 | return from.substr(0, end + 1); 19 | }; 20 | 21 | export {}; 22 | -------------------------------------------------------------------------------- /data.json: -------------------------------------------------------------------------------- 1 | { 2 | "queueTagMap": { 3 | "IW-Queue": [ 4 | "iw" 5 | ] 6 | }, 7 | "defaultPriorityMin": 10, 8 | "defaultPriorityMax": 50, 9 | "queueFolderPath": "IW-Queues", 10 | "queueFileName": "IW-Queue.md", 11 | "defaultQueueType": "afactor", 12 | "skipAddNoteWindow": false, 13 | "autoAddNewNotes": false, 14 | "defaultFirstRepDate": "tomorrow", 15 | "askForNextRepDate": false, 16 | "dropdownNaturalDates": { 17 | "today": "today", 18 | "tomorrow": "tomorrow", 19 | "in two days": "in two days", 20 | "next week": "next week", 21 | "in two weeks": "in two weeks" 22 | } 23 | } -------------------------------------------------------------------------------- /src/views/fuzzy-note-adder.ts: -------------------------------------------------------------------------------- 1 | import { FuzzySuggestModal } from "obsidian"; 2 | import IW from "../main"; 3 | import { ReviewFileModal } from "./modals"; 4 | 5 | export class FuzzyNoteAdder extends FuzzySuggestModal { 6 | plugin: IW; 7 | 8 | constructor(plugin: IW) { 9 | super(plugin.app); 10 | this.plugin = plugin; 11 | } 12 | 13 | onChooseItem(item: string, evt: MouseEvent | KeyboardEvent) { 14 | new ReviewFileModal(this.plugin, item).open(); 15 | } 16 | 17 | getItems(): string[] { 18 | return this.plugin.app.vault.getMarkdownFiles().map((file) => file.path); 19 | } 20 | 21 | getItemText(item: string) { 22 | return item; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/views/date-suggest.ts: -------------------------------------------------------------------------------- 1 | import IW from "src/main"; 2 | import { TextInputSuggest } from "./suggest"; 3 | 4 | export class NaturalDateSuggest extends TextInputSuggest { 5 | private plugin: IW; 6 | constructor(plugin: IW, inputEl: HTMLInputElement) { 7 | super(plugin.app, inputEl); 8 | this.plugin = plugin; 9 | } 10 | 11 | getSuggestions(inputStr: string): string[] { 12 | return Object.keys( 13 | this.plugin.settings.dropdownNaturalDates 14 | ).filter((date) => date.contains(inputStr)); 15 | } 16 | 17 | renderSuggestion(date: string, el: HTMLElement): void { 18 | el.setText(date); 19 | } 20 | 21 | selectSuggestion(date: string): void { 22 | this.inputEl.value = date; 23 | this.inputEl.trigger("input"); 24 | this.close(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/helpers/number-utils.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Number { 3 | isValidAFactor(): boolean; 4 | round(places: number): number; 5 | isValidInterval(): boolean; 6 | isValidPriority(): boolean; 7 | } 8 | } 9 | 10 | Number.prototype.round = function (places: number): number { 11 | const x = Math.pow(10, places); 12 | return Math.round(Number(this) * x) / x; 13 | }; 14 | 15 | Number.prototype.isValidPriority = function (): boolean { 16 | const priority = Number(this); 17 | return !isNaN(priority) && priority >= 0 && priority <= 100; 18 | }; 19 | 20 | Number.prototype.isValidAFactor = function (): boolean { 21 | const afactor = Number(this); 22 | return !isNaN(afactor) && afactor >= 0; 23 | }; 24 | 25 | Number.prototype.isValidInterval = function (): boolean { 26 | const interval = Number(this); 27 | return !isNaN(interval) && interval >= 0; 28 | }; 29 | 30 | export {}; 31 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | export interface IWSettings { 2 | defaultPriorityMin: number; 3 | queueTagMap: Record; 4 | defaultPriorityMax: number; 5 | queueFileName: string; 6 | queueFolderPath: string; 7 | defaultQueueType: string; 8 | skipAddNoteWindow: boolean; 9 | autoAddNewNotes: boolean; 10 | defaultFirstRepDate: string; 11 | askForNextRepDate: boolean; 12 | dropdownNaturalDates: Record; 13 | } 14 | 15 | export const DefaultSettings: IWSettings = { 16 | queueTagMap: {}, 17 | defaultPriorityMin: 10, 18 | defaultPriorityMax: 50, 19 | queueFolderPath: "IW-Queues", 20 | queueFileName: "IW-Queue.md", 21 | defaultQueueType: "afactor", 22 | skipAddNoteWindow: false, 23 | autoAddNewNotes: false, 24 | defaultFirstRepDate: "1970-01-01", 25 | askForNextRepDate: false, 26 | dropdownNaturalDates: { 27 | today: "today", 28 | tomorrow: "tomorrow", 29 | "in two days": "in two days", 30 | "next week": "next week", 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-incremental-writing", 3 | "author": "Jamesb this.app).plugins.getPlugin( 15 | "nldates-obsidian" 16 | ); 17 | 18 | if (!naturalLanguageDates) { 19 | return; 20 | } 21 | 22 | const nlDateResult = naturalLanguageDates.parseDate(dateString); 23 | if (nlDateResult && nlDateResult.date) return nlDateResult.date; 24 | } 25 | 26 | parseDate(dateString: string): Date { 27 | let d1 = this.parseDateAsDate(dateString); 28 | if (d1.isValid()) return d1; 29 | 30 | let d2 = this.parseDateAsNatural(dateString); 31 | if (d2.isValid()) return d2; 32 | 33 | return new Date("1970-01-01"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jamesb | Experimental Learning 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 | -------------------------------------------------------------------------------- /src/helpers/date-utils.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Date { 3 | addDays(days: number): Date; 4 | formatYYMMDD(): string; 5 | isValid(): boolean; 6 | daysDifference(from: Date): number; 7 | } 8 | } 9 | 10 | Date.prototype.addDays = function (days: number): Date { 11 | var result = new Date(this); 12 | result.setDate(result.getDate() + days); 13 | return result; 14 | }; 15 | 16 | Date.prototype.formatYYMMDD = function (): string { 17 | const d = new Date(this); 18 | var month = "" + (d.getMonth() + 1); 19 | var day = "" + d.getDate(); 20 | var year = d.getFullYear(); 21 | 22 | if (month.length < 2) month = "0" + month; 23 | if (day.length < 2) day = "0" + day; 24 | 25 | return [year, month, day].join("-"); 26 | }; 27 | 28 | Date.prototype.isValid = function () { 29 | const date = new Date(this); 30 | return date instanceof Date && !isNaN(date.valueOf()); 31 | }; 32 | 33 | Date.prototype.daysDifference = function (from: Date): number { 34 | const date = new Date(this); 35 | const oneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds 36 | // @ts-ignore 37 | return Math.round(Math.abs((date - from) / oneDay)); 38 | }; 39 | 40 | export {}; 41 | -------------------------------------------------------------------------------- /src/views/queue-modal.ts: -------------------------------------------------------------------------------- 1 | import { normalizePath, FuzzySuggestModal } from "obsidian"; 2 | import * as path from "path"; 3 | import { LogTo } from "src/logger"; 4 | import IW from "../main"; 5 | 6 | export class QueueLoadModal extends FuzzySuggestModal { 7 | plugin: IW; 8 | 9 | constructor(plugin: IW) { 10 | super(plugin.app); 11 | this.plugin = plugin; 12 | } 13 | 14 | async onChooseItem(item: string, _: MouseEvent | KeyboardEvent) { 15 | const path = [this.plugin.settings.queueFolderPath, item].join("/"); 16 | LogTo.Debug("Chose: " + path); 17 | await this.plugin.loadQueue(path); 18 | } 19 | 20 | getItems(): string[] { 21 | const queueFolderPath = normalizePath(this.plugin.settings.queueFolderPath); 22 | const defaultQueue = path.relative( 23 | queueFolderPath, 24 | this.plugin.getDefaultQueuePath() 25 | ); 26 | const folder = this.plugin.files.getTFolder(queueFolderPath); 27 | if (folder) { 28 | let files = this.plugin.app.vault 29 | .getMarkdownFiles() 30 | .filter((file) => this.plugin.files.isDescendantOf(file, folder)) 31 | .map((file) => path.relative(queueFolderPath, file.path)); 32 | 33 | if (!files.some((f) => f === defaultQueue)) files.push(defaultQueue); 34 | return files; 35 | } 36 | 37 | return [defaultQueue]; 38 | } 39 | 40 | getItemText(item: string) { 41 | return item; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/scheduler.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownTable, MarkdownTableRow } from "./markdown"; 2 | import "./helpers/date-utils"; 3 | import "./helpers/number-utils"; 4 | 5 | export abstract class Scheduler { 6 | protected name: string; 7 | constructor(name: string) { 8 | this.name = name; 9 | } 10 | 11 | abstract schedule(table: MarkdownTable, row: MarkdownTableRow): void; 12 | } 13 | 14 | export class SimpleScheduler extends Scheduler { 15 | constructor() { 16 | super("simple"); 17 | } 18 | 19 | schedule(table: MarkdownTable, row: MarkdownTableRow) { 20 | table.addRow(row); 21 | // spread rows between 0 and 100 priority 22 | let step = 99.9 / table.rows.length; 23 | let curPri = step; 24 | for (let row of table.rows) { 25 | row.priority = curPri.round(2); 26 | curPri += step; 27 | } 28 | } 29 | 30 | toString() { 31 | return `--- 32 | scheduler: "${this.name}" 33 | ---`; 34 | } 35 | } 36 | 37 | export class AFactorScheduler extends Scheduler { 38 | private afactor: number; 39 | private interval: number; 40 | 41 | constructor(afactor: number = 2, interval: number = 1) { 42 | super("afactor"); 43 | this.afactor = afactor.isValidAFactor() ? afactor : 2; 44 | this.interval = interval.isValidInterval() ? interval : 1; 45 | } 46 | 47 | schedule(table: MarkdownTable, row: MarkdownTableRow) { 48 | row.nextRepDate = new Date().addDays(row.interval); 49 | row.interval = this.afactor * row.interval; 50 | table.addRow(row); 51 | } 52 | 53 | toString() { 54 | return `--- 55 | scheduler: "${this.name}" 56 | afactor: ${this.afactor} 57 | interval: ${this.interval} 58 | ---`; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/helpers/link-utils.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian"; 2 | import { ObsidianUtilsBase } from "./obsidian-utils-base"; 3 | import { getLinkpath, parseLinktext } from "obsidian"; 4 | import { LogTo } from "src/logger"; 5 | 6 | export class LinkEx extends ObsidianUtilsBase { 7 | constructor(app: App) { 8 | super(app); 9 | } 10 | 11 | static addBrackets(link: string) { 12 | if (!link.startsWith("[[")) link = "[[" + link; 13 | 14 | if (!link.endsWith("]]")) link = link + "]]"; 15 | 16 | return link; 17 | } 18 | 19 | static removeBrackets(link: string) { 20 | if (link.startsWith("[[")) { 21 | link = link.substr(2); 22 | } 23 | 24 | if (link.endsWith("]]")) { 25 | link = link.substr(0, link.length - 2); 26 | } 27 | 28 | return link; 29 | } 30 | 31 | // TODO: 32 | exists(link: string, source: string): boolean { 33 | let path = getLinkpath(link); 34 | let file = this.app.metadataCache.getFirstLinkpathDest(path, source); 35 | return file instanceof TFile; 36 | } 37 | 38 | createAbsoluteLink(linktext: string, source: string): string | null { 39 | const { path, subpath } = parseLinktext(linktext); 40 | const file = this.app.metadataCache.getFirstLinkpathDest(path, source); 41 | // has to be set to lower case 42 | // because obsidian link cache 43 | // record keys are lower case 44 | return file !== null 45 | ? this.app.metadataCache.fileToLinktext(file, "", true) + 46 | (subpath.toLowerCase() ?? "") 47 | : null; 48 | } 49 | 50 | getLinksIn(file: TFile): string[] { 51 | const links = this.app.metadataCache.getFileCache(file).links ?? []; 52 | const linkPaths = links 53 | .map((link) => this.createAbsoluteLink(link.link, file.path)) 54 | .filter((x) => x !== null && x.length > 0); 55 | return linkPaths; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/views/status-bar.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownTableRow } from "../markdown"; 2 | import "../helpers/str-utils"; 3 | import { normalizePath, parseLinktext } from "obsidian"; 4 | import IW from "../main"; 5 | import path from "path"; 6 | 7 | export class StatusBar { 8 | private statusBarAdded: boolean; 9 | private statusBar: HTMLElement; 10 | 11 | private repText: HTMLSpanElement; 12 | private priorityText: HTMLSpanElement; 13 | private queueText: HTMLSpanElement; 14 | 15 | private plugin: IW; 16 | 17 | constructor(statusBar: HTMLElement, plugin: IW) { 18 | this.statusBar = statusBar; 19 | this.plugin = plugin; 20 | } 21 | 22 | initStatusBar() { 23 | if (this.statusBarAdded) { 24 | return; 25 | } 26 | 27 | let status = this.statusBar.createEl("div", { prepend: true }); 28 | this.repText = status.createEl("span", { 29 | cls: ["status-bar-item-segment"], 30 | }); 31 | this.priorityText = status.createEl("span", { 32 | cls: ["status-bar-item-segment"], 33 | }); 34 | this.queueText = status.createEl("span", { 35 | cls: ["status-bar-item-segment"], 36 | }); 37 | this.statusBarAdded = true; 38 | } 39 | 40 | updateCurrentQueue(queuePath: string) { 41 | const normalized = normalizePath(queuePath); 42 | const name = path 43 | .relative(this.plugin.settings.queueFolderPath, normalized) 44 | .rtrim(".md"); 45 | this.queueText.innerText = 46 | name && name.length > 0 ? "Queue: " + name : "Queue: None"; 47 | } 48 | 49 | updateCurrentPriority(n: number) { 50 | this.priorityText.innerText = "Pri: " + n.toString(); 51 | } 52 | 53 | updateCurrentRep(row: MarkdownTableRow) { 54 | if (row) { 55 | const { path, subpath } = parseLinktext(row.link); 56 | const file = this.plugin.app.metadataCache.getFirstLinkpathDest( 57 | path, 58 | this.plugin.queue.queuePath 59 | ); 60 | if (file) { 61 | this.updateCurrentPriority(row.priority); 62 | this.repText.innerText = 63 | "Rep: " + file.name.substr(0, file.name.length - 3) + subpath; 64 | } 65 | } else { 66 | this.repText.innerText = "Rep: None."; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/views/file-suggest.ts: -------------------------------------------------------------------------------- 1 | import { App, TAbstractFile, TFile, TFolder } from "obsidian"; 2 | import { TextInputSuggest } from "./suggest"; 3 | import IW from "../main"; 4 | import path from "path"; 5 | import { LogTo } from "src/logger"; 6 | 7 | export class FileSuggest extends TextInputSuggest { 8 | folder: () => TFolder; 9 | plugin: IW; 10 | 11 | constructor( 12 | plugin: IW, 13 | inputEl: HTMLInputElement, 14 | folderFunc: () => TFolder 15 | ) { 16 | super(plugin.app, inputEl); 17 | this.plugin = plugin; 18 | this.folder = folderFunc; 19 | } 20 | 21 | getSuggestions(inputStr: string): TFile[] { 22 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 23 | const files: TFile[] = []; 24 | 25 | for (const file of abstractFiles) { 26 | if (!(file instanceof TFile)) continue; 27 | 28 | if (!this.plugin.files.isDescendantOf(file, this.folder())) continue; 29 | 30 | if (file.extension !== "md") continue; 31 | 32 | const relPath = path.relative(this.folder().path, file.path); 33 | if (relPath.contains(inputStr)) files.push(file); 34 | } 35 | 36 | return files; 37 | } 38 | 39 | renderSuggestion(file: TFile, el: HTMLElement): void { 40 | el.setText(path.relative(this.plugin.settings.queueFolderPath, file.path)); 41 | } 42 | 43 | selectSuggestion(file: TFile): void { 44 | this.inputEl.value = path.relative( 45 | this.plugin.settings.queueFolderPath, 46 | file.path 47 | ); 48 | this.inputEl.trigger("input"); 49 | this.close(); 50 | } 51 | } 52 | 53 | export class FolderSuggest extends TextInputSuggest { 54 | getSuggestions(inputStr: string): TFolder[] { 55 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 56 | const folders: TFolder[] = []; 57 | const lowerCaseInputStr = inputStr.toLowerCase(); 58 | 59 | abstractFiles.forEach((folder: TAbstractFile) => { 60 | if ( 61 | folder instanceof TFolder && 62 | folder.path.toLowerCase().contains(lowerCaseInputStr) 63 | ) { 64 | folders.push(folder); 65 | } 66 | }); 67 | 68 | return folders; 69 | } 70 | 71 | renderSuggestion(file: TFolder, el: HTMLElement): void { 72 | el.setText(file.path); 73 | } 74 | 75 | selectSuggestion(file: TFolder): void { 76 | this.inputEl.value = file.path; 77 | this.inputEl.trigger("input"); 78 | this.close(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/helpers/file-utils.ts: -------------------------------------------------------------------------------- 1 | import { normalizePath, MarkdownView, App, TFile, TFolder } from "obsidian"; 2 | import { ObsidianUtilsBase } from "./obsidian-utils-base"; 3 | 4 | // TODO: read: https://github.com/lynchjames/obsidian-day-planner/blob/d1eb7ce187e7757b7a3880358a6ee184b3b025da/src/file.ts#L48 5 | 6 | export class FileUtils extends ObsidianUtilsBase { 7 | constructor(app: App) { 8 | super(app); 9 | } 10 | 11 | async exists(file: string) { 12 | return await this.app.vault.adapter.exists(normalizePath(file)); 13 | } 14 | 15 | async createIfNotExists(file: string, data: string) { 16 | const normalizedPath = normalizePath(file); 17 | if (!(await this.exists(normalizedPath))) { 18 | let folderPath = this.getParentOfNormalized(normalizedPath); 19 | await this.createFolders(folderPath); 20 | await this.app.vault.create(normalizedPath, data); 21 | } 22 | } 23 | 24 | getTFile(filePath: string) { 25 | let file = this.app.vault.getAbstractFileByPath(filePath); 26 | if (file instanceof TFile) return file; 27 | return null; 28 | } 29 | 30 | getTFolder(folderPath: string) { 31 | let folder = this.app.vault.getAbstractFileByPath(folderPath); 32 | if (folder instanceof TFolder) return folder; 33 | return null; 34 | } 35 | 36 | toLinkText(file: TFile) { 37 | return this.app.metadataCache.fileToLinktext(file, "", true); 38 | } 39 | 40 | getParentOfNormalized(normalizedPath: string) { 41 | let pathSplit = normalizedPath.split("/"); 42 | return pathSplit.slice(0, pathSplit.length - 1).join("/"); 43 | } 44 | 45 | async createFolders(normalizedPath: string) { 46 | let current = normalizedPath; 47 | while (current && !(await this.app.vault.adapter.exists(current))) { 48 | await this.app.vault.createFolder(current); 49 | current = this.getParentOfNormalized(current); 50 | } 51 | } 52 | 53 | isDescendantOf(file: TFile, folder: TFolder): boolean { 54 | let ancestor = file.parent; 55 | while (ancestor && !ancestor.isRoot()) { 56 | if (ancestor === folder) { 57 | return true; 58 | } 59 | ancestor = ancestor.parent; 60 | } 61 | return false; 62 | } 63 | 64 | async goTo(filePath: string, newLeaf: boolean) { 65 | let file = this.getTFile(filePath); 66 | let link = this.app.metadataCache.fileToLinktext(file, ""); 67 | await this.app.workspace.openLinkText(link, "", newLeaf); 68 | } 69 | 70 | getActiveNoteFile() { 71 | return this.app.workspace.getActiveViewOfType(MarkdownView)?.file; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/helpers/block-utils.ts: -------------------------------------------------------------------------------- 1 | import { ObsidianUtilsBase } from "./obsidian-utils-base"; 2 | import { App, TFile } from "obsidian"; 3 | import { LogTo } from "src/logger"; 4 | import { EOL } from "os"; 5 | 6 | export class BlockUtils extends ObsidianUtilsBase { 7 | constructor(app: App) { 8 | super(app); 9 | } 10 | 11 | async getBlockRefHash(lineNumber: number, noteFile: TFile): Promise { 12 | const noteLines = (await this.app.vault.read(noteFile))?.split(/\r?\n/); 13 | if (!noteLines || noteLines.length === 0) { 14 | LogTo.Debug("Failed to read lines from note."); 15 | return null; 16 | } 17 | const match = noteLines[lineNumber].match(/(.+)( \^[=a-zA-Z0-9]+)$/); 18 | LogTo.Debug(JSON.stringify(match)); 19 | return match && match.length == 3 ? match[2].trim() : ""; 20 | } 21 | 22 | async createBlockRefIfNotExists( 23 | lineNumber: number, 24 | noteFile: TFile, 25 | customBlockRef: string = null 26 | ): Promise { 27 | const blockRef = await this.getBlockRefHash(lineNumber, noteFile); 28 | if (blockRef === null) { 29 | return null; 30 | } else if (blockRef !== "") { 31 | return ( 32 | this.app.metadataCache.fileToLinktext(noteFile, "", true) + 33 | "#" + 34 | blockRef 35 | ); 36 | } else { 37 | return await this.addBlockRef(lineNumber, noteFile, customBlockRef); 38 | } 39 | } 40 | 41 | async addBlockRef( 42 | lineNumber: number, 43 | noteFile: TFile, 44 | customBlockRef: string = null 45 | ): Promise { 46 | const oldNoteLines = 47 | (await this.app.vault.read(noteFile))?.split(/\r?\n/) || []; 48 | 49 | const blockRef = 50 | customBlockRef && customBlockRef.length !== 0 51 | ? customBlockRef 52 | : this.createBlockHash(); 53 | 54 | if (!blockRef.match(/^[=a-zA-Z0-9]+$/)) { 55 | LogTo.Debug("Invalid block ref name.", true); 56 | return null; 57 | } 58 | 59 | const refs = this.app.metadataCache.getFileCache(noteFile).blocks; 60 | if (refs && Object.keys(refs).some((ref) => ref === blockRef)) { 61 | LogTo.Debug("This block ref is already used in this file.", true); 62 | return null; 63 | } 64 | 65 | oldNoteLines[lineNumber] = oldNoteLines[lineNumber] + " ^" + blockRef; 66 | await this.app.vault.modify(noteFile, oldNoteLines.join(EOL)); 67 | return ( 68 | this.app.metadataCache.fileToLinktext(noteFile, "", true) + 69 | "#^" + 70 | blockRef 71 | ); 72 | } 73 | 74 | createBlockHash(): string { 75 | // Credit to https://stackoverflow.com/a/1349426 76 | let result = ""; 77 | var characters = "abcdefghijklmnopqrstuvwxyz0123456789"; 78 | var charactersLength = characters.length; 79 | for (var i = 0; i < 7; i++) { 80 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 81 | } 82 | return result; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/views/next-rep-schedule.ts: -------------------------------------------------------------------------------- 1 | import IW from "../main"; 2 | import { LogTo } from "../logger"; 3 | import { SliderComponent, TextComponent, ButtonComponent } from "obsidian"; 4 | import { ModalBase } from "./modal-base"; 5 | import { MarkdownTableRow, MarkdownTable } from "../markdown"; 6 | import "../helpers/date-utils"; 7 | import { NaturalDateSuggest } from "./date-suggest"; 8 | 9 | export class NextRepScheduler extends ModalBase { 10 | private intervalComponent: TextComponent; 11 | private priorityComponent: SliderComponent; 12 | private repDateComponent: TextComponent; 13 | private curRep: MarkdownTableRow; 14 | private table: MarkdownTable; 15 | 16 | constructor(plugin: IW, curRep: MarkdownTableRow, table: MarkdownTable) { 17 | super(plugin); 18 | this.curRep = curRep; 19 | this.table = table; 20 | } 21 | 22 | onOpen() { 23 | this.subscribeToEvents(); 24 | let { contentEl } = this; 25 | 26 | contentEl.createEl("h2", { text: "Set Next Repetition Data" }); 27 | 28 | // 29 | // Date 30 | 31 | contentEl.appendText("Next repetition date: "); 32 | this.repDateComponent = new TextComponent(contentEl).setPlaceholder( 33 | this.curRep.nextRepDate.formatYYMMDD() 34 | ); 35 | new NaturalDateSuggest(this.plugin, this.repDateComponent.inputEl); 36 | contentEl.createEl("br"); 37 | 38 | this.repDateComponent.inputEl.focus(); 39 | this.repDateComponent.inputEl.select(); 40 | 41 | // 42 | // Priority 43 | 44 | contentEl.appendText("Priority: "); 45 | this.priorityComponent = new SliderComponent(contentEl) 46 | .setLimits(0, 100, 1) 47 | .setValue(this.curRep.priority) 48 | .setDynamicTooltip(); 49 | contentEl.createEl("br"); 50 | 51 | // 52 | // Interval 53 | contentEl.appendText("Interval: "); 54 | this.intervalComponent = new TextComponent(contentEl).setValue( 55 | this.curRep.interval.toString() 56 | ); 57 | contentEl.createEl("br"); 58 | 59 | // 60 | // Button 61 | 62 | new ButtonComponent(contentEl) 63 | .setButtonText("Schedule") 64 | .onClick(async () => { 65 | await this.schedule(); 66 | this.close(); 67 | }); 68 | } 69 | 70 | subscribeToEvents() { 71 | this.contentEl.addEventListener("keydown", async (ev) => { 72 | if (ev.key === "PageUp") { 73 | let curValue = this.priorityComponent.getValue(); 74 | if (curValue < 95) this.priorityComponent.setValue(curValue + 5); 75 | else this.priorityComponent.setValue(100); 76 | } else if (ev.key === "PageDown") { 77 | let curValue = this.priorityComponent.getValue(); 78 | if (curValue > 5) this.priorityComponent.setValue(curValue - 5); 79 | else this.priorityComponent.setValue(0); 80 | } else if (ev.key === "Enter") { 81 | await this.schedule(); 82 | this.close(); 83 | } 84 | }); 85 | } 86 | 87 | async schedule() { 88 | const dateStr = this.repDateComponent.getValue(); 89 | const date = this.plugin.dates.parseDate( 90 | dateStr === "" ? this.curRep.nextRepDate.formatYYMMDD() : dateStr 91 | ); 92 | if (!date) { 93 | LogTo.Console("Failed to parse next repetition date!", true); 94 | return; 95 | } 96 | 97 | const interval = Number(this.intervalComponent.getValue()); 98 | if (!interval.isValidInterval()) { 99 | LogTo.Console("Invalid interval data", true); 100 | return; 101 | } 102 | 103 | const priority = this.priorityComponent.getValue(); 104 | this.curRep.nextRepDate = date; 105 | this.curRep.priority = priority; 106 | this.curRep.interval = interval; 107 | await this.plugin.queue.writeQueueTable(this.table); 108 | await this.plugin.updateStatusBar(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/views/edit-data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SliderComponent, 3 | TextComponent, 4 | ButtonComponent, 5 | DropdownComponent, 6 | } from "obsidian"; 7 | import IW from "../main"; 8 | import { ModalBase } from "./modal-base"; 9 | import { LogTo } from "../logger"; 10 | import { MarkdownTable, MarkdownTableRow } from "../markdown"; 11 | import "../helpers/date-utils"; 12 | import "../helpers/number-utils"; 13 | import { NaturalDateSuggest } from "./date-suggest"; 14 | 15 | export class EditDataModal extends ModalBase { 16 | private inputSlider: SliderComponent; 17 | private inputNoteField: TextComponent; 18 | private inputNextRep: TextComponent; 19 | private currentRep: MarkdownTableRow; 20 | private intervalInput: TextComponent; 21 | private table: MarkdownTable; 22 | 23 | constructor(plugin: IW, curRep: MarkdownTableRow, table: MarkdownTable) { 24 | super(plugin); 25 | this.currentRep = curRep; 26 | this.table = table; 27 | } 28 | 29 | async onOpen() { 30 | let { contentEl } = this; 31 | 32 | contentEl.createEl("h2", { text: "Edit Rep Data" }); 33 | contentEl.createEl("p", { text: "Current Rep: " + this.currentRep.link }); 34 | 35 | // 36 | // Next Rep Date 37 | 38 | contentEl.appendText("Next Rep Date: "); 39 | this.inputNextRep = new TextComponent(contentEl).setPlaceholder( 40 | this.currentRep.nextRepDate.formatYYMMDD() 41 | ); 42 | new NaturalDateSuggest(this.plugin, this.inputNextRep.inputEl); 43 | contentEl.createEl("br"); 44 | 45 | this.inputNextRep.inputEl.focus(); 46 | this.inputNextRep.inputEl.select(); 47 | 48 | // 49 | // Priority 50 | 51 | contentEl.appendText("Priority: "); 52 | this.inputSlider = new SliderComponent(contentEl) 53 | .setLimits(0, 100, 1) 54 | .setValue(this.currentRep.priority) 55 | .setDynamicTooltip(); 56 | contentEl.createEl("br"); 57 | 58 | // 59 | // Interval 60 | 61 | contentEl.appendText("Interval: "); 62 | this.intervalInput = new TextComponent(contentEl).setValue( 63 | this.currentRep.interval.toString() 64 | ); 65 | contentEl.createEl("br"); 66 | 67 | // 68 | // Notes 69 | 70 | contentEl.appendText("Notes: "); 71 | this.inputNoteField = new TextComponent(contentEl).setValue( 72 | this.currentRep.notes 73 | ); 74 | contentEl.createEl("br"); 75 | 76 | // 77 | // Button 78 | 79 | contentEl.createEl("br"); 80 | new ButtonComponent(contentEl).setButtonText("Update").onClick(async () => { 81 | await this.updateRepData(); 82 | this.close(); 83 | }); 84 | 85 | this.subscribeToEvents(); 86 | } 87 | 88 | async updateStatusBar() { 89 | const curRep = (await this.plugin.queue.loadTable())?.currentRep(); 90 | this.plugin.statusBar.updateCurrentRep(curRep); 91 | } 92 | 93 | async updateRepData() { 94 | const dateStr = this.inputNextRep.getValue(); 95 | const date = this.plugin.dates.parseDate( 96 | dateStr === "" ? this.currentRep.nextRepDate.formatYYMMDD() : dateStr 97 | ); 98 | if (!date) { 99 | LogTo.Console("Failed to parse next repetition date!", true); 100 | return; 101 | } 102 | 103 | const interval = Number(this.intervalInput.getValue()); 104 | if (!interval.isValidInterval()) { 105 | LogTo.Console("Invalid interval data!", true); 106 | return; 107 | } 108 | 109 | const priority = this.inputSlider.getValue(); 110 | const notes = this.inputNoteField.getValue(); 111 | if (notes.contains("|")) { 112 | LogTo.Console("Repetition notes contain illegal character '|'.", true); 113 | return; 114 | } 115 | 116 | this.currentRep.nextRepDate = date; 117 | this.currentRep.interval = interval; 118 | this.currentRep.priority = priority; 119 | this.currentRep.notes = notes; 120 | await this.plugin.queue.writeQueueTable(this.table); 121 | LogTo.Debug("Updated repetition data.", true); 122 | await this.updateStatusBar(); 123 | } 124 | 125 | subscribeToEvents() { 126 | this.contentEl.addEventListener("keydown", async (ev) => { 127 | if (ev.key === "PageUp") { 128 | let curValue = this.inputSlider.getValue(); 129 | if (curValue < 95) this.inputSlider.setValue(curValue + 5); 130 | else this.inputSlider.setValue(100); 131 | } else if (ev.key === "PageDown") { 132 | let curValue = this.inputSlider.getValue(); 133 | if (curValue > 5) this.inputSlider.setValue(curValue - 5); 134 | else this.inputSlider.setValue(0); 135 | } else if (ev.key === "Enter") { 136 | await this.updateRepData(); 137 | this.close(); 138 | } 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/views/create-queue.ts: -------------------------------------------------------------------------------- 1 | import "../helpers/number-utils"; 2 | import { 3 | TextComponent, 4 | ButtonComponent, 5 | DropdownComponent, 6 | normalizePath, 7 | } from "obsidian"; 8 | import IW from "../main"; 9 | import { ModalBase } from "./modal-base"; 10 | import { LogTo } from "../logger"; 11 | import "../helpers/date-utils"; 12 | import "../helpers/str-utils"; 13 | import { AFactorScheduler, Scheduler, SimpleScheduler } from "src/scheduler"; 14 | 15 | export class CreateQueueModal extends ModalBase { 16 | private queueNameText: TextComponent; 17 | private intervalText: TextComponent; 18 | private afactorText: TextComponent; 19 | private schedulerDropdown: DropdownComponent; 20 | 21 | constructor(plugin: IW) { 22 | super(plugin); 23 | } 24 | 25 | onOpen() { 26 | let { contentEl } = this; 27 | 28 | contentEl.createEl("h2", { text: "Create and Load a New Queue" }); 29 | 30 | // 31 | // Queue Name 32 | contentEl.appendText("Queue Name: "); 33 | this.queueNameText = new TextComponent(contentEl).setPlaceholder( 34 | "Examples: queue, folder/queue" 35 | ); 36 | contentEl.createEl("br"); 37 | this.queueNameText.inputEl.focus(); 38 | this.queueNameText.inputEl.select(); 39 | 40 | // 41 | // Queue Type 42 | contentEl.appendText("Scheduler: "); 43 | this.schedulerDropdown = new DropdownComponent(contentEl) 44 | .addOption("afactor", "A-Factor Scheduler") 45 | .addOption("simple", "Simple Scheduler") 46 | .setValue(this.plugin.settings.defaultQueueType) 47 | .onChange((value: "afactor" | "simple") => 48 | this.showHideSchedulerSettings(value) 49 | ); 50 | contentEl.createEl("br"); 51 | 52 | // 53 | // Interval 54 | contentEl.appendText("Default Interval: "); 55 | this.intervalText = new TextComponent(contentEl).setValue("1"); 56 | contentEl.createEl("br"); 57 | 58 | // 59 | // Afactor 60 | contentEl.appendText("Default A-Factor: "); 61 | this.afactorText = new TextComponent(contentEl).setValue("2"); 62 | contentEl.createEl("br"); 63 | 64 | // 65 | // Button 66 | 67 | contentEl.createEl("br"); 68 | new ButtonComponent(contentEl) 69 | .setButtonText("Create Queue") 70 | .onClick(async () => { 71 | await this.create(); 72 | this.close(); 73 | }); 74 | 75 | this.subscribeToEvents(); 76 | } 77 | 78 | subscribeToEvents() { 79 | this.contentEl.addEventListener("keydown", async (ev) => { 80 | if (ev.key === "Enter") { 81 | await this.create(); 82 | this.close(); 83 | } 84 | }); 85 | } 86 | 87 | createScheduler(): Scheduler { 88 | if (this.schedulerDropdown.getValue() === "afactor") { 89 | const interval = Number(this.intervalText.getValue()); 90 | if (!interval.isValidInterval()) { 91 | LogTo.Debug("Invalid interval data.", true); 92 | return; 93 | } 94 | 95 | const afactor = Number(this.afactorText.getValue()); 96 | if (!afactor.isValidAFactor()) { 97 | LogTo.Debug("Invalid afactor data.", true); 98 | return; 99 | } 100 | 101 | return new AFactorScheduler(afactor, interval); 102 | } else { 103 | return new SimpleScheduler(); 104 | } 105 | } 106 | 107 | async create() { 108 | const queueName = this.queueNameText.getValue(); 109 | if (!queueName || queueName.length === 0) { 110 | LogTo.Debug("Invalid queue name.", true); 111 | return; 112 | } 113 | 114 | const queueNameWithExt = queueName.withExtension(".md"); 115 | const queueFile = normalizePath( 116 | [this.plugin.settings.queueFolderPath, queueNameWithExt].join("/") 117 | ); 118 | if (await this.plugin.files.exists(queueFile)) { 119 | LogTo.Debug("Queue already exists!", true); 120 | return; 121 | } 122 | 123 | const schedulerData = this.createScheduler()?.toString(); 124 | if (!schedulerData || schedulerData.length === 0) return; 125 | 126 | LogTo.Debug("Creating queue: " + queueName, true); 127 | await this.plugin.files.createIfNotExists(queueFile, schedulerData); 128 | await this.plugin.loadQueue(queueFile); 129 | } 130 | 131 | showHideSchedulerSettings(value: "simple" | "afactor") { 132 | switch (value) { 133 | case "simple": 134 | this.intervalText.setDisabled(true); 135 | this.afactorText.setDisabled(true); 136 | this.intervalText.setValue("---"); 137 | this.afactorText.setValue("---"); 138 | return; 139 | case "afactor": 140 | this.intervalText.setDisabled(false); 141 | this.afactorText.setDisabled(false); 142 | this.intervalText.setValue("1"); 143 | this.afactorText.setValue("2"); 144 | return; 145 | default: 146 | throw new Error("Expected simple or afactor, got: " + value); 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/queue.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownTable, MarkdownTableRow } from "./markdown"; 2 | import { LogTo } from "./logger"; 3 | import IW from "./main"; 4 | import matter from "gray-matter"; 5 | import { GrayMatterFile } from "gray-matter"; 6 | import { NextRepScheduler } from "./views/next-rep-schedule"; 7 | 8 | export class Queue { 9 | queuePath: string; 10 | plugin: IW; 11 | 12 | constructor(plugin: IW, filePath: string) { 13 | this.plugin = plugin; 14 | this.queuePath = filePath; 15 | } 16 | 17 | async createTableIfNotExists() { 18 | let data = new MarkdownTable(this.plugin).toString(); 19 | await this.plugin.files.createIfNotExists(this.queuePath, data); 20 | } 21 | 22 | async goToQueue(newLeaf: boolean) { 23 | await this.createTableIfNotExists(); 24 | await this.plugin.files.goTo(this.queuePath, newLeaf); 25 | } 26 | 27 | async dismissCurrent() { 28 | let table = await this.loadTable(); 29 | if (!table || !table.hasReps()) { 30 | LogTo.Debug("No repetitions!", true); 31 | if (table.removeDeleted) await this.writeQueueTable(table); 32 | return; 33 | } 34 | 35 | let curRep = table.currentRep(); 36 | if (!curRep.isDue()) { 37 | LogTo.Debug("No due repetition to dismiss.", true); 38 | if (table.removeDeleted) await this.writeQueueTable(table); 39 | return; 40 | } 41 | 42 | table.removeCurrentRep(); 43 | LogTo.Console("Dismissed repetition: " + curRep.link, true); 44 | await this.writeQueueTable(table); 45 | await this.plugin.updateStatusBar(); 46 | } 47 | 48 | async loadTable(): Promise { 49 | let text: string = await this.readQueue(); 50 | if (!text) { 51 | LogTo.Debug("Failed to load queue table."); 52 | return; 53 | } 54 | 55 | let fm = this.getFrontmatterString(text); 56 | let table = new MarkdownTable(this.plugin, fm, text); 57 | table.removeDeleted(); 58 | table.sortReps(); 59 | return table; 60 | } 61 | 62 | getFrontmatterString(text: string): GrayMatterFile { 63 | return matter(text); 64 | } 65 | 66 | async goToCurrentRep() { 67 | let table = await this.loadTable(); 68 | if (!table || !table.hasReps()) { 69 | if (table.removeDeleted) await this.writeQueueTable(table); 70 | LogTo.Console("No more repetitions!", true); 71 | return; 72 | } 73 | 74 | let currentRep = table.currentRep(); 75 | if (currentRep.isDue()) { 76 | await this.loadRep(currentRep); 77 | } else { 78 | LogTo.Console("No more repetitions!", true); 79 | } 80 | 81 | if (table.removeDeleted) await this.writeQueueTable(table); 82 | } 83 | 84 | async nextRepetition(): Promise { 85 | const table = await this.loadTable(); 86 | if (!table || !table.hasReps()) { 87 | LogTo.Console("No more repetitions!", true); 88 | if (table.removeDeleted) await this.writeQueueTable(table); 89 | return false; 90 | } 91 | 92 | const currentRep = table.currentRep(); 93 | const nextRep = table.nextRep(); 94 | 95 | // Not due; don't schedule or load 96 | if (currentRep && !currentRep.isDue()) { 97 | LogTo.Debug("No more repetitions!", true); 98 | if (table.removeDeleted) await this.writeQueueTable(table); 99 | return false; 100 | } 101 | 102 | table.removeCurrentRep(); 103 | table.schedule(currentRep); 104 | 105 | let repToLoad = null; 106 | if (currentRep && currentRep.isDue()) { 107 | repToLoad = currentRep; 108 | } else if (nextRep && nextRep.isDue()) { 109 | repToLoad = nextRep; 110 | } 111 | 112 | if (repToLoad) await this.loadRep(repToLoad); 113 | else LogTo.Debug("No more repetitions!", true); 114 | 115 | await this.writeQueueTable(table); 116 | 117 | if (this.plugin.settings.askForNextRepDate) { 118 | new NextRepScheduler(this.plugin, currentRep, table).open(); 119 | } 120 | await this.plugin.updateStatusBar(); 121 | return true; 122 | } 123 | 124 | private async loadRep(repToLoad: MarkdownTableRow) { 125 | if (!repToLoad) { 126 | LogTo.Console("Failed to load repetition.", true); 127 | return; 128 | } 129 | 130 | this.plugin.statusBar.updateCurrentRep(repToLoad); 131 | LogTo.Console("Loading repetition: " + repToLoad.link, true); 132 | await this.plugin.app.workspace.openLinkText(repToLoad.link, "", false, { 133 | active: true, 134 | }); 135 | } 136 | 137 | async add(...rows: MarkdownTableRow[]) { 138 | await this.createTableIfNotExists(); 139 | const table = await this.loadTable(); 140 | if (!table) { 141 | LogTo.Debug("Failed to create table.", true); 142 | return; 143 | } 144 | 145 | for (const row of rows) { 146 | if (table.hasRowWithLink(row.link)) { 147 | LogTo.Console( 148 | `Skipping ${row.link} because it is already in your queue!`, 149 | true 150 | ); 151 | continue; 152 | } 153 | 154 | if (row.link.contains("|") || row.notes.contains("|")) { 155 | LogTo.Console( 156 | `Skipping ${row.link} because it contains a pipe character.`, 157 | true 158 | ); 159 | continue; 160 | } 161 | 162 | table.addRow(row); 163 | LogTo.Console("Added note to queue: " + row.link, true); 164 | } 165 | 166 | await this.writeQueueTable(table); 167 | await this.plugin.updateStatusBar(); 168 | } 169 | 170 | getQueueAsTFile() { 171 | return this.plugin.files.getTFile(this.queuePath); 172 | } 173 | 174 | async writeQueueTable(table: MarkdownTable): Promise { 175 | let queue = this.getQueueAsTFile(); 176 | if (queue) { 177 | table.removeDeleted(); 178 | let data = table.toString(); 179 | table.sortReps(); 180 | await this.plugin.app.vault.modify(queue, data); 181 | } else { 182 | LogTo.Console("Failed to write queue because queue file was null.", true); 183 | } 184 | } 185 | 186 | async readQueue(): Promise { 187 | let queue = this.getQueueAsTFile(); 188 | try { 189 | return await this.plugin.app.vault.read(queue); 190 | } catch (Exception) { 191 | return; 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Incremental Writing Plugin for Obsidian 2 | 3 | This plugin allows you to do [incremental writing](https://supermemo.guru/wiki/Incremental_writing) in Obsidian. In incremental writing you add notes and blocks from your Obsidian vault to prioritised queues to be reviewed incrementally over time. 4 | 5 | If you are interested in learning more about this plugin and incremental writing in general, here are some resources you may find useful: 6 | 7 | - (Video) [What is Incremental Writing? (Obsidian and SuperMemo)](https://youtu.be/LLS_8Y744lk): A video I made to introduce the concept of incremental writing with examples in Obsidian and SuperMemo. 8 | - (Article) [Incremental Writing: A Summary](https://www.experimental-learning.com/SimpleGuru/IncrementalWriting.md): An article version of the above video. 9 | - (Video) [Obsidian Incremental Writing Plugin: Getting Started](https://youtu.be/bFF3umvXydQ): A video I made to explain the basic features of this plugin. 10 | - (Video) [Obsidian Incremental Writing Plugin: Advanced Stuff](https://youtu.be/onvKkHQfOzU): A video I made to explain some of the advanced features 11 | 12 | Also, if you find incremental writing useful, you should definitely check out [incremental reading](https://www.experimental-learning.com/en/SimpleGuru/IncrementalReading)! 13 | 14 | ### Support 15 | 16 | I want to put all of my energy into these projects and work on them full time! I also want to keep as much of my content open source and freely available as possible. That those seeking knowledge may find it! 17 | 18 | If you would like to support my work, I have a [Patreon page](https://www.patreon.com/experimental_learning) with rewards for each tier or you can [buy me a coffee](https://www.buymeacoffee.com/experilearning). 19 | 20 | ## Using the plugin 21 | 22 | ### Notes 23 | 24 | - This plugin adds a button to the search pane using private Obsidian APIs which could cause the plugin to break when Obsidian updates until I have time to fix it. 25 | - The Obsidian API is in early alpha so this plugin could break (temporarily) after an update. 26 | - I strongly recommend installing the [Natural Language Dates](https://github.com/argenos/nldates-obsidian) plugin alongside this plugin because it allows you to use natural language when you are asked to provide a date eg. "tomorrow" or "in two weeks", rather than having to type out a date like "2020-02-02". 27 | - This plugin is not supported on mobile! (yet) 28 | 29 | ### Important! Priorities 30 | 31 | - Confusingly, low priority numbers correspond to high priorities! That means your daily queue of repetitions will be sorted from lowest priority number (most important) to highest priority number (least important). This is because this is the way priorities work in SuperMemo and having used it for a couple years I just got used to thinking about it like that. I didn't realise how confusing this could be until someone mentioned it in an issue. Apologies for any confusion! 32 | 33 | ### Features 34 | 35 | #### Commands 36 | 37 | - **Load a queue**: The plugin supports multiple incremental writing queues that you can switch between using a fuzzy search menu. This command uses a fuzzy search component to search in the queue folder specified in the settings for queue files. 38 | - **Open queue in current pane**: Open the currently loaded queue in the current pane. You can check which queue is currently loaded by looking at the status bar at the bottom of the Obsidian window. 39 | - **Open queue in new pane**: Same as above, but open the currently loaded queue in a new pane. 40 | - **Add note to queue**: Adds the active note in Obsidian to the currently loaded incremental writing queue. 41 | - **Add block to queue**: Adds the current block to the currently loaded incremental writing queue. 42 | - **Current repetition**: Goes to the current repetition for the currently loaded queue. 43 | - **Next repetition**: Goes to the next repetition for the currently loaded queue. 44 | - **Edit current repetition data**: Edit the interval, priority, next repetition date or notes for the current repetition. 45 | - **Next repetition and manually schedule**: Executes next repetition and opens a modal for you to edit the next repetition date and interval manually. 46 | - **Dismiss current repetition**: Dismiss the current repetition from the queue. This note or block will not show up again for review. 47 | - **Add links within the current note to a queue**: Add any links to other notes within the current note to a queue. 48 | - **Bulk add blocks with references to queue**: Add all of the blocks with "^references" to an incremental writing queue. 49 | - **Add note to queue through a fuzzy finder**: Opens a fuzzy finder which you can use to add any note in your vault to the current incremental writing queue. 50 | - **Add search results to a queue**: Do a search and click the "Add to IW Queue" button at the top of the search pane to add all of the results to a queue. 51 | - **Add folders, files and links to a queue**: You can also right click on folders, files and links to add them to queues through the context menu. 52 | 53 | #### Automatically Add Notes to Queues 54 | 55 | There are some options for automatically adding notes to queues. 56 | 57 | - **Auto add notes using tags**: In the settings page you can define a list of queue names and associated tags. When you modify a note, the plugin will check to see if a queue tag was added. If so, the note will automatically get added to the queue. This mapping only applies to newly created notes, ie. when you install the plugin it won't automatically add all notes with a given tag to a queue. So the recommended workflow is to begin by searching for all notes with a given tag and adding those to a queue using the "add search results to queue" function (see above). Then you can set up the queue to tag mapping in the settings to make sure that future notes with a given tag get added to the queue. 58 | 59 | - **Auto add new notes option**: When toggled on in the settings, new will automatically get added to the default queue. I recommend using the tag method above rather than this because using tags gives you more control over which notes get added and which queue(s) they get added to. 60 | 61 | #### Scheduling Options 62 | 63 | There are currently two scheduling styles to choose from: A-Factor and Simple. 64 | 65 | - **Simple**: When you hit next repetition, the current repetition gets pushed to the end of the queue by setting its priority to 99. 66 | - **A-Factor**: When you hit next repetition, the interval between repetitions gets multiplied by the A-Factor to work out the next repetition date. 67 | -------------------------------------------------------------------------------- /src/views/suggest.ts: -------------------------------------------------------------------------------- 1 | import { App, ISuggestOwner, Scope } from "obsidian"; 2 | import { createPopper, Instance as PopperInstance } from "@popperjs/core"; 3 | 4 | const wrapAround = (value: number, size: number): number => { 5 | return ((value % size) + size) % size; 6 | }; 7 | 8 | class Suggest { 9 | private owner: ISuggestOwner; 10 | private values: T[]; 11 | private suggestions: HTMLDivElement[]; 12 | private selectedItem: number; 13 | private containerEl: HTMLElement; 14 | 15 | constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope) { 16 | this.owner = owner; 17 | this.containerEl = containerEl; 18 | 19 | containerEl.on( 20 | "click", 21 | ".suggestion-item", 22 | this.onSuggestionClick.bind(this) 23 | ); 24 | containerEl.on( 25 | "mousemove", 26 | ".suggestion-item", 27 | this.onSuggestionMouseover.bind(this) 28 | ); 29 | 30 | scope.register([], "ArrowUp", (event) => { 31 | if (!event.isComposing) { 32 | this.setSelectedItem(this.selectedItem - 1, true); 33 | return false; 34 | } 35 | }); 36 | 37 | scope.register([], "ArrowDown", (event) => { 38 | if (!event.isComposing) { 39 | this.setSelectedItem(this.selectedItem + 1, true); 40 | return false; 41 | } 42 | }); 43 | 44 | scope.register([], "Enter", (event) => { 45 | if (!event.isComposing) { 46 | this.useSelectedItem(event); 47 | return false; 48 | } 49 | }); 50 | } 51 | 52 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 53 | event.preventDefault(); 54 | 55 | const item = this.suggestions.indexOf(el); 56 | this.setSelectedItem(item, false); 57 | this.useSelectedItem(event); 58 | } 59 | 60 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 61 | const item = this.suggestions.indexOf(el); 62 | this.setSelectedItem(item, false); 63 | } 64 | 65 | setSuggestions(values: T[]) { 66 | this.containerEl.empty(); 67 | const suggestionEls: HTMLDivElement[] = []; 68 | 69 | values.forEach((value) => { 70 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 71 | this.owner.renderSuggestion(value, suggestionEl); 72 | suggestionEls.push(suggestionEl); 73 | }); 74 | 75 | this.values = values; 76 | this.suggestions = suggestionEls; 77 | this.setSelectedItem(0, false); 78 | } 79 | 80 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 81 | const currentValue = this.values[this.selectedItem]; 82 | if (currentValue) { 83 | this.owner.selectSuggestion(currentValue, event); 84 | } 85 | } 86 | 87 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 88 | const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length); 89 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 90 | const selectedSuggestion = this.suggestions[normalizedIndex]; 91 | 92 | prevSelectedSuggestion?.removeClass("is-selected"); 93 | selectedSuggestion?.addClass("is-selected"); 94 | 95 | this.selectedItem = normalizedIndex; 96 | 97 | if (scrollIntoView) { 98 | selectedSuggestion.scrollIntoView(false); 99 | } 100 | } 101 | } 102 | 103 | export abstract class TextInputSuggest implements ISuggestOwner { 104 | protected app: App; 105 | protected inputEl: HTMLInputElement; 106 | 107 | private popper: PopperInstance; 108 | private scope: Scope; 109 | private suggestEl: HTMLElement; 110 | private suggest: Suggest; 111 | 112 | constructor(app: App, inputEl: HTMLInputElement) { 113 | this.app = app; 114 | this.inputEl = inputEl; 115 | this.scope = new Scope(); 116 | 117 | this.suggestEl = createDiv("suggestion-container"); 118 | const suggestion = this.suggestEl.createDiv("suggestion"); 119 | this.suggest = new Suggest(this, suggestion, this.scope); 120 | 121 | this.scope.register([], "Escape", this.close.bind(this)); 122 | 123 | this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); 124 | this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); 125 | this.inputEl.addEventListener("blur", this.close.bind(this)); 126 | this.suggestEl.on( 127 | "mousedown", 128 | ".suggestion-container", 129 | (event: MouseEvent) => { 130 | event.preventDefault(); 131 | } 132 | ); 133 | } 134 | 135 | onInputChanged(): void { 136 | const inputStr = this.inputEl.value; 137 | const suggestions = this.getSuggestions(inputStr); 138 | 139 | if (suggestions.length > 0) { 140 | this.suggest.setSuggestions(suggestions); 141 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 142 | this.open((this.app).dom.appContainerEl, this.inputEl); 143 | } 144 | } 145 | 146 | open(container: HTMLElement, inputEl: HTMLElement): void { 147 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 148 | (this.app).keymap.pushScope(this.scope); 149 | 150 | container.appendChild(this.suggestEl); 151 | this.popper = createPopper(inputEl, this.suggestEl, { 152 | placement: "bottom-start", 153 | modifiers: [ 154 | { 155 | name: "sameWidth", 156 | enabled: true, 157 | fn: ({ state, instance }) => { 158 | // Note: positioning needs to be calculated twice - 159 | // first pass - positioning it according to the width of the popper 160 | // second pass - position it with the width bound to the reference element 161 | // we need to early exit to avoid an infinite loop 162 | const targetWidth = `${state.rects.reference.width}px`; 163 | if (state.styles.popper.width === targetWidth) { 164 | return; 165 | } 166 | state.styles.popper.width = targetWidth; 167 | instance.update(); 168 | }, 169 | phase: "beforeWrite", 170 | requires: ["computeStyles"], 171 | }, 172 | ], 173 | }); 174 | } 175 | 176 | close(): void { 177 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 178 | (this.app).keymap.popScope(this.scope); 179 | 180 | this.suggest.setSuggestions([]); 181 | this.popper.destroy(); 182 | this.suggestEl.detach(); 183 | } 184 | 185 | abstract getSuggestions(inputStr: string): T[]; 186 | abstract renderSuggestion(item: T, el: HTMLElement): void; 187 | abstract selectSuggestion(item: T): void; 188 | } 189 | -------------------------------------------------------------------------------- /src/markdown.ts: -------------------------------------------------------------------------------- 1 | import "./helpers/date-utils"; 2 | import { EOL } from "os"; 3 | import "./helpers/number-utils"; 4 | import { LinkEx } from "./helpers/link-utils"; 5 | import { Scheduler, SimpleScheduler, AFactorScheduler } from "./scheduler"; 6 | import IW from "./main"; 7 | import { GrayMatterFile } from "gray-matter"; 8 | import { LogTo } from "./logger"; 9 | import { markdownTable } from "markdown-table"; 10 | 11 | export class MarkdownTable { 12 | plugin: IW; 13 | scheduler: Scheduler; 14 | private header = ["Link", "Priority", "Notes", "Interval", "Next Rep"]; 15 | rows: MarkdownTableRow[] = []; 16 | removedDeleted: boolean = false; 17 | 18 | // TODO: just pass the gray matter object, replace text with contents. 19 | constructor(plugin: IW, frontMatter?: GrayMatterFile, text?: string) { 20 | this.plugin = plugin; 21 | this.scheduler = this.createScheduler(frontMatter); 22 | if (text) { 23 | text = text.trim(); 24 | let split = text.split(/\r?\n/); 25 | let idx = this.findYamlEnd(split); 26 | if (idx !== -1) 27 | // line after yaml + header 28 | this.rows = this.parseRows(split.slice(idx + 1 + 2)); 29 | } 30 | } 31 | 32 | removeDeleted() { 33 | let queuePath = this.plugin.queue.queuePath; 34 | let exists = this.rows.filter((r) => 35 | this.plugin.links.exists(r.link, queuePath) 36 | ); 37 | let removedNum = this.rows.length - exists.length; 38 | this.rows = exists; 39 | if (removedNum > 0) { 40 | this.removedDeleted = true; 41 | LogTo.Console(`Removed ${removedNum} reps with non-existent links.`); 42 | } 43 | } 44 | 45 | hasRowWithLink(link: string) { 46 | link = LinkEx.removeBrackets(link); 47 | return this.rows.some((r) => r.link === link); 48 | } 49 | 50 | schedule(rep: MarkdownTableRow) { 51 | this.scheduler.schedule(this, rep); 52 | } 53 | 54 | findYamlEnd(split: string[]) { 55 | let ct = 0; 56 | let idx = split.findIndex((value) => { 57 | if (value === "---") { 58 | if (ct === 1) { 59 | return true; 60 | } 61 | ct += 1; 62 | return false; 63 | } 64 | }); 65 | 66 | return idx; 67 | } 68 | 69 | private createScheduler(frontMatter: GrayMatterFile): Scheduler { 70 | let scheduler: Scheduler; 71 | 72 | // Default 73 | if (this.plugin.settings.defaultQueueType === "afactor") { 74 | scheduler = new AFactorScheduler(); 75 | } else if (this.plugin.settings.defaultQueueType === "simple") { 76 | scheduler = new SimpleScheduler(); 77 | } 78 | 79 | // Specified in YAML 80 | if (frontMatter) { 81 | let schedulerName = frontMatter.data["scheduler"]; 82 | if (schedulerName && schedulerName === "simple") { 83 | scheduler = new SimpleScheduler(); 84 | } else if (schedulerName && schedulerName === "afactor") { 85 | let afactor = Number(frontMatter.data["afactor"]); 86 | let interval = Number(frontMatter.data["interval"]); 87 | scheduler = new AFactorScheduler(afactor, interval); 88 | } 89 | } 90 | return scheduler; 91 | } 92 | 93 | parseRows(arr: string[]): MarkdownTableRow[] { 94 | return arr.map((v) => this.parseRow(v)); 95 | } 96 | 97 | parseRow(text: string): MarkdownTableRow { 98 | let arr = text 99 | .substr(1, text.length - 1) 100 | .split("|") 101 | .map((r) => r.trim()); 102 | return new MarkdownTableRow( 103 | arr[0], 104 | Number(arr[1]), 105 | arr[2], 106 | Number(arr[3]), 107 | new Date(arr[4]) 108 | ); 109 | } 110 | 111 | hasReps() { 112 | return this.rows.length > 0; 113 | } 114 | 115 | currentRep() { 116 | this.sortReps(); 117 | return this.rows[0]; 118 | } 119 | 120 | nextRep() { 121 | this.sortReps(); 122 | return this.rows[1]; 123 | } 124 | 125 | removeCurrentRep() { 126 | this.sortReps(); 127 | let removed; 128 | if (this.rows.length === 1) { 129 | removed = this.rows.pop(); 130 | } else if (this.rows.length > 1) { 131 | removed = this.rows[0]; 132 | this.rows = this.rows.slice(1); 133 | } 134 | return removed; 135 | } 136 | 137 | sortReps() { 138 | this.sortByPriority(); 139 | this.sortByDue(); 140 | } 141 | 142 | getReps() { 143 | return this.rows; 144 | } 145 | 146 | private sortByDue() { 147 | this.rows.sort((a, b) => { 148 | if (a.isDue() && !b.isDue()) return -1; 149 | if (a.isDue() && b.isDue()) return 0; 150 | if (!a.isDue() && b.isDue()) return 1; 151 | }); 152 | } 153 | 154 | private sortByPriority() { 155 | this.rows.sort((a, b) => { 156 | let fst = +a.priority; 157 | let snd = +b.priority; 158 | if (fst > snd) return 1; 159 | else if (fst == snd) return 0; 160 | else if (fst < snd) return -1; 161 | }); 162 | } 163 | 164 | addRow(row: MarkdownTableRow) { 165 | this.rows.push(row); 166 | } 167 | 168 | sort(compareFn: (a: MarkdownTableRow, b: MarkdownTableRow) => number) { 169 | if (this.rows) this.rows = this.rows.sort(compareFn); 170 | } 171 | 172 | toString() { 173 | const yaml = this.scheduler.toString(); 174 | const rows = this.toArray(); 175 | if (rows && rows.length > 0) { 176 | const align = { align: ["l", "r", "l", "r", "r"] }; 177 | return [yaml, markdownTable([this.header, ...rows], align)] 178 | .join(EOL) 179 | .trim(); 180 | } else { 181 | return yaml.trim(); 182 | } 183 | } 184 | 185 | toArray() { 186 | return this.rows.map((x) => x.toArray()); 187 | } 188 | } 189 | 190 | export class MarkdownTableRow { 191 | link: string; 192 | priority: number; 193 | notes: string; 194 | interval: number; 195 | nextRepDate: Date; 196 | 197 | constructor( 198 | link: string, 199 | priority: number, 200 | notes: string, 201 | interval: number = 1, 202 | nextRepDate: Date = new Date("1970-01-01") 203 | ) { 204 | this.link = LinkEx.removeBrackets(link); 205 | this.priority = priority.isValidPriority() ? priority : 30; 206 | this.notes = notes.replace(/(\r\n|\n|\r|\|)/gm, ""); 207 | this.interval = interval.isValidInterval() ? interval : 1; 208 | this.nextRepDate = nextRepDate.isValid() 209 | ? nextRepDate 210 | : new Date("1970-01-01"); 211 | } 212 | 213 | isDue(): boolean { 214 | return new Date(Date.now()) >= this.nextRepDate; 215 | } 216 | 217 | toArray() { 218 | return [ 219 | LinkEx.addBrackets(this.link), 220 | this.priority.toString(), 221 | this.notes, 222 | this.interval.toString(), 223 | this.nextRepDate.formatYYMMDD(), 224 | ]; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/views/bulk-adding.ts: -------------------------------------------------------------------------------- 1 | import { 2 | normalizePath, 3 | TFolder, 4 | SliderComponent, 5 | TextComponent, 6 | ButtonComponent, 7 | debounce, 8 | } from "obsidian"; 9 | import { ModalBase } from "./modal-base"; 10 | import IW from "../main"; 11 | import { Queue } from "../queue"; 12 | import { FileSuggest } from "./file-suggest"; 13 | import { MarkdownTableRow } from "../markdown"; 14 | import { LogTo } from "../logger"; 15 | import "../helpers/number-utils.ts"; 16 | import "../helpers/date-utils.ts"; 17 | import "../helpers/str-utils.ts"; 18 | import { NaturalDateSuggest } from "./date-suggest"; 19 | import path from "path"; 20 | 21 | export class BulkAdderModal extends ModalBase { 22 | private queuePath: string; 23 | private queueComponent: TextComponent; 24 | private minPriorityComponent: SliderComponent; 25 | private maxPriorityComponent: SliderComponent; 26 | private inputFirstRepMin: TextComponent; 27 | private inputFirstRepMax: TextComponent; 28 | private toAddCountDiv: HTMLDivElement; 29 | private outstanding: Set; 30 | private toAdd: string[] = []; 31 | private linkPaths: string[]; 32 | private title: string; 33 | 34 | constructor( 35 | plugin: IW, 36 | queuePath: string, 37 | title: string, 38 | linkPaths: string[] 39 | ) { 40 | super(plugin); 41 | this.queuePath = queuePath; 42 | this.title = title; 43 | this.linkPaths = linkPaths; 44 | } 45 | 46 | async updateToAdd() { 47 | await this.updateOutstanding(); 48 | this.toAdd = this.linkPaths.filter((pair) => !this.outstanding.has(pair)); 49 | this.toAddCountDiv.innerText = 50 | "To Add (excluding duplicates): " + this.toAdd.length; 51 | } 52 | 53 | async updateOutstanding() { 54 | const queuePath = this.getQueuePath(); 55 | if (await this.plugin.app.vault.adapter.exists(queuePath)) { 56 | const queue = new Queue(this.plugin, queuePath); 57 | const table = await queue.loadTable(); 58 | const alreadyAdded = table 59 | .getReps() 60 | .map((rep) => 61 | this.plugin.links.createAbsoluteLink(rep.link, queuePath) 62 | ); 63 | this.outstanding = new Set(alreadyAdded); 64 | } else { 65 | this.outstanding = new Set(); 66 | } 67 | } 68 | 69 | protected getQueuePath() { 70 | const queue = 71 | this.queueComponent.getValue() === "" 72 | ? path.relative( 73 | this.plugin.settings.queueFolderPath, 74 | this.plugin.queue.queuePath 75 | ) 76 | : this.queueComponent.getValue().withExtension(".md"); 77 | 78 | return normalizePath( 79 | [this.plugin.settings.queueFolderPath, queue].join("/") 80 | ); 81 | } 82 | 83 | async onOpen() { 84 | let { contentEl } = this; 85 | 86 | contentEl.createEl("h3", { text: this.title }); 87 | 88 | // 89 | // Queue 90 | 91 | contentEl.appendText("Queue: "); 92 | this.queueComponent = new TextComponent(contentEl) 93 | .setPlaceholder( 94 | path.relative( 95 | this.plugin.settings.queueFolderPath, 96 | this.plugin.queue.queuePath 97 | ) 98 | ) 99 | .onChange( 100 | debounce( 101 | (_: string) => { 102 | this.updateToAdd(); 103 | }, 104 | 500, 105 | true 106 | ) 107 | ); 108 | let folderFunc = () => 109 | this.plugin.app.vault.getAbstractFileByPath( 110 | this.plugin.settings.queueFolderPath 111 | ) as TFolder; 112 | new FileSuggest(this.plugin, this.queueComponent.inputEl, folderFunc); 113 | contentEl.createEl("br"); 114 | 115 | // 116 | // Note Count 117 | 118 | this.toAddCountDiv = contentEl.createDiv(); 119 | await this.updateToAdd(); 120 | 121 | // 122 | // Priorities 123 | 124 | // Min 125 | 126 | this.contentEl.appendText("Min Priority: "); 127 | this.minPriorityComponent = new SliderComponent(contentEl) 128 | .setLimits(0, 100, 1) 129 | .setDynamicTooltip() 130 | .onChange((value) => { 131 | if (this.maxPriorityComponent) { 132 | let max = this.maxPriorityComponent.getValue(); 133 | if (value > max) this.maxPriorityComponent.setValue(value); 134 | } 135 | }) 136 | .setValue(0); 137 | this.contentEl.createEl("br"); 138 | 139 | // Max 140 | 141 | this.contentEl.appendText("Max Priority: "); 142 | this.maxPriorityComponent = new SliderComponent(contentEl) 143 | .setLimits(0, 100, 1) 144 | .setDynamicTooltip() 145 | .onChange((value) => { 146 | if (this.minPriorityComponent) { 147 | let min = this.minPriorityComponent.getValue(); 148 | if (value < min) this.minPriorityComponent.setValue(value); 149 | } 150 | }) 151 | .setValue(100); 152 | this.contentEl.createEl("br"); 153 | 154 | // 155 | // Rep Dates 156 | 157 | this.contentEl.appendText("Earliest Rep Date: "); 158 | this.inputFirstRepMin = new TextComponent(contentEl).setPlaceholder( 159 | this.plugin.settings.defaultFirstRepDate 160 | ); 161 | new NaturalDateSuggest(this.plugin, this.inputFirstRepMin.inputEl); 162 | this.contentEl.createEl("br"); 163 | 164 | this.contentEl.appendText("Latest Rep Date: "); 165 | this.inputFirstRepMax = new TextComponent(contentEl).setPlaceholder( 166 | this.plugin.settings.defaultFirstRepDate 167 | ); 168 | new NaturalDateSuggest(this.plugin, this.inputFirstRepMax.inputEl); 169 | this.contentEl.createEl("br"); 170 | // 171 | // Events 172 | 173 | contentEl.addEventListener("keydown", (ev) => { 174 | if (ev.key === "Enter") { 175 | this.add(); 176 | } 177 | }); 178 | 179 | // 180 | // Button 181 | 182 | new ButtonComponent(contentEl) 183 | .setButtonText("Add to IW Queue") 184 | .onClick(async () => { 185 | await this.add(); 186 | this.close(); 187 | return; 188 | }); 189 | } 190 | 191 | async add() { 192 | if (this.toAdd.length === 0) { 193 | LogTo.Debug("Nothing to add (excluding duplicates).", true); 194 | this.close(); 195 | return; 196 | } 197 | 198 | const priMin = Number(this.minPriorityComponent.getValue()); 199 | const priMax = Number(this.maxPriorityComponent.getValue()); 200 | const dateMinStr = this.inputFirstRepMin.getValue(); 201 | const dateMaxStr = this.inputFirstRepMax.getValue(); 202 | const dateMin = this.plugin.dates.parseDate( 203 | dateMinStr === "" ? this.plugin.settings.defaultFirstRepDate : dateMinStr 204 | ); 205 | const dateMax = this.plugin.dates.parseDate( 206 | dateMaxStr === "" ? this.plugin.settings.defaultFirstRepDate : dateMaxStr 207 | ); 208 | 209 | if ( 210 | !( 211 | priMin.isValidPriority() && 212 | priMax.isValidPriority() && 213 | priMin <= priMax 214 | ) 215 | ) { 216 | LogTo.Debug("Min: " + priMin.toString()); 217 | LogTo.Debug("Max: " + priMax.toString()); 218 | LogTo.Debug("Priority data is invalid.", true); 219 | return; 220 | } 221 | 222 | if (!(dateMin.isValid() && dateMax.isValid() && dateMin <= dateMax)) { 223 | LogTo.Debug("Date data is invalid!", true); 224 | return; 225 | } 226 | 227 | let priStep = (priMax - priMin) / this.toAdd.length; 228 | let curPriority = priMin; 229 | let curDate = dateMin; 230 | let dateDiff = dateMin.daysDifference(dateMax); 231 | let numToAdd = this.toAdd.length > 0 ? this.toAdd.length : 1; 232 | let dateStep = dateDiff / numToAdd; 233 | let curStep = dateStep; 234 | 235 | const queuePath = this.getQueuePath(); 236 | const queue = new Queue(this.plugin, queuePath); 237 | const rows: MarkdownTableRow[] = []; 238 | LogTo.Console("To add: " + this.toAdd); 239 | for (let link of this.toAdd) { 240 | rows.push(new MarkdownTableRow(link, curPriority, "", 1, curDate)); 241 | curPriority = (curPriority + priStep).round(2); 242 | curDate = new Date(dateMin).addDays(curStep); 243 | curStep += dateStep; 244 | } 245 | await queue.add(...rows); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/views/modals.ts: -------------------------------------------------------------------------------- 1 | import { 2 | normalizePath, 3 | TFolder, 4 | MarkdownView, 5 | SliderComponent, 6 | TextComponent, 7 | ButtonComponent, 8 | } from "obsidian"; 9 | import IW from "../main"; 10 | import { ModalBase } from "./modal-base"; 11 | import { LogTo } from "../logger"; 12 | import { FileSuggest } from "./file-suggest"; 13 | import { Queue } from "../queue"; 14 | import { PriorityUtils } from "../helpers/priority-utils"; 15 | import { MarkdownTableRow } from "../markdown"; 16 | import "../helpers/date-utils"; 17 | import "../helpers/number-utils"; 18 | import { NaturalDateSuggest } from "./date-suggest"; 19 | import path from "path"; 20 | 21 | abstract class ReviewModal extends ModalBase { 22 | protected title: string; 23 | protected inputSlider: SliderComponent; 24 | protected inputNoteField: TextComponent; 25 | protected inputFirstRep: TextComponent; 26 | protected inputQueueField: TextComponent; 27 | protected titleNode: HTMLElement; 28 | 29 | constructor(plugin: IW, title: string) { 30 | super(plugin); 31 | this.title = title; 32 | } 33 | 34 | onOpen() { 35 | let { contentEl } = this; 36 | 37 | this.titleNode = contentEl.createEl("h2", { text: this.title }); 38 | 39 | // 40 | // Queue 41 | 42 | contentEl.appendText("Queue: "); 43 | this.inputQueueField = new TextComponent(contentEl).setPlaceholder( 44 | path.relative( 45 | this.plugin.settings.queueFolderPath, 46 | this.plugin.queue.queuePath 47 | ) 48 | ); 49 | let folderFunc = () => 50 | this.plugin.app.vault.getAbstractFileByPath( 51 | this.plugin.settings.queueFolderPath 52 | ) as TFolder; 53 | new FileSuggest(this.plugin, this.inputQueueField.inputEl, folderFunc); 54 | contentEl.createEl("br"); 55 | 56 | // 57 | // First Rep Date 58 | 59 | const firstRepDate = this.plugin.settings.defaultFirstRepDate; 60 | contentEl.appendText("First Rep Date: "); 61 | this.inputFirstRep = new TextComponent(contentEl).setPlaceholder( 62 | firstRepDate 63 | ); 64 | new NaturalDateSuggest(this.plugin, this.inputFirstRep.inputEl); 65 | contentEl.createEl("br"); 66 | 67 | this.inputFirstRep.inputEl.focus(); 68 | this.inputFirstRep.inputEl.select(); 69 | 70 | // 71 | // Priority 72 | 73 | let pMin = this.plugin.settings.defaultPriorityMin; 74 | let pMax = this.plugin.settings.defaultPriorityMax; 75 | contentEl.appendText("Priority: "); 76 | this.inputSlider = new SliderComponent(contentEl) 77 | .setLimits(0, 100, 1) 78 | .setValue(PriorityUtils.getPriorityBetween(pMin, pMax)) 79 | .setDynamicTooltip(); 80 | contentEl.createEl("br"); 81 | 82 | // 83 | // Notes 84 | 85 | contentEl.appendText("Notes: "); 86 | this.inputNoteField = new TextComponent(contentEl).setPlaceholder("Notes"); 87 | contentEl.createEl("br"); 88 | 89 | // 90 | // Button 91 | 92 | contentEl.createEl("br"); 93 | new ButtonComponent(contentEl) 94 | .setButtonText("Add to Queue") 95 | .onClick(async () => { 96 | await this.addToOutstanding(); 97 | this.close(); 98 | }); 99 | 100 | this.subscribeToEvents(); 101 | } 102 | 103 | subscribeToEvents() { 104 | this.contentEl.addEventListener("keydown", async (ev) => { 105 | if (ev.key === "PageUp") { 106 | let curValue = this.inputSlider.getValue(); 107 | if (curValue < 95) this.inputSlider.setValue(curValue + 5); 108 | else this.inputSlider.setValue(100); 109 | } else if (ev.key === "PageDown") { 110 | let curValue = this.inputSlider.getValue(); 111 | if (curValue > 5) this.inputSlider.setValue(curValue - 5); 112 | else this.inputSlider.setValue(0); 113 | } else if (ev.key === "Enter") { 114 | await this.addToOutstanding(); 115 | this.close(); 116 | } 117 | }); 118 | } 119 | 120 | getQueuePath() { 121 | const queue = 122 | this.inputQueueField.getValue() === "" 123 | ? path.relative( 124 | this.plugin.settings.queueFolderPath, 125 | this.plugin.queue.queuePath 126 | ) 127 | : this.inputQueueField.getValue().withExtension(".md"); 128 | 129 | return normalizePath( 130 | [this.plugin.settings.queueFolderPath, queue].join("/") 131 | ); 132 | } 133 | 134 | abstract addToOutstanding(): Promise; 135 | } 136 | 137 | export class ReviewNoteModal extends ReviewModal { 138 | constructor(plugin: IW) { 139 | super(plugin, "Add Note to Outstanding?"); 140 | } 141 | 142 | onOpen() { 143 | super.onOpen(); 144 | } 145 | 146 | async addToOutstanding() { 147 | const dateStr = this.inputFirstRep.getValue(); 148 | const date = this.plugin.dates.parseDate( 149 | dateStr === "" ? this.plugin.settings.defaultFirstRepDate : dateStr 150 | ); 151 | if (!date) { 152 | LogTo.Console("Failed to parse initial repetition date!"); 153 | return; 154 | } 155 | 156 | const queue = new Queue(this.plugin, this.getQueuePath()); 157 | const file = this.plugin.files.getActiveNoteFile(); 158 | if (!file) { 159 | LogTo.Console("Failed to add to outstanding.", true); 160 | return; 161 | } 162 | const link = this.plugin.files.toLinkText(file); 163 | const row = new MarkdownTableRow( 164 | link, 165 | this.inputSlider.getValue(), 166 | this.inputNoteField.getValue(), 167 | 1, 168 | date 169 | ); 170 | await queue.add(row); 171 | } 172 | } 173 | 174 | export class ReviewFileModal extends ReviewModal { 175 | filePath: string; 176 | 177 | constructor(plugin: IW, filePath: string) { 178 | super(plugin, "Add File to Outstanding?"); 179 | this.filePath = filePath; 180 | } 181 | 182 | onOpen() { 183 | super.onOpen(); 184 | } 185 | 186 | async addToOutstanding() { 187 | const dateStr = this.inputFirstRep.getValue(); 188 | const date = this.plugin.dates.parseDate( 189 | dateStr === "" ? this.plugin.settings.defaultFirstRepDate : dateStr 190 | ); 191 | if (!date) { 192 | LogTo.Console("Failed to parse initial repetition date!"); 193 | return; 194 | } 195 | 196 | const queue = new Queue(this.plugin, this.getQueuePath()); 197 | const file = this.plugin.files.getTFile(this.filePath); 198 | if (!file) { 199 | LogTo.Console("Failed to add to outstanding because file was null", true); 200 | return; 201 | } 202 | const link = this.plugin.files.toLinkText(file); 203 | const row = new MarkdownTableRow( 204 | link, 205 | this.inputSlider.getValue(), 206 | this.inputNoteField.getValue(), 207 | 1, 208 | date 209 | ); 210 | await queue.add(row); 211 | } 212 | } 213 | 214 | export class ReviewBlockModal extends ReviewModal { 215 | private customBlockRefInput: TextComponent; 216 | 217 | constructor(plugin: IW) { 218 | super(plugin, "Add Block to Outstanding?"); 219 | } 220 | 221 | onOpen() { 222 | super.onOpen(); 223 | let { contentEl } = this; 224 | this.customBlockRefInput = new TextComponent(contentEl); 225 | const br = contentEl.createEl("br"); 226 | this.titleNode.after( 227 | "Block Ref Name: ", 228 | this.customBlockRefInput.inputEl, 229 | br 230 | ); 231 | this.customBlockRefInput.inputEl.focus(); 232 | this.customBlockRefInput.inputEl.select(); 233 | } 234 | 235 | getCurrentLineNumber(): number | null { 236 | return (this.app.workspace.activeLeaf 237 | .view as MarkdownView).editor?.getCursor()?.line; 238 | } 239 | 240 | async addToOutstanding() { 241 | const dateStr = this.inputFirstRep.getValue(); 242 | const date = this.plugin.dates.parseDate( 243 | dateStr === "" ? this.plugin.settings.defaultFirstRepDate : dateStr 244 | ); 245 | if (!date) { 246 | LogTo.Console("Failed to parse initial repetition date!"); 247 | return; 248 | } 249 | 250 | const queue = new Queue(this.plugin, this.getQueuePath()); 251 | const file = this.plugin.files.getActiveNoteFile(); 252 | if (!file) { 253 | LogTo.Console("Failed to add to outstanding.", true); 254 | return; 255 | } 256 | 257 | const lineNumber = this.getCurrentLineNumber(); 258 | if (lineNumber == null) { 259 | LogTo.Console("Failed to get the current line number.", true); 260 | return; 261 | } 262 | 263 | const customRefName = this.customBlockRefInput.getValue(); 264 | const blockLink = await this.plugin.blocks.createBlockRefIfNotExists( 265 | lineNumber, 266 | file, 267 | customRefName 268 | ); 269 | if (!blockLink || blockLink.length === 0) { 270 | LogTo.Debug("Failed to add block to queue: block link was invalid."); 271 | return; 272 | } 273 | 274 | await queue.add( 275 | new MarkdownTableRow( 276 | blockLink, 277 | this.inputSlider.getValue(), 278 | this.inputNoteField.getValue(), 279 | 1, 280 | date 281 | ) 282 | ); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/views/settings-tab.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TFolder, 3 | SliderComponent, 4 | normalizePath, 5 | PluginSettingTab, 6 | App, 7 | Setting, 8 | debounce, 9 | } from "obsidian"; 10 | import IW from "../main"; 11 | import { FileSuggest, FolderSuggest } from "./file-suggest"; 12 | import { LogTo } from "src/logger"; 13 | import { NaturalDateSuggest } from "./date-suggest"; 14 | 15 | export class IWSettingsTab extends PluginSettingTab { 16 | plugin: IW; 17 | inputPriorityMin: SliderComponent; 18 | inputPriorityMax: SliderComponent; 19 | 20 | constructor(app: App, plugin: IW) { 21 | super(app, plugin); 22 | this.plugin = plugin; 23 | } 24 | 25 | display(): void { 26 | const { containerEl } = this; 27 | const settings = this.plugin.settings; 28 | containerEl.empty(); 29 | 30 | containerEl.createEl("h3", { text: "Incremental Writing Settings" }); 31 | 32 | // 33 | // Queue Folder 34 | 35 | new Setting(containerEl) 36 | .setName("Queue Folder") 37 | .setDesc( 38 | "The path to the folder where new incremental writing queues should be created. Relative to the vault root." 39 | ) 40 | .addText((text) => { 41 | text.setPlaceholder("Example: folder1/folder2"); 42 | new FolderSuggest(this.app, text.inputEl); 43 | text.setValue(String(settings.queueFolderPath)).onChange((value) => { 44 | settings.queueFolderPath = normalizePath(String(value)); 45 | this.plugin.saveData(settings); 46 | }); 47 | }); 48 | 49 | // 50 | // Default Queue 51 | 52 | new Setting(containerEl) 53 | .setName("Default Queue") 54 | .setDesc( 55 | "The name of the default incremental writing queue file. Relative to the queue folder." 56 | ) 57 | .addText((text) => { 58 | new FileSuggest( 59 | this.plugin, 60 | text.inputEl, 61 | () => 62 | this.app.vault.getAbstractFileByPath( 63 | settings.queueFolderPath 64 | ) as TFolder 65 | ); 66 | text.setPlaceholder("Example: queue.md"); 67 | text.setValue(String(settings.queueFileName)).onChange((value) => { 68 | let str = String(value); 69 | if (!str) return; 70 | let file = normalizePath(String(value)); 71 | if (!file.endsWith(".md")) file += ".md"; 72 | settings.queueFileName = file; 73 | this.plugin.saveData(settings); 74 | }); 75 | }); 76 | 77 | // 78 | // Default Queue Type 79 | 80 | new Setting(containerEl) 81 | .setName("Default Scheduler") 82 | .setDesc("The default scheduler to use for newly created queues.") 83 | .addDropdown((comp) => { 84 | comp.addOption("afactor", "A-Factor Scheduler"); 85 | comp.addOption("simple", "Simple Scheduler"); 86 | comp.setValue(String(settings.defaultQueueType)).onChange((value) => { 87 | settings.defaultQueueType = String(value); 88 | this.plugin.saveData(settings); 89 | }); 90 | }); 91 | 92 | const nldates = (this.plugin.app).plugins.getPlugin( 93 | "nldates-obsidian" 94 | ); 95 | const hasNlDates = nldates != null; 96 | 97 | // 98 | // Dropdown Dates 99 | new Setting(containerEl) 100 | .setName("Dropdown Date List") 101 | .setDesc( 102 | "Sets the default list of dropdown dates that show up in modals so you can quickly set repetition dates." 103 | ) 104 | .addTextArea((comp) => { 105 | comp.setPlaceholder("Example:\ntoday\ntomorrow\nnext week"); 106 | const currentValue = Object.keys( 107 | this.plugin.settings.dropdownNaturalDates 108 | ).join("\n"); 109 | comp.setValue(currentValue).onChange( 110 | debounce( 111 | (value) => { 112 | if (hasNlDates) { 113 | const inputDates = 114 | String(value) 115 | ?.split(/\r?\n/) 116 | ?.map((str) => [str, nldates.parseDate(str)]) || []; 117 | 118 | if (!inputDates || inputDates.length === 0) { 119 | LogTo.Debug("User inputted dates were null or empty"); 120 | settings.dropdownNaturalDates = {}; 121 | this.plugin.saveData(settings); 122 | return; 123 | } 124 | 125 | const validDates: string[] = inputDates 126 | .filter( 127 | ([_, date]: [string, any]) => date != null && date.date 128 | ) 129 | .map(([s, _]: [string, Date]) => s); 130 | 131 | if (inputDates.length !== validDates.length) { 132 | LogTo.Debug( 133 | `Ignoring ${ 134 | inputDates.length - validDates.length 135 | } invalid natural language date strings.` 136 | ); 137 | } 138 | 139 | const dateOptionsRecord: Record< 140 | string, 141 | string 142 | > = validDates.reduce((acc, x) => { 143 | acc[x] = x; 144 | return acc; 145 | }, {} as Record); 146 | 147 | LogTo.Debug( 148 | "Setting dropdown date options to " + 149 | JSON.stringify(dateOptionsRecord) 150 | ); 151 | settings.dropdownNaturalDates = dateOptionsRecord; 152 | this.plugin.saveData(settings); 153 | } 154 | }, 155 | 500, 156 | true 157 | ) 158 | ); 159 | }); 160 | 161 | // 162 | // First Rep Date 163 | 164 | new Setting(containerEl) 165 | .setName("Default First Rep Date") 166 | .setDesc( 167 | "Sets the default first repetition date for new repetitions. Example: today, tomorrow, next week. **Requires that you have installed the Natural Language Dates plugin.**" 168 | ) 169 | .addText((comp) => { 170 | new NaturalDateSuggest(this.plugin, comp.inputEl); 171 | comp 172 | .setValue(String(settings.defaultFirstRepDate)) 173 | .setPlaceholder("1970-01-01") 174 | .onChange( 175 | debounce( 176 | (value) => { 177 | if (hasNlDates) { 178 | const dateString = String(value); 179 | const date = nldates.parseDate(dateString); 180 | if (date && date.date) { 181 | LogTo.Debug( 182 | "Setting default first rep date to " + dateString 183 | ); 184 | settings.defaultFirstRepDate = dateString; 185 | this.plugin.saveData(settings); 186 | } else { 187 | LogTo.Debug("Invalid natural language date string."); 188 | } 189 | } 190 | }, 191 | 500, 192 | true 193 | ) 194 | ); 195 | }); 196 | 197 | // 198 | // Ask for next repetition date 199 | 200 | new Setting(containerEl) 201 | .setName("Ask for Next Repetition Date?") 202 | .setDesc( 203 | "Do you want to be asked to give the next repetition date when you execute the next repetition command?" 204 | ) 205 | .addToggle((comp) => { 206 | comp.setValue(Boolean(settings.askForNextRepDate)).onChange((value) => { 207 | settings.askForNextRepDate = Boolean(value); 208 | this.plugin.saveData(settings); 209 | }); 210 | }); 211 | 212 | // 213 | // Queue Tags 214 | 215 | new Setting(containerEl) 216 | .setName("Queue Tags") 217 | .setDesc( 218 | "Mapping from queue names to tags. Tagging a note with these tags will add it to the corresponding queue." 219 | ) 220 | .addTextArea((textArea) => { 221 | textArea.setPlaceholder( 222 | "Example:\nIW-Queue=iw,writing\nTasks=tasks,todo" 223 | ); 224 | const currentValue = Object.entries(settings.queueTagMap) 225 | .map( 226 | ([queue, tags]: [string, string[]]) => `${queue}=${tags.join(",")}` 227 | ) 228 | .join("\n"); 229 | 230 | textArea.setValue(currentValue).onChange((value) => { 231 | const str = String(value).trim(); 232 | if (!str) { 233 | LogTo.Debug("Setting the queue tag map to empty."); 234 | settings.queueTagMap = {}; 235 | this.plugin.saveData(settings); 236 | return; 237 | } else if ( 238 | !str.split(/\r?\n/).every((line) => line.match(/(.+)=(.+,?)+/)) 239 | ) { 240 | LogTo.Debug("Invalid queue tag map. Not saving."); 241 | return; 242 | } 243 | 244 | const isEmpty = (s: string | any[]) => !s || s.length === 0; 245 | const split: [string, string[]][] = str 246 | .split(/\r?\n/) 247 | .map((line) => line.split("=")) 248 | .map(([queue, tags]: [string, string]) => [ 249 | queue, 250 | tags 251 | .split(",") 252 | .map((s) => s.trim()) 253 | .filter((s) => !isEmpty(s)), 254 | ]); 255 | 256 | let queueTagMap: Record = {}; 257 | for (let [queue, tags] of split) { 258 | if (!isEmpty(queue) && !isEmpty(tags)) queueTagMap[queue] = tags; 259 | } 260 | 261 | settings.queueTagMap = queueTagMap; 262 | LogTo.Debug( 263 | "Updating queue tag map to: " + JSON.stringify(queueTagMap) 264 | ); 265 | this.plugin.saveData(settings); 266 | }); 267 | }); 268 | 269 | // 270 | // Skip New Note Dialog 271 | 272 | // new Setting(containerEl) 273 | // .setName("Skip Add Note Dialog?") 274 | // .setDesc("Skip the add note dialog and use the defaults?") 275 | // .addToggle((comp) => { 276 | // comp.setValue(Boolean(settings.skipAddNoteWindow)).onChange((value) => { 277 | // settings.skipAddNoteWindow = Boolean(value); 278 | // this.plugin.saveData(settings); 279 | // }) 280 | // }) 281 | 282 | // 283 | // Priority 284 | 285 | // Min 286 | 287 | new Setting(containerEl) 288 | .setName("Default Minimum Priority") 289 | .setDesc("Default minimum priority for new repetitions.") 290 | .addSlider((comp) => { 291 | this.inputPriorityMin = comp; 292 | comp.setDynamicTooltip(); 293 | comp.setValue(Number(settings.defaultPriorityMin)).onChange((value) => { 294 | if (this.inputPriorityMax) { 295 | let num = Number(value); 296 | if (!num.isValidPriority()) { 297 | return; 298 | } 299 | 300 | if (num > this.inputPriorityMax.getValue()) { 301 | this.inputPriorityMax.setValue(num); 302 | } 303 | 304 | settings.defaultPriorityMin = num; 305 | this.plugin.saveData(settings); 306 | } 307 | }); 308 | }); 309 | 310 | // Max 311 | 312 | new Setting(containerEl) 313 | .setName("Default Maximum Priority") 314 | .setDesc("Default maximum priority for new repetitions.") 315 | .addSlider((comp) => { 316 | this.inputPriorityMax = comp; 317 | comp.setDynamicTooltip(); 318 | comp.setValue(Number(settings.defaultPriorityMax)).onChange((value) => { 319 | if (this.inputPriorityMin) { 320 | let num = Number(value); 321 | if (!num.isValidPriority()) { 322 | return; 323 | } 324 | 325 | if (num < this.inputPriorityMin.getValue()) { 326 | this.inputPriorityMin.setValue(num); 327 | } 328 | 329 | settings.defaultPriorityMax = num; 330 | this.plugin.saveData(settings); 331 | } 332 | }); 333 | }); 334 | 335 | // Auto add 336 | 337 | new Setting(containerEl) 338 | .setName("Auto Add New Notes?") 339 | .setDesc("Automatically add new notes to the default queue?") 340 | .addToggle((comp) => { 341 | comp.setValue(settings.autoAddNewNotes).onChange((value) => { 342 | settings.autoAddNewNotes = value; 343 | this.plugin.saveData(settings); 344 | this.plugin.autoAddNewNotesOnCreate(); 345 | }); 346 | }); 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventRef, 3 | TFolder, 4 | Plugin, 5 | TFile, 6 | ButtonComponent, 7 | getAllTags, 8 | debounce, 9 | TAbstractFile, 10 | normalizePath, 11 | MarkdownView, 12 | } from "obsidian"; 13 | import { Queue } from "./queue"; 14 | import { LogTo } from "./logger"; 15 | import { 16 | ReviewFileModal, 17 | ReviewNoteModal, 18 | ReviewBlockModal, 19 | } from "./views/modals"; 20 | import { IWSettings, DefaultSettings } from "./settings"; 21 | import { IWSettingsTab } from "./views/settings-tab"; 22 | import { StatusBar } from "./views/status-bar"; 23 | import { QueueLoadModal } from "./views/queue-modal"; 24 | import { LinkEx } from "./helpers/link-utils"; 25 | import { FileUtils } from "./helpers/file-utils"; 26 | import { BulkAdderModal } from "./views/bulk-adding"; 27 | import { BlockUtils } from "./helpers/block-utils"; 28 | import { FuzzyNoteAdder } from "./views/fuzzy-note-adder"; 29 | import { MarkdownTableRow } from "./markdown"; 30 | import { NextRepScheduler } from "./views/next-rep-schedule"; 31 | import { EditDataModal } from "./views/edit-data"; 32 | import { DateParser } from "./helpers/parse-date"; 33 | import { CreateQueueModal } from "./views/create-queue"; 34 | 35 | export default class IW extends Plugin { 36 | public settings: IWSettings; 37 | public statusBar: StatusBar; 38 | public queue: Queue; 39 | 40 | // 41 | // Utils 42 | 43 | public readonly links: LinkEx = new LinkEx(this.app); 44 | public readonly files: FileUtils = new FileUtils(this.app); 45 | public readonly blocks: BlockUtils = new BlockUtils(this.app); 46 | public readonly dates: DateParser = new DateParser(this.app); 47 | 48 | private autoAddNewNotesOnCreateEvent: EventRef; 49 | private checkTagsOnModifiedEvent: EventRef; 50 | private tagMap: Map> = new Map(); 51 | 52 | async loadConfig() { 53 | this.settings = this.settings = Object.assign( 54 | {}, 55 | DefaultSettings, 56 | await this.loadData() 57 | ); 58 | } 59 | 60 | getQueueFiles() { 61 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 62 | const queueFiles = abstractFiles.filter((file: TAbstractFile) => { 63 | return ( 64 | file instanceof TFile && 65 | file.parent.path === this.settings.queueFolderPath && 66 | file.extension === "md" 67 | ); 68 | }); 69 | return queueFiles; 70 | } 71 | 72 | getDefaultQueuePath() { 73 | return normalizePath( 74 | [this.settings.queueFolderPath, this.settings.queueFileName].join("/") 75 | ); 76 | } 77 | 78 | createTagMap() { 79 | const notes: TFile[] = this.app.vault.getMarkdownFiles(); 80 | for (const note of notes) { 81 | const fileCachedData = this.app.metadataCache.getFileCache(note) || {}; 82 | const tags = new Set(getAllTags(fileCachedData) || []); 83 | this.tagMap.set(note, tags); 84 | } 85 | } 86 | 87 | async onload() { 88 | LogTo.Console("Loading..."); 89 | await this.loadConfig(); 90 | this.addSettingTab(new IWSettingsTab(this.app, this)); 91 | this.registerCommands(); 92 | this.subscribeToEvents(); 93 | } 94 | 95 | randomWithinInterval(min: number, max: number) { 96 | return Math.floor(Math.random() * (max - min + 1) + min); 97 | } 98 | 99 | checkTagsOnModified() { 100 | this.checkTagsOnModifiedEvent = this.app.vault.on( 101 | "modify", 102 | debounce( 103 | async (file) => { 104 | if (!(file instanceof TFile) || file.extension !== "md") { 105 | return; 106 | } 107 | 108 | const fileCachedData = 109 | this.app.metadataCache.getFileCache(file) || {}; 110 | 111 | const currentTags = new Set(getAllTags(fileCachedData) || []); 112 | const lastTags = this.tagMap.get(file) || new Set(); 113 | 114 | let setsEqual = (a: Set, b: Set) => 115 | a.size === b.size && [...a].every((value) => b.has(value)); 116 | if (setsEqual(new Set(currentTags), new Set(lastTags))) { 117 | LogTo.Debug("No tag changes."); 118 | return; 119 | } 120 | 121 | LogTo.Debug("Updating tags."); 122 | this.tagMap.set(file, currentTags); 123 | const newTags = [...currentTags].filter((x) => !lastTags.has(x)); // set difference 124 | LogTo.Debug("Added new tags: " + newTags.toString()); 125 | 126 | const queueFiles = this.getQueueFiles(); 127 | LogTo.Debug("Queue Files: " + queueFiles.toString()); 128 | 129 | const queueTagMap = this.settings.queueTagMap; 130 | const newQueueTags = newTags 131 | .map((tag) => tag.substr(1)) 132 | .filter((tag) => 133 | Object.values(queueTagMap).some((arr) => arr.contains(tag)) 134 | ); 135 | 136 | LogTo.Debug("New Queue Tags: " + newQueueTags.toString()); 137 | for (const queueTag of newQueueTags) { 138 | const addToQueueFiles = queueFiles 139 | .filter((f) => queueTagMap[f.name.substr(0, f.name.length - 3)]) 140 | .filter((f) => 141 | queueTagMap[f.name.substr(0, f.name.length - 3)].contains( 142 | queueTag 143 | ) 144 | ); 145 | 146 | for (const queueFile of addToQueueFiles) { 147 | const queue = new Queue(this, queueFile.path); 148 | LogTo.Debug(`Adding ${file.name} to ${queueFile.name}`); 149 | const link = this.files.toLinkText(file); 150 | const min = this.settings.defaultPriorityMin; 151 | const max = this.settings.defaultPriorityMax; 152 | const priority = this.randomWithinInterval(min, max); 153 | const date = this.dates.parseDate( 154 | this.settings.defaultFirstRepDate 155 | ); 156 | const row = new MarkdownTableRow(link, priority, "", 1, date); 157 | await queue.add(row); 158 | } 159 | } 160 | // already debounced 2 secs but not throttled, true on resetTimer throttles the callback 161 | }, 162 | 3000, 163 | true 164 | ) 165 | ); 166 | } 167 | 168 | autoAddNewNotesOnCreate() { 169 | if (this.settings.autoAddNewNotes) { 170 | this.autoAddNewNotesOnCreateEvent = this.app.vault.on( 171 | "create", 172 | async (file) => { 173 | if (!(file instanceof TFile) || file.extension !== "md") { 174 | return; 175 | } 176 | let link = this.files.toLinkText(file); 177 | let min = this.settings.defaultPriorityMin; 178 | let max = this.settings.defaultPriorityMax; 179 | let priority = this.randomWithinInterval(min, max); 180 | let row = new MarkdownTableRow(link, priority, ""); 181 | LogTo.Console("Auto adding new note to default queue: " + link); 182 | await this.queue.add(row); 183 | } 184 | ); 185 | } else { 186 | if (this.autoAddNewNotesOnCreateEvent) { 187 | this.app.vault.offref(this.autoAddNewNotesOnCreateEvent); 188 | this.autoAddNewNotesOnCreateEvent = undefined; 189 | } 190 | } 191 | } 192 | 193 | async getSearchLeafView() { 194 | return this.app.workspace.getLeavesOfType("search")[0]?.view; 195 | } 196 | 197 | async getFound() { 198 | const view = await this.getSearchLeafView(); 199 | if (!view) { 200 | LogTo.Console("Failed to get search leaf view."); 201 | return []; 202 | } 203 | // @ts-ignore 204 | return Array.from(view.dom.resultDomLookup.keys()); 205 | } 206 | 207 | async addSearchButton() { 208 | const view = await this.getSearchLeafView(); 209 | if (!view) { 210 | LogTo.Console("Failed to add button to the search pane."); 211 | return; 212 | } 213 | (view).addToQueueButton = new ButtonComponent( 214 | view.containerEl.children[0].firstChild as HTMLElement 215 | ) 216 | .setClass("nav-action-button") 217 | .setIcon("sheets-in-box") 218 | .setTooltip("Add to IW Queue") 219 | .onClick(async () => await this.addSearchResultsToQueue()); 220 | } 221 | 222 | async getSearchResults(): Promise { 223 | return (await this.getFound()) as TFile[]; 224 | } 225 | 226 | async addSearchResultsToQueue() { 227 | const files = await this.getSearchResults(); 228 | const pairs = files.map((file) => 229 | this.links.createAbsoluteLink(normalizePath(file.path), "") 230 | ); 231 | if (pairs && pairs.length > 0) { 232 | new BulkAdderModal( 233 | this, 234 | this.queue.queuePath, 235 | "Bulk Add Search Results", 236 | pairs 237 | ).open(); 238 | } else { 239 | LogTo.Console("No files to add.", true); 240 | } 241 | } 242 | 243 | async updateStatusBar() { 244 | const table = await this.queue.loadTable(); 245 | this.statusBar.updateCurrentRep(table?.currentRep()); 246 | this.statusBar.updateCurrentQueue(this.queue.queuePath); 247 | } 248 | 249 | async loadQueue(file: string) { 250 | if (file && file.length > 0) { 251 | this.queue = new Queue(this, file); 252 | await this.updateStatusBar(); 253 | LogTo.Console("Loaded Queue: " + file, true); 254 | } else { 255 | LogTo.Console("Failed to load queue.", true); 256 | } 257 | } 258 | 259 | registerCommands() { 260 | // 261 | // Queue Creation 262 | 263 | this.addCommand({ 264 | id: "create-new-iw-queue", 265 | name: "Create and load a new queue.", 266 | callback: () => new CreateQueueModal(this).open(), 267 | hotkeys: [], 268 | }); 269 | 270 | // 271 | // Queue Browsing 272 | 273 | this.addCommand({ 274 | id: "open-queue-current-pane", 275 | name: "Open queue in current pane.", 276 | callback: () => this.queue.goToQueue(false), 277 | hotkeys: [], 278 | }); 279 | 280 | this.addCommand({ 281 | id: "open-queue-new-pane", 282 | name: "Open queue in new pane.", 283 | callback: () => this.queue.goToQueue(true), 284 | hotkeys: [], 285 | }); 286 | 287 | // 288 | // Repetitions 289 | 290 | this.addCommand({ 291 | id: "current-iw-repetition", 292 | name: "Current repetition.", 293 | callback: async () => await this.queue.goToCurrentRep(), 294 | hotkeys: [], 295 | }); 296 | 297 | this.addCommand({ 298 | id: "dismiss-current-repetition", 299 | name: "Dismiss current repetition.", 300 | callback: async () => { 301 | await this.queue.dismissCurrent(); 302 | }, 303 | hotkeys: [], 304 | }); 305 | 306 | this.addCommand({ 307 | id: "next-iw-repetition-schedule", 308 | name: "Next repetition and manually schedule.", 309 | callback: async () => { 310 | const table = await this.queue.loadTable(); 311 | if (!table || !table.hasReps()) { 312 | LogTo.Console("No repetitions!", true); 313 | return; 314 | } 315 | const currentRep = table.currentRep(); 316 | if (await this.queue.nextRepetition()) { 317 | new NextRepScheduler(this, currentRep, table).open(); 318 | } 319 | }, 320 | }); 321 | 322 | this.addCommand({ 323 | id: "next-iw-repetition", 324 | name: "Next repetition.", 325 | callback: async () => await this.queue.nextRepetition(), 326 | hotkeys: [], 327 | }); 328 | 329 | this.addCommand({ 330 | id: "edit-current-rep-data", 331 | name: "Edit current rep data. ", 332 | callback: async () => { 333 | const table = await this.queue.loadTable(); 334 | if (!table || !table.hasReps()) { 335 | LogTo.Debug("No repetitions!", true); 336 | return; 337 | } 338 | 339 | const curRep = table.currentRep(); 340 | if (!curRep) { 341 | LogTo.Debug("No current repetition!", true); 342 | return; 343 | } 344 | 345 | new EditDataModal(this, curRep, table).open(); 346 | await this.updateStatusBar(); 347 | }, 348 | hotkeys: [], 349 | }); 350 | 351 | // 352 | // Element Adding. 353 | 354 | this.addCommand({ 355 | id: "add-links-in-selected-text", 356 | name: "Add links in selected text.", 357 | checkCallback: (checking) => { 358 | const view = this.app.workspace.getActiveViewOfType(MarkdownView); 359 | const editor = view?.editor; 360 | const file = view?.file; 361 | 362 | if (file && editor) { 363 | if (!checking) { 364 | const links = this.app.metadataCache.getFileCache(file).links ?? []; 365 | if (!links || links.length === 0) { 366 | LogTo.Debug("Active note does not contain any links.", true); 367 | return; 368 | } 369 | 370 | const selection = editor.getSelection(); 371 | if (!selection || selection.length === 0) { 372 | LogTo.Debug("No selected text.", true); 373 | return; 374 | } 375 | 376 | const selectedLinks = Array.from( 377 | links 378 | .filter((link) => selection.contains(link.original)) 379 | .map((link) => 380 | this.links.createAbsoluteLink(link.link, file.path) 381 | ) 382 | .filter((link) => link !== null && link.length > 0) 383 | .reduce((set, link) => set.add(link), new Set()) 384 | ); 385 | 386 | if (!selectedLinks || selectedLinks.length === 0) { 387 | LogTo.Debug("No selected links.", true); 388 | return; 389 | } 390 | 391 | LogTo.Debug("Selected links: " + selectedLinks.toString()); 392 | new BulkAdderModal( 393 | this, 394 | this.queue.queuePath, 395 | "Bulk Add Links", 396 | selectedLinks 397 | ).open(); 398 | } 399 | return true; 400 | } 401 | return false; 402 | }, 403 | }); 404 | 405 | this.addCommand({ 406 | id: "bulk-add-blocks", 407 | name: "Bulk add blocks with references to queue.", 408 | checkCallback: (checking) => { 409 | const file = this.files.getActiveNoteFile(); 410 | if (file != null) { 411 | if (!checking) { 412 | const refs = this.app.metadataCache.getFileCache(file).blocks; 413 | if (!refs) { 414 | LogTo.Debug("File does not contain any blocks with references."); 415 | } else { 416 | const fileLink = this.app.metadataCache.fileToLinktext( 417 | file, 418 | "", 419 | true 420 | ); 421 | const linkPaths = Object.keys(refs).map( 422 | (l) => fileLink + "#^" + l 423 | ); 424 | new BulkAdderModal( 425 | this, 426 | this.queue.queuePath, 427 | "Bulk Add Block References", 428 | linkPaths 429 | ).open(); 430 | } 431 | } 432 | return true; 433 | } 434 | return false; 435 | }, 436 | }); 437 | 438 | this.addCommand({ 439 | id: "note-add-iw-queue", 440 | name: "Add note to queue.", 441 | checkCallback: (checking: boolean) => { 442 | if (this.files.getActiveNoteFile() !== null) { 443 | if (!checking) { 444 | new ReviewNoteModal(this).open(); 445 | } 446 | return true; 447 | } 448 | return false; 449 | }, 450 | }); 451 | 452 | this.addCommand({ 453 | id: "fuzzy-note-add-iw-queue", 454 | name: "Add note to queue through a fuzzy finder", 455 | callback: () => new FuzzyNoteAdder(this).open(), 456 | hotkeys: [], 457 | }); 458 | 459 | this.addCommand({ 460 | id: "block-add-iw-queue", 461 | name: "Add block to queue.", 462 | checkCallback: (checking: boolean) => { 463 | if (this.files.getActiveNoteFile() != null) { 464 | if (!checking) { 465 | new ReviewBlockModal(this).open(); 466 | } 467 | return true; 468 | } 469 | return false; 470 | }, 471 | hotkeys: [], 472 | }); 473 | 474 | this.addCommand({ 475 | id: "add-links-within-note", 476 | name: "Add links within note to queue.", 477 | checkCallback: (checking: boolean) => { 478 | const file = this.files.getActiveNoteFile(); 479 | if (file !== null) { 480 | if (!checking) { 481 | const links = this.links.getLinksIn(file); 482 | if (links && links.length > 0) { 483 | new BulkAdderModal( 484 | this, 485 | this.queue.queuePath, 486 | "Bulk Add Links", 487 | links 488 | ).open(); 489 | } else { 490 | LogTo.Console("No links in the current file.", true); 491 | } 492 | } 493 | return true; 494 | } 495 | return false; 496 | }, 497 | hotkeys: [], 498 | }); 499 | 500 | // 501 | // Queue Loading 502 | 503 | this.addCommand({ 504 | id: "load-iw-queue", 505 | name: "Load a queue.", 506 | callback: () => { 507 | new QueueLoadModal(this).open(); 508 | }, 509 | hotkeys: [], 510 | }); 511 | } 512 | 513 | createStatusBar() { 514 | this.statusBar = new StatusBar(this.addStatusBarItem(), this); 515 | this.statusBar.initStatusBar(); 516 | } 517 | 518 | subscribeToEvents() { 519 | this.app.workspace.onLayoutReady(async () => { 520 | this.createStatusBar(); 521 | const queuePath = this.getDefaultQueuePath(); 522 | await this.loadQueue(queuePath); 523 | this.createTagMap(); 524 | this.checkTagsOnModified(); 525 | this.addSearchButton(); 526 | this.autoAddNewNotesOnCreate(); 527 | }); 528 | 529 | this.registerEvent( 530 | this.app.workspace.on("file-menu", (menu, file, _: string) => { 531 | if (file == null) { 532 | return; 533 | } 534 | 535 | if (file instanceof TFile && file.extension === "md") { 536 | menu.addItem((item) => { 537 | item 538 | .setTitle(`Add File to IW Queue`) 539 | .setIcon("sheets-in-box") 540 | .onClick((_) => { 541 | new ReviewFileModal(this, file.path).open(); 542 | }); 543 | }); 544 | } else if (file instanceof TFolder) { 545 | menu.addItem((item) => { 546 | item 547 | .setTitle(`Add Folder to IW Queue`) 548 | .setIcon("sheets-in-box") 549 | .onClick((_) => { 550 | const pairs = this.app.vault 551 | .getMarkdownFiles() 552 | .filter((f) => this.files.isDescendantOf(f, file)) 553 | .map((f) => 554 | this.links.createAbsoluteLink(normalizePath(f.path), "") 555 | ); 556 | 557 | if (pairs && pairs.length > 0) { 558 | new BulkAdderModal( 559 | this, 560 | this.queue.queuePath, 561 | "Bulk Add Folder Notes", 562 | pairs 563 | ).open(); 564 | } else { 565 | LogTo.Console("Folder contains no files!", true); 566 | } 567 | }); 568 | }); 569 | } 570 | }) 571 | ); 572 | } 573 | 574 | async removeSearchButton() { 575 | let searchView = await this.getSearchLeafView(); 576 | let btn = (searchView)?.addToQueueButton; 577 | if (btn) { 578 | btn.buttonEl?.remove(); 579 | btn = null; 580 | } 581 | } 582 | 583 | unsubscribeFromEvents() { 584 | for (let e of [ 585 | this.autoAddNewNotesOnCreateEvent, 586 | this.checkTagsOnModifiedEvent, 587 | ]) { 588 | this.app.vault.offref(e); 589 | e = undefined; 590 | } 591 | } 592 | 593 | async onunload() { 594 | LogTo.Console("Disabled and unloaded."); 595 | await this.removeSearchButton(); 596 | this.unsubscribeFromEvents(); 597 | } 598 | } 599 | --------------------------------------------------------------------------------