Add flag to allow enqueueing non-repeating notes (#41)

* Add command to repeat: never

* Add a default repeat value to the settings

* Add comments to repeat types

* Remove unused utility

* Use the default repeat property when inferring values

* Use a one day default default period after all

* Update README to mention the new default interval setting

* Reword new repeat property setting

* Prepare release of version 1.8.0

* Add toggle to enqueue notes without repeat (for setup)

* Show the Never button if non-repeating notes are enqueued

* Fix merge conflict after adding the never repeat setting

* Prepare release of version 1.9.0
This commit is contained in:
Andre Perunicic 2025-06-22 00:54:44 -04:00 committed by GitHub
parent a8bc2b0bfd
commit 1c4d77bf3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 252 additions and 72 deletions

View file

@ -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",

View file

@ -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": {

View file

@ -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();
}));
}
}

View file

@ -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();
});

View file

@ -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',
}];
}

View file

@ -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));

View file

@ -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,
});
});

View file

@ -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(

View file

@ -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<Record<string, Literal>> | 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<string, Literal> | undefined {
const page = getNotesDue(dv, ignoreFolderPath, ignoreFilePath)?.first();
const page = getNotesDue(dv, ignoreFolderPath, ignoreFilePath, enqueueNonRepeatingNotes, defaultRepeat)?.first();
if (!page) { return; }
return page;
}

View file

@ -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',
}

View file

@ -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);
});
});

View file

@ -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,
}
}
}

View file

@ -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,
};

View file

@ -13,6 +13,7 @@
"isolatedModules": true,
"jsx": "react",
"strictNullChecks": true,
"esModuleInterop": true,
"lib": [
"DOM",
"ES5",

View file

@ -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"
}