diff --git a/manifest.json b/manifest.json index a982832..8328731 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "repeat-plugin", "name": "Repeat", - "version": "1.8.0", + "version": "1.9.0", "minAppVersion": "1.1.0", "description": "Review notes using periodic or spaced repetition.", "author": "Andre Perunicic", diff --git a/package.json b/package.json index 8e82a24..a603e9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-repeat-plugin", - "version": "1.8.0", + "version": "1.9.0", "description": "An Obsidian plugin to review notes using periodic or spaced repetition.", "main": "./src/main.ts", "scripts": { diff --git a/src/main.ts b/src/main.ts index f4be80e..f5383cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -77,7 +77,11 @@ export default class RepeatPlugin extends Plugin { this.statusBarItem = this.addStatusBarItem(); } const dueNoteCount = getNotesDue( - getAPI(this.app), this.settings.ignoreFolderPath)?.length; + getAPI(this.app), + this.settings.ignoreFolderPath, + undefined, + this.settings.enqueueNonRepeatingNotes, + this.settings.defaultRepeat)?.length; if (dueNoteCount != undefined && this.statusBarItem) { this.statusBarItem.setText( `${dueNoteCount} repeat notes due`); @@ -191,6 +195,7 @@ export default class RepeatPlugin extends Plugin { ...repeat, hidden: parseHiddenFieldFromMarkdown(content), repeatDueAt, + virtual: false, })); this.app.vault.modify(file, newContent); } @@ -222,6 +227,28 @@ export default class RepeatPlugin extends Plugin { return false; } }); + + this.addCommand({ + id: 'repeat-never', + name: 'Never repeat this note', + checkCallback: (checking: boolean) => { + const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (markdownView && !!markdownView.file) { + if (!checking) { + const { editor, file } = markdownView; + const content = editor.getValue(); + const newContent = updateRepetitionMetadata(content, { + repeat: 'never', + due_at: undefined, + hidden: undefined, + }); + this.app.vault.modify(file, newContent); + } + return true; + } + return false; + } + }); } async onload() { @@ -319,7 +346,6 @@ class RepeatPluginSettingTab extends PluginSettingTab { .setName('Default `repeat` property') .setDesc('Used to populate "Repeat this note..." command\'s modal. Ignored if the supplied value is not parsable.') .addText((component) => { - console.log(this.plugin.settings.defaultRepeat); return component .setValue(serializeRepeat(this.plugin.settings.defaultRepeat)) .onChange(async (value) => { @@ -329,5 +355,15 @@ class RepeatPluginSettingTab extends PluginSettingTab { }); }); + new Setting(containerEl) + .setName('Enqueue non-repeating notes') + .setDesc('Add notes without a repeat field to the end of the queue. Useful to quickly make new notes repeating during reviews.') + .addToggle(component => component + .setValue(this.plugin.settings.enqueueNonRepeatingNotes) + .onChange(async (value) => { + this.plugin.settings.enqueueNonRepeatingNotes = value; + await this.plugin.saveSettings(); + })); + } } diff --git a/src/repeat/choices.test.ts b/src/repeat/choices.test.ts index 082992e..18852e8 100644 --- a/src/repeat/choices.test.ts +++ b/src/repeat/choices.test.ts @@ -5,6 +5,7 @@ import { getRepeatChoices, DISMISS_BUTTON_TEXT, SKIP_BUTTON_TEXT, + NEVER_BUTTON_TEXT, } from './choices'; import { parseTime } from './parsers'; @@ -39,6 +40,11 @@ const invalidRepetition = { repeatDueAt: dueAt, }; +// Helper function to check if nextRepetition is a Repetition object +function isRepetition(nextRepetition: Repetition | 'DISMISS' | 'NEVER'): nextRepetition is Repetition { + return typeof nextRepetition === 'object' && nextRepetition !== null; +} + test.concurrent.each([ ...['HOUR', 'DAY', 'MONTH', 'YEAR'].map((repeatPeriodUnit) => ({ ...periodicRepetition, @@ -55,22 +61,22 @@ test.concurrent.each([ expect(choices).toHaveLength(1); expect(choices[0]).toStrictEqual({ text: DISMISS_BUTTON_TEXT, - nextRepetition: null, + nextRepetition: 'DISMISS', } as RepeatChoice); return; } expect(choices).toHaveLength(2); choices.forEach((choice) => { - expect(choice.nextRepetition?.repeatDueAt).not.toBeNull(); - // @ts-ignore - expect(choice.nextRepetition?.repeatDueAt > now).toBe(true); - if (choice.text !== SKIP_BUTTON_TEXT) { - // @ts-ignore - expect(choice.nextRepetition?.repeatDueAt.hour).toBe( - (repetition.repeatTimeOfDay === 'AM') - ? parseTime(mockPluginSettings.morningReviewTime).hour - : parseTime(mockPluginSettings.eveningReviewTime).hour, - ); + if (isRepetition(choice.nextRepetition)) { + expect(choice.nextRepetition.repeatDueAt).not.toBeNull(); + expect(choice.nextRepetition.repeatDueAt > now).toBe(true); + if (choice.text !== SKIP_BUTTON_TEXT) { + expect(choice.nextRepetition.repeatDueAt.hour).toBe( + (repetition.repeatTimeOfDay === 'AM') + ? parseTime(mockPluginSettings.morningReviewTime).hour + : parseTime(mockPluginSettings.eveningReviewTime).hour, + ); + } } }); }); @@ -91,7 +97,7 @@ test.concurrent.each([ expect(choices).toHaveLength(1); expect(choices[0]).toStrictEqual({ text: DISMISS_BUTTON_TEXT, - nextRepetition: null, + nextRepetition: 'DISMISS', }); return; } @@ -99,16 +105,18 @@ test.concurrent.each([ // Skip button. const firstChoice = choices.shift(); - expect(firstChoice?.nextRepetition?.repeatPeriodUnit).toBe( - repetition.repeatPeriodUnit); - expect(firstChoice?.nextRepetition?.repeatPeriod).toBe( - repetition.repeatPeriod); + if (firstChoice && isRepetition(firstChoice.nextRepetition)) { + expect(firstChoice.nextRepetition.repeatPeriodUnit).toBe( + repetition.repeatPeriodUnit); + expect(firstChoice.nextRepetition.repeatPeriod).toBe( + repetition.repeatPeriod); + } choices.forEach((choice) => { - expect(choice.nextRepetition).not.toBeNull(); - // @ts-ignore - expect(choice.nextRepetition?.repeatDueAt > now).toBe(true); - expect(choice.nextRepetition?.repeatPeriodUnit).toBe('HOUR'); + if (isRepetition(choice.nextRepetition)) { + expect(choice.nextRepetition.repeatDueAt > now).toBe(true); + expect(choice.nextRepetition.repeatPeriodUnit).toBe('HOUR'); + } }); }); @@ -119,6 +127,72 @@ test('a note with invalid repetition gets only a skip choice', () => { expect(choices).toHaveLength(1); expect(choices[0]).toStrictEqual({ text: DISMISS_BUTTON_TEXT, - nextRepetition: null, + nextRepetition: 'DISMISS', }); }); + +test('periodic repetition includes NEVER button when enqueueNonRepeatingNotes is true', () => { + const settingsWithEnqueue = { + ...mockPluginSettings, + enqueueNonRepeatingNotes: true, + }; + + const choices = getRepeatChoices(periodicRepetition, settingsWithEnqueue as any); + + // Should have 3 choices: skip, next repetition, and never + expect(choices).toHaveLength(3); + + // Check that the NEVER button is present + const neverChoice = choices.find(choice => choice.nextRepetition === 'NEVER'); + expect(neverChoice).toBeDefined(); + expect(neverChoice?.text).toBe(NEVER_BUTTON_TEXT); +}); + +test('spaced repetition includes NEVER button when enqueueNonRepeatingNotes is true', () => { + const settingsWithEnqueue = { + ...mockPluginSettings, + enqueueNonRepeatingNotes: true, + }; + + const choices = getRepeatChoices(spacedRepetition, settingsWithEnqueue as any); + + // Should have 6 choices: skip, 4 multiplier choices, and never + expect(choices).toHaveLength(6); + + // Check that the NEVER button is present + const neverChoice = choices.find(choice => choice.nextRepetition === 'NEVER'); + expect(neverChoice).toBeDefined(); + expect(neverChoice?.text).toBe(NEVER_BUTTON_TEXT); +}); + +test('periodic repetition excludes NEVER button when enqueueNonRepeatingNotes is false', () => { + const settingsWithoutEnqueue = { + ...mockPluginSettings, + enqueueNonRepeatingNotes: false, + }; + + const choices = getRepeatChoices(periodicRepetition, settingsWithoutEnqueue as any); + + // Should have 2 choices: skip and next repetition (no never) + expect(choices).toHaveLength(2); + + // Check that the NEVER button is not present + const neverChoice = choices.find(choice => choice.nextRepetition === 'NEVER'); + expect(neverChoice).toBeUndefined(); +}); + +test('spaced repetition excludes NEVER button when enqueueNonRepeatingNotes is false', () => { + const settingsWithoutEnqueue = { + ...mockPluginSettings, + enqueueNonRepeatingNotes: false, + }; + + const choices = getRepeatChoices(spacedRepetition, settingsWithoutEnqueue as any); + + // Should have 5 choices: skip and 4 multiplier choices (no never) + expect(choices).toHaveLength(5); + + // Check that the NEVER button is not present + const neverChoice = choices.find(choice => choice.nextRepetition === 'NEVER'); + expect(neverChoice).toBeUndefined(); +}); diff --git a/src/repeat/choices.ts b/src/repeat/choices.ts index ff4e428..48ca809 100644 --- a/src/repeat/choices.ts +++ b/src/repeat/choices.ts @@ -6,7 +6,8 @@ import { uniqByField } from '../utils'; import { RepeatPluginSettings } from '../settings'; import { parseTime } from './parsers'; -export const DISMISS_BUTTON_TEXT = 'dismiss'; +export const DISMISS_BUTTON_TEXT = 'Dismiss'; +export const NEVER_BUTTON_TEXT = 'Never'; export const SKIP_PERIOD_MINUTES = 5; export const SKIP_BUTTON_TEXT = `${SKIP_PERIOD_MINUTES} minutes (skip)`; @@ -79,11 +80,11 @@ function getPeriodicRepeatChoices( if ((repeatDueAt > now) || !repeatDueAt) { return [{ text: DISMISS_BUTTON_TEXT, - nextRepetition: null, + nextRepetition: 'DISMISS', }]; } const nextRepeatDueAt = incrementRepeatDueAt({ ...repetition }, settings); - return [{ + const choices: RepeatChoice[] = [{ text: SKIP_BUTTON_TEXT, nextRepetition: { ...repetition, @@ -96,6 +97,15 @@ function getPeriodicRepeatChoices( repeatDueAt: nextRepeatDueAt, }, }]; + + if (settings.enqueueNonRepeatingNotes) { + choices.push({ + text: NEVER_BUTTON_TEXT, + nextRepetition: 'NEVER', + }); + } + + return choices; } /** @@ -119,12 +129,12 @@ function getSpacedRepeatChoices( if ((repeatDueAt > now) || !repeatDueAt) { return [{ text: DISMISS_BUTTON_TEXT, - nextRepetition: null, + nextRepetition: 'DISMISS', }]; } const morningReviewTime = parseTime(settings.morningReviewTime); const eveningReviewTime = parseTime(settings.eveningReviewTime); - const multiplierChoices = [0.5, 1.0, 1.5, 2.0].map((multiplier) => { + const multiplierChoices: RepeatChoice[] = [0.5, 1.0, 1.5, 2.0].map((multiplier) => { let nextRepeatDueAt = now.plus({ [repeatPeriodUnit]: multiplier * repeatPeriod, }); @@ -160,7 +170,7 @@ function getSpacedRepeatChoices( } }; }); - return uniqByField([ + const choices: RepeatChoice[] = [ { text: SKIP_BUTTON_TEXT, nextRepetition: { @@ -169,7 +179,14 @@ function getSpacedRepeatChoices( }, }, ...multiplierChoices, - ], 'text'); + ]; + if (settings.enqueueNonRepeatingNotes) { + choices.push({ + text: NEVER_BUTTON_TEXT, + nextRepetition: 'NEVER', + }); + } + return uniqByField(choices, 'text'); } /** @@ -195,6 +212,6 @@ export function getRepeatChoices( } return [{ text: DISMISS_BUTTON_TEXT, - nextRepetition: null, + nextRepetition: 'DISMISS', }]; } diff --git a/src/repeat/obsidian/RepeatView.tsx b/src/repeat/obsidian/RepeatView.tsx index 5de8170..220a4ac 100644 --- a/src/repeat/obsidian/RepeatView.tsx +++ b/src/repeat/obsidian/RepeatView.tsx @@ -157,7 +157,9 @@ class RepeatView extends ItemView { const page = getNextDueNote( this.dv, this.settings.ignoreFolderPath, - ignoreFilePath); + ignoreFilePath, + this.settings.enqueueNonRepeatingNotes, + this.settings.defaultRepeat); if (!page) { this.setMessage('All done for now!'); this.buttonsContainer.createEl('button', { @@ -253,11 +255,6 @@ class RepeatView extends ItemView { (buttonElement) => { buttonElement.onclick = async () => { this.resetView(); - if (!choice.nextRepetition) { - // TODO: Handle case of null nextRepetition properly. - this.setPage(); - return; - } const markdown = await this.app.vault.read(file); const newMarkdown = updateRepetitionMetadata( markdown, serializeRepetition(choice.nextRepetition)); diff --git a/src/repeat/parsers.test.ts b/src/repeat/parsers.test.ts index 57ef55b..7529e72 100644 --- a/src/repeat/parsers.test.ts +++ b/src/repeat/parsers.test.ts @@ -17,6 +17,7 @@ describe('parseRepetitionFields', () => { repeatTimeOfDay: 'AM', repeatDueAt: DateTime.fromISO(referenceRepeatDueAt), hidden: false, + virtual: false, }; test.concurrent.each([ { @@ -100,6 +101,7 @@ describe('parseRepetitionFields', () => { repeatTimeOfDay: 'AM', repeatDueAt: DateTime.fromISO(referenceRepeatDueAt), hidden: false, + virtual: false, }; const makeUnitCases = (unit: string) => ([{ repeat: `every ${unit}`, @@ -168,9 +170,10 @@ test('spaced without period specified', () => { delete repetition.repeatDueAt; expect(repetition).toEqual({ repeatStrategy: 'SPACED', - repeatPeriod: 24, - repeatPeriodUnit: 'HOUR', + repeatPeriod: 1, + repeatPeriodUnit: 'DAY', repeatTimeOfDay: 'AM', hidden: false, + virtual: false, }); }); diff --git a/src/repeat/parsers.ts b/src/repeat/parsers.ts index 6ce9a11..214f51c 100644 --- a/src/repeat/parsers.ts +++ b/src/repeat/parsers.ts @@ -10,7 +10,7 @@ import { Strategy, TimeOfDay, } from './repeatTypes'; -import { DEFAULT_SETTINGS } from 'src/settings'; +import { DEFAULT_SETTINGS } from '../settings'; const joinedUnits = 'hour|day|week|month|year'; @@ -138,6 +138,25 @@ export function parseYamlBoolean( return booleanRegex.test(value); } +export function formRepetition( + parsedRepeat: Repeat, + repeatDueAt: string | undefined, + hidden?: string | undefined, + referenceDateTime?: DateTime | undefined, + virtual?: boolean | undefined, +): Repetition { + return { + ...parsedRepeat, + hidden: parseYamlBoolean(hidden), + virtual: virtual || false, + repeatDueAt: parseRepeatDueAt( + repeatDueAt, + parsedRepeat, + referenceDateTime || DateTime.now(), + ), + } +} + export function parseRepetitionFields( repeat: string, repeatDueAt: string | undefined, @@ -145,15 +164,7 @@ export function parseRepetitionFields( referenceDateTime?: DateTime | undefined, ): Repetition { const parsedRepeat = parseRepeat(repeat); - return { - ...parsedRepeat, - hidden: parseYamlBoolean(hidden), - repeatDueAt: parseRepeatDueAt( - repeatDueAt, - parsedRepeat, - referenceDateTime || DateTime.now(), - ), - } + return formRepetition(parsedRepeat, repeatDueAt, hidden, referenceDateTime); } export function parseRepetitionFromMarkdown( diff --git a/src/repeat/queries.ts b/src/repeat/queries.ts index 9fb3cbd..964494f 100644 --- a/src/repeat/queries.ts +++ b/src/repeat/queries.ts @@ -1,43 +1,63 @@ import { DateTime } from 'luxon'; import { Literal, DataviewApi, DataArray } from 'obsidian-dataview'; -import { isRepeatDisabled, parseRepetitionFields } from './parsers'; +import { isRepeatDisabled, formRepetition, parseRepetitionFields } from './parsers'; export function getNotesDue( dv: DataviewApi | undefined, ignoreFolderPath: string, ignoreFilePath?: string | undefined, + enqueueNonRepeatingNotes?: boolean, + defaultRepeat?: any, ): DataArray> | undefined { const now = DateTime.now(); return dv?.pages() .mutate((page: any) => { const { repeat, due_at, hidden } = page.file.frontmatter || {}; - if (!repeat || isRepeatDisabled(repeat)) { + if (isRepeatDisabled(repeat)) { page.repetition = undefined; return page; } - page.repetition = parseRepetitionFields( - repeat, - due_at, - hidden, - page.file.ctime); - return page; + else if (!repeat) { + if (enqueueNonRepeatingNotes) { + page.repetition = formRepetition( + defaultRepeat, + undefined, + undefined, + page.file.ctime, + true, + ); + return page; + } else { + page.repetition = undefined; + return page; + } + } else { + page.repetition = parseRepetitionFields( + repeat, + due_at, + hidden, + page.file.ctime); + return page; + } }) .where((page: any) => { const { repetition } = page; if (!repetition) { return false; } - if (ignoreFolderPath && page.file.folder.startsWith(ignoreFolderPath)) { + else if (ignoreFolderPath && page.file.folder.startsWith(ignoreFolderPath)) { return false; } - if (ignoreFilePath && (page.file.path === ignoreFilePath)) { + else if (ignoreFilePath && (page.file.path === ignoreFilePath)) { return false; } - return repetition.repeatDueAt <= now; + else { + return repetition.repeatDueAt <= now; + } }) .sort((page: any) => { - return page.repetition.repeatDueAt; + return [page.repetition.virtual ? 1 : 0, page.repetition.repeatDueAt]; }, 'asc') } @@ -45,8 +65,10 @@ export function getNextDueNote( dv: DataviewApi | undefined, ignoreFolderPath: string, ignoreFilePath?: string | undefined, + enqueueNonRepeatingNotes?: boolean, + defaultRepeat?: any, ): Record | undefined { - const page = getNotesDue(dv, ignoreFolderPath, ignoreFilePath)?.first(); + const page = getNotesDue(dv, ignoreFolderPath, ignoreFilePath, enqueueNonRepeatingNotes, defaultRepeat)?.first(); if (!page) { return; } return page; } diff --git a/src/repeat/repeatTypes.ts b/src/repeat/repeatTypes.ts index 73ac367..514071c 100644 --- a/src/repeat/repeatTypes.ts +++ b/src/repeat/repeatTypes.ts @@ -18,10 +18,11 @@ export type Repeat = { export interface Repetition extends Repeat { repeatDueAt: DateTime, hidden: boolean, + virtual: boolean, } // A next-repeat choice shown in the review interface. export type RepeatChoice = { text: string, - nextRepetition: Repetition | null, + nextRepetition: Repetition | 'DISMISS' | 'NEVER', } diff --git a/src/repeat/serializers.test.ts b/src/repeat/serializers.test.ts index d235556..6ead3e6 100644 --- a/src/repeat/serializers.test.ts +++ b/src/repeat/serializers.test.ts @@ -18,6 +18,7 @@ const makeTestRepetitions = () => { repeatTimeOfDay: 'AM', repeatDueAt: referenceRepeatDueAt, hidden: false, + virtual: false, })); return [ ...basicRepetitions, @@ -56,7 +57,7 @@ describe('serializeRepeat round trip', () => { 'retains $repeatStrategy, $repeatPeriod, $repeatPeriodUnit, $repeatTimeOfDay', (repetition) => { const { repeat, due_at, hidden } = serializeRepetition(repetition as any); - const serializedUnserializedRepetition = parseRepetitionFields(repeat, due_at, hidden, undefined); + const serializedUnserializedRepetition = parseRepetitionFields(String(repeat), String(due_at ?? ""), String(hidden), undefined); expect(serializedUnserializedRepetition).toEqual(repetition); }); }); diff --git a/src/repeat/serializers.ts b/src/repeat/serializers.ts index 27c9d6a..671cf9f 100644 --- a/src/repeat/serializers.ts +++ b/src/repeat/serializers.ts @@ -44,10 +44,24 @@ export function serializeRepeat({ ].join(' '); } -export function serializeRepetition(repetition: Repetition) { - return { - repeat: serializeRepeat(repetition), - due_at: repetition.repeatDueAt.toISO(), - hidden: repetition.hidden ? SERIALIZED_TRUE : SERIALIZED_FALSE, +export function serializeRepetition(repetition: Repetition | 'DISMISS' | 'NEVER') { + if (repetition === 'NEVER') { + return { + repeat: 'never', + due_at: undefined, + hidden: undefined, + } + } else if (repetition === 'DISMISS') { + return { + repeat: undefined, + due_at: undefined, + hidden: undefined, + } + } else { + return { + repeat: serializeRepeat(repetition), + due_at: repetition.repeatDueAt.toISO(), + hidden: repetition.hidden ? SERIALIZED_TRUE : SERIALIZED_FALSE, + } } } diff --git a/src/settings.ts b/src/settings.ts index df073de..8ca449f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,6 +7,7 @@ export interface RepeatPluginSettings { morningReviewTime: string; eveningReviewTime: string; defaultRepeat: Repeat; + enqueueNonRepeatingNotes: boolean; } export const DEFAULT_SETTINGS: RepeatPluginSettings = { @@ -21,4 +22,5 @@ export const DEFAULT_SETTINGS: RepeatPluginSettings = { repeatPeriodUnit: 'DAY', repeatTimeOfDay: 'AM', }, + enqueueNonRepeatingNotes: false, }; diff --git a/tsconfig.json b/tsconfig.json index 0569975..e95d75b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "isolatedModules": true, "jsx": "react", "strictNullChecks": true, + "esModuleInterop": true, "lib": [ "DOM", "ES5", diff --git a/versions.json b/versions.json index 13f821f..4ccda2a 100644 --- a/versions.json +++ b/versions.json @@ -19,5 +19,6 @@ "1.7.0": "1.1.0", "1.7.1": "1.1.0", "1.7.2": "1.1.0", - "1.8.0": "1.1.0" + "1.8.0": "1.1.0", + "1.9.0": "1.1.0" }