Add reminder management improvements

This commit is contained in:
dinlo
2026-06-27 15:12:40 +08:00
parent 44c6694e50
commit a88ed8da75
19 changed files with 455 additions and 28 deletions
+2 -1
View File
@@ -1,4 +1,6 @@
node_modules/ node_modules/
dist-tests/
dist/
.DS_Store .DS_Store
*.log *.log
npm-debug.log* npm-debug.log*
@@ -6,4 +8,3 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.env .env
.env.* .env.*
+4 -1
View File
@@ -97,6 +97,7 @@ The list lets you snooze a reminder for 15 minutes, mark it done, or delete it.
- [ ] Check server @remind(2026-07-01 09:00, ntfy) - [ ] Check server @remind(2026-07-01 09:00, ntfy)
- [ ] Take a break @remind(+2h, local) - [ ] Take a break @remind(+2h, local)
- [ ] Weekly planning @remind(next monday 14:30, telegram, ntfy) - [ ] Weekly planning @remind(next monday 14:30, telegram, ntfy)
- [ ] Standup @remind(2026-07-01 09:00, ntfy, daily)
``` ```
If no channel is provided, scanned reminders are sent through all enabled notification providers. If no channel is provided, scanned reminders are sent through all enabled notification providers.
@@ -122,6 +123,7 @@ title: Make a backup
at: 2026-07-01 23:00 at: 2026-07-01 23:00
notify: ntfy notify: ntfy
priority: high priority: high
repeat: weekly
``` ```
```` ````
@@ -265,6 +267,7 @@ discord
- [ ] Проверить сервер @remind(2026-07-01 09:00, ntfy) - [ ] Проверить сервер @remind(2026-07-01 09:00, ntfy)
- [ ] Сделать перерыв @remind(+2h, local) - [ ] Сделать перерыв @remind(+2h, local)
- [ ] Планирование недели @remind(next monday 14:30, telegram, ntfy) - [ ] Планирование недели @remind(next monday 14:30, telegram, ntfy)
- [ ] Стендап @remind(2026-07-01 09:00, ntfy, daily)
``` ```
Если канал не указан, найденное в заметке напоминание отправляется во все включённые провайдеры уведомлений. Если канал не указан, найденное в заметке напоминание отправляется во все включённые провайдеры уведомлений.
@@ -290,6 +293,7 @@ title: Сделать бэкап
at: 2026-07-01 23:00 at: 2026-07-01 23:00
notify: ntfy notify: ntfy
priority: high priority: high
repeat: weekly
``` ```
```` ````
@@ -348,4 +352,3 @@ npm run build
``` ```
The generated production bundle is `main.js`. The generated production bundle is `main.js`.
+5 -4
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -5,7 +5,9 @@
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs --watch", "dev": "node esbuild.config.mjs --watch",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "node scripts/run-tests.mjs",
"release": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/create-release.ps1"
}, },
"keywords": [ "keywords": [
"obsidian", "obsidian",
+25
View File
@@ -0,0 +1,25 @@
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$manifestPath = Join-Path $root "manifest.json"
$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
$releaseDir = Join-Path $root "dist"
$stagingDir = Join-Path $releaseDir "reminder-notifier"
$zipPath = Join-Path $releaseDir ("reminder-notifier-" + $manifest.version + ".zip")
if (Test-Path -LiteralPath $stagingDir) {
Remove-Item -LiteralPath $stagingDir -Recurse -Force
}
New-Item -ItemType Directory -Path $stagingDir | Out-Null
Copy-Item -LiteralPath (Join-Path $root "manifest.json") -Destination $stagingDir
Copy-Item -LiteralPath (Join-Path $root "main.js") -Destination $stagingDir
Copy-Item -LiteralPath (Join-Path $root "styles.css") -Destination $stagingDir
if (Test-Path -LiteralPath $zipPath) {
Remove-Item -LiteralPath $zipPath -Force
}
Compress-Archive -Path (Join-Path $stagingDir "*") -DestinationPath $zipPath
Write-Host "Release archive created: $zipPath"
+16
View File
@@ -0,0 +1,16 @@
import esbuild from "esbuild";
import { pathToFileURL } from "node:url";
await esbuild.build({
entryPoints: ["tests/date.test.ts"],
bundle: true,
platform: "node",
target: "node20",
outfile: "dist-tests/date.test.mjs",
format: "esm",
logLevel: "silent",
});
await import(pathToFileURL(`${process.cwd()}/dist-tests/date.test.mjs`).href);
console.log("Tests passed.");
+35 -5
View File
@@ -3,6 +3,7 @@ import { ReminderListModal } from "./reminders/ReminderListModal";
import { ReminderModal } from "./reminders/ReminderModal"; import { ReminderModal } from "./reminders/ReminderModal";
import { ReminderScheduler } from "./reminders/ReminderScheduler"; import { ReminderScheduler } from "./reminders/ReminderScheduler";
import { ReminderStore } from "./reminders/ReminderStore"; import { ReminderStore } from "./reminders/ReminderStore";
import { REMINDER_VIEW_TYPE, ReminderView } from "./reminders/ReminderView";
import { ReminderNotifierSettingTab } from "./settings/SettingsTab"; import { ReminderNotifierSettingTab } from "./settings/SettingsTab";
import { NotificationProviderConfig, ProviderType } from "./types"; import { NotificationProviderConfig, ProviderType } from "./types";
import { createProviderRegistry } from "./notifiers"; import { createProviderRegistry } from "./notifiers";
@@ -16,7 +17,8 @@ export default class ReminderNotifierPlugin extends Plugin {
this.store = new ReminderStore(this); this.store = new ReminderStore(this);
await this.store.load(); await this.store.load();
this.scheduler = new ReminderScheduler(this.app.vault, this.store, this.providers); this.scheduler = new ReminderScheduler(this.app.vault, this.store, this.providers, () => this.refreshReminderViews());
this.registerView(REMINDER_VIEW_TYPE, (leaf) => new ReminderView(leaf, this));
this.addSettingTab(new ReminderNotifierSettingTab(this.app, this)); this.addSettingTab(new ReminderNotifierSettingTab(this.app, this));
this.registerCommands(); this.registerCommands();
this.registerRibbonActions(); this.registerRibbonActions();
@@ -50,6 +52,30 @@ export default class ReminderNotifierPlugin extends Plugin {
await notifier.test(provider); await notifier.test(provider);
} }
async activateReminderView(): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(REMINDER_VIEW_TYPE)[0];
if (existing) {
this.app.workspace.revealLeaf(existing);
return;
}
const leaf = this.app.workspace.getRightLeaf(false);
if (!leaf) {
return;
}
await leaf.setViewState({ type: REMINDER_VIEW_TYPE, active: true });
this.app.workspace.revealLeaf(leaf);
}
refreshReminderViews(): void {
for (const leaf of this.app.workspace.getLeavesOfType(REMINDER_VIEW_TYPE)) {
const view = leaf.view;
if (view instanceof ReminderView) {
view.refresh();
}
}
}
private registerCommands(): void { private registerCommands(): void {
this.addCommand({ this.addCommand({
id: "create-reminder", id: "create-reminder",
@@ -57,6 +83,7 @@ export default class ReminderNotifierPlugin extends Plugin {
callback: () => { callback: () => {
new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => {
await this.store.add(input, this.store.getSettings().defaultChannels); await this.store.add(input, this.store.getSettings().defaultChannels);
this.refreshReminderViews();
new Notice("Reminder created."); new Notice("Reminder created.");
}).open(); }).open();
}, },
@@ -69,6 +96,7 @@ export default class ReminderNotifierPlugin extends Plugin {
const selection = editor.getSelection(); const selection = editor.getSelection();
new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => {
await this.store.add(input, this.store.getSettings().defaultChannels); await this.store.add(input, this.store.getSettings().defaultChannels);
this.refreshReminderViews();
new Notice("Reminder created."); new Notice("Reminder created.");
}, selection).open(); }, selection).open();
}, },
@@ -78,7 +106,7 @@ export default class ReminderNotifierPlugin extends Plugin {
id: "show-active-reminders", id: "show-active-reminders",
name: "Show active reminders", name: "Show active reminders",
callback: () => { callback: () => {
new ReminderListModal(this.app, this.store, false).open(); void this.activateReminderView();
}, },
}); });
@@ -86,7 +114,7 @@ export default class ReminderNotifierPlugin extends Plugin {
id: "show-all-reminders", id: "show-all-reminders",
name: "Show all reminders", name: "Show all reminders",
callback: () => { callback: () => {
new ReminderListModal(this.app, this.store, true).open(); void this.activateReminderView();
}, },
}); });
@@ -131,12 +159,13 @@ export default class ReminderNotifierPlugin extends Plugin {
this.addRibbonIcon("bell-plus", "Create reminder", () => { this.addRibbonIcon("bell-plus", "Create reminder", () => {
new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => {
await this.store.add(input, this.store.getSettings().defaultChannels); await this.store.add(input, this.store.getSettings().defaultChannels);
this.refreshReminderViews();
new Notice("Reminder created."); new Notice("Reminder created.");
}).open(); }).open();
}); });
this.addRibbonIcon("list-checks", "Show reminders", () => { this.addRibbonIcon("list-checks", "Show reminders", () => {
new ReminderListModal(this.app, this.store, true).open(); void this.activateReminderView();
}); });
} }
@@ -150,6 +179,7 @@ export default class ReminderNotifierPlugin extends Plugin {
const selection = editor.getSelection(); const selection = editor.getSelection();
new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => {
await this.store.add(input, this.store.getSettings().defaultChannels); await this.store.add(input, this.store.getSettings().defaultChannels);
this.refreshReminderViews();
new Notice("Reminder created."); new Notice("Reminder created.");
}, selection).open(); }, selection).open();
})); }));
@@ -158,7 +188,7 @@ export default class ReminderNotifierPlugin extends Plugin {
.setTitle("Show reminders") .setTitle("Show reminders")
.setIcon("list-checks") .setIcon("list-checks")
.onClick(() => { .onClick(() => {
new ReminderListModal(this.app, this.store, true).open(); void this.activateReminderView();
})); }));
})); }));
} }
+9
View File
@@ -1,6 +1,7 @@
import { App, Modal, Setting } from "obsidian"; import { App, Modal, Setting } from "obsidian";
import { ReminderStore } from "./ReminderStore"; import { ReminderStore } from "./ReminderStore";
import { formatDateTime } from "../utils/date"; import { formatDateTime } from "../utils/date";
import { ReminderModal } from "./ReminderModal";
export class ReminderListModal extends Modal { export class ReminderListModal extends Modal {
constructor(app: App, private store: ReminderStore, private showCompleted = false) { constructor(app: App, private store: ReminderStore, private showCompleted = false) {
@@ -52,6 +53,14 @@ export class ReminderListModal extends Modal {
const actions = item.createDiv({ cls: "reminder-notifier-actions" }); const actions = item.createDiv({ cls: "reminder-notifier-actions" });
new Setting(actions) new Setting(actions)
.addButton((button) => button
.setButtonText("Edit")
.onClick(() => {
new ReminderModal(this.app, reminder.channels, async (input) => {
await this.store.update(reminder.id, input);
this.render();
}, reminder.title, reminder).open();
}))
.addButton((button) => button .addButton((button) => button
.setButtonText("Snooze 15m") .setButtonText("Snooze 15m")
.onClick(async () => { .onClick(async () => {
+34 -7
View File
@@ -1,5 +1,5 @@
import { App, Modal, Notice, Setting } from "obsidian"; import { App, Modal, Notice, Setting } from "obsidian";
import { ParsedReminderInput } from "../types"; import { ParsedReminderInput, Reminder, RepeatFrequency } from "../types";
export class ReminderModal extends Modal { export class ReminderModal extends Modal {
private title = ""; private title = "";
@@ -10,18 +10,23 @@ export class ReminderModal extends Modal {
private hour = ""; private hour = "";
private minute = ""; private minute = "";
private channels = ""; private channels = "";
private repeatFrequency: RepeatFrequency = "none";
private repeatInterval = "1";
constructor( constructor(
app: App, app: App,
private defaultChannels: string[], private defaultChannels: string[],
private onSubmit: (input: ParsedReminderInput) => Promise<void>, private onSubmit: (input: ParsedReminderInput) => Promise<void>,
initialTitle = "", initialTitle = "",
private existing?: Reminder,
) { ) {
super(app); super(app);
const initialDate = new Date(Date.now() + 60 * 60 * 1000); const initialDate = existing ? new Date(existing.dueAt) : new Date(Date.now() + 60 * 60 * 1000);
this.title = initialTitle; this.title = existing?.title ?? initialTitle;
this.body = initialTitle; this.body = existing?.body ?? initialTitle;
this.channels = defaultChannels.join(", "); this.channels = existing ? existing.channels.join(", ") : defaultChannels.join(", ");
this.repeatFrequency = existing?.repeat?.frequency ?? "none";
this.repeatInterval = String(existing?.repeat?.interval ?? 1);
this.year = String(initialDate.getFullYear()); this.year = String(initialDate.getFullYear());
this.month = String(initialDate.getMonth() + 1).padStart(2, "0"); this.month = String(initialDate.getMonth() + 1).padStart(2, "0");
this.day = String(initialDate.getDate()).padStart(2, "0"); this.day = String(initialDate.getDate()).padStart(2, "0");
@@ -36,7 +41,7 @@ export class ReminderModal extends Modal {
onOpen(): void { onOpen(): void {
const { contentEl } = this; const { contentEl } = this;
contentEl.empty(); contentEl.empty();
contentEl.createEl("h2", { text: "Create reminder" }); contentEl.createEl("h2", { text: this.existing ? "Edit reminder" : "Create reminder" });
this.renderReminderRow(contentEl); this.renderReminderRow(contentEl);
@@ -60,9 +65,27 @@ export class ReminderModal extends Modal {
this.channels = value; this.channels = value;
})); }));
new Setting(contentEl)
.setName("Repeat")
.addDropdown((dropdown) => dropdown
.addOption("none", "None")
.addOption("daily", "Daily")
.addOption("weekly", "Weekly")
.addOption("monthly", "Monthly")
.setValue(this.repeatFrequency)
.onChange((value) => {
this.repeatFrequency = value as RepeatFrequency;
}))
.addText((text) => text
.setPlaceholder("Interval")
.setValue(this.repeatInterval)
.onChange((value) => {
this.repeatInterval = value;
}));
new Setting(contentEl) new Setting(contentEl)
.addButton((button) => button .addButton((button) => button
.setButtonText("Create") .setButtonText(this.existing ? "Save" : "Create")
.setCta() .setCta()
.onClick(async () => { .onClick(async () => {
const dueAt = this.getSelectedDate(); const dueAt = this.getSelectedDate();
@@ -76,6 +99,10 @@ export class ReminderModal extends Modal {
body: this.body.trim(), body: this.body.trim(),
dueAt: dueAt.toISOString(), dueAt: dueAt.toISOString(),
channels: this.channels.split(",").map((value) => value.trim()).filter(Boolean), channels: this.channels.split(",").map((value) => value.trim()).filter(Boolean),
repeat: {
frequency: this.repeatFrequency,
interval: Math.max(1, Number(this.repeatInterval) || 1),
},
}); });
this.close(); this.close();
})); }));
+28 -5
View File
@@ -1,4 +1,4 @@
import { ParsedReminderInput, ReminderPriority } from "../types"; import { ParsedReminderInput, ReminderPriority, RepeatRule } from "../types";
import { parseReminderDate } from "../utils/date"; import { parseReminderDate } from "../utils/date";
const INLINE_PATTERN = /@remind\(([^)]+)\)/gi; const INLINE_PATTERN = /@remind\(([^)]+)\)/gi;
@@ -36,8 +36,9 @@ export class ReminderParser {
dueAt: date.toISOString(), dueAt: date.toISOString(),
sourceFile, sourceFile,
sourceLine: index + 1, sourceLine: index + 1,
channels: args.slice(1), channels: this.parseChannels(args.slice(1)),
priority: this.parsePriority(args), priority: this.parsePriority(args),
repeat: this.parseRepeat(args),
}); });
} }
@@ -57,9 +58,10 @@ export class ReminderParser {
dueAt: date.toISOString(), dueAt: date.toISOString(),
sourceFile, sourceFile,
sourceLine: index + 1, sourceLine: index + 1,
channels: [], channels: [],
priority: "normal", priority: "normal",
}); repeat: undefined,
});
} }
}); });
@@ -116,6 +118,7 @@ export class ReminderParser {
sourceLine, sourceLine,
channels, channels,
priority: this.normalizePriority(item.priority), priority: this.normalizePriority(item.priority),
repeat: this.parseRepeat([item.repeat ?? ""]),
}]; }];
}); });
} }
@@ -174,6 +177,26 @@ export class ReminderParser {
return this.normalizePriority(priority); return this.normalizePriority(priority);
} }
private parseChannels(values: string[]): string[] {
return values.filter((value) =>
!/^(low|normal|high)$/i.test(value)
&& !/^(daily|weekly|monthly)(?::\d+)?$/i.test(value)
);
}
private parseRepeat(values: string[]): RepeatRule | undefined {
const repeat = values.find((value) => /^(daily|weekly|monthly)(?::\d+)?$/i.test(value));
if (!repeat) {
return undefined;
}
const [frequency, interval] = repeat.toLowerCase().split(":");
return {
frequency: frequency as RepeatRule["frequency"],
interval: Math.max(1, Number(interval) || 1),
};
}
private normalizePriority(value?: string): ReminderPriority { private normalizePriority(value?: string): ReminderPriority {
if (value === "low" || value === "high" || value === "normal") { if (value === "low" || value === "high" || value === "normal") {
return value; return value;
+27
View File
@@ -11,6 +11,7 @@ export class ReminderScheduler {
private vault: Vault, private vault: Vault,
private store: ReminderStore, private store: ReminderStore,
private providers: Map<ProviderType, NotificationProvider>, private providers: Map<ProviderType, NotificationProvider>,
private onChange: () => void = () => undefined,
) {} ) {}
start(): void { start(): void {
@@ -68,6 +69,7 @@ export class ReminderScheduler {
: `Reminder has no enabled providers: ${reminder.title}`; : `Reminder has no enabled providers: ${reminder.title}`;
new Notice(message); new Notice(message);
await this.store.markFailed(reminder.id, message); await this.store.markFailed(reminder.id, message);
this.onChange();
return; return;
} }
@@ -86,10 +88,35 @@ export class ReminderScheduler {
dueAt: reminder.dueAt, dueAt: reminder.dueAt,
}, config); }, config);
} }
await this.completeSourceTask(reminder);
await this.store.markSent(reminder.id); await this.store.markSent(reminder.id);
this.onChange();
} catch (error) { } catch (error) {
await this.store.markFailed(reminder.id, error); await this.store.markFailed(reminder.id, error);
this.onChange();
new Notice(`Reminder failed: ${reminder.title}`); new Notice(`Reminder failed: ${reminder.title}`);
} }
} }
private async completeSourceTask(reminder: Reminder): Promise<void> {
if (!this.store.getSettings().markTaskCompleteOnSend || reminder.repeat || !reminder.sourceFile || !reminder.sourceLine) {
return;
}
const file = this.vault.getAbstractFileByPath(reminder.sourceFile);
if (!(file instanceof TFile)) {
return;
}
const content = await this.vault.read(file);
const lines = content.split(/\r?\n/);
const index = reminder.sourceLine - 1;
const line = lines[index];
if (!line || !/^\s*[-*]\s+\[\s\]/.test(line)) {
return;
}
lines[index] = line.replace(/^(\s*[-*]\s+\[)\s(\])/, "$1x$2");
await this.vault.modify(file, lines.join("\n"));
}
} }
+63 -4
View File
@@ -2,9 +2,13 @@ import { Plugin } from "obsidian";
import { ParsedReminderInput, PluginData, PluginSettings, Reminder } from "../types"; import { ParsedReminderInput, PluginData, PluginSettings, Reminder } from "../types";
import { createReminderId } from "../utils/ids"; import { createReminderId } from "../utils/ids";
import { createDefaultSettings } from "../settings/templates"; import { createDefaultSettings } from "../settings/templates";
import { getNextRepeatDate } from "../utils/date";
export const CURRENT_DATA_VERSION = 2;
export class ReminderStore { export class ReminderStore {
private data: PluginData = { private data: PluginData = {
dataVersion: CURRENT_DATA_VERSION,
settings: createDefaultSettings(), settings: createDefaultSettings(),
reminders: [], reminders: [],
}; };
@@ -14,14 +18,16 @@ export class ReminderStore {
async load(): Promise<void> { async load(): Promise<void> {
const loaded = await this.plugin.loadData() as Partial<PluginData> | null; const loaded = await this.plugin.loadData() as Partial<PluginData> | null;
const defaults = createDefaultSettings(); const defaults = createDefaultSettings();
this.data = { this.data = this.migrate({
dataVersion: loaded?.dataVersion ?? 1,
settings: { settings: {
...defaults, ...defaults,
...(loaded?.settings ?? {}), ...(loaded?.settings ?? {}),
notificationProviders: loaded?.settings?.notificationProviders ?? defaults.notificationProviders, notificationProviders: loaded?.settings?.notificationProviders ?? defaults.notificationProviders,
}, },
reminders: loaded?.reminders ?? [], reminders: loaded?.reminders ?? [],
}; });
await this.save();
} }
async save(): Promise<void> { async save(): Promise<void> {
@@ -61,6 +67,7 @@ export class ReminderStore {
channels: input.channels.length ? input.channels : defaultChannels, channels: input.channels.length ? input.channels : defaultChannels,
priority: input.priority ?? "normal", priority: input.priority ?? "normal",
status: "pending", status: "pending",
repeat: input.repeat?.frequency && input.repeat.frequency !== "none" ? input.repeat : undefined,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -83,6 +90,25 @@ export class ReminderStore {
return reminder; return reminder;
} }
async update(id: string, input: ParsedReminderInput): Promise<void> {
const reminder = this.find(id);
if (!reminder) {
return;
}
reminder.title = input.title;
reminder.body = input.body;
reminder.dueAt = input.dueAt;
reminder.channels = input.channels;
reminder.priority = input.priority ?? "normal";
reminder.repeat = input.repeat?.frequency && input.repeat.frequency !== "none" ? input.repeat : undefined;
reminder.status = "pending";
reminder.completed = false;
reminder.updatedAt = new Date().toISOString();
reminder.lastError = undefined;
await this.save();
}
async addMany(inputs: ParsedReminderInput[]): Promise<number> { async addMany(inputs: ParsedReminderInput[]): Promise<number> {
let count = 0; let count = 0;
for (const input of inputs) { for (const input of inputs) {
@@ -98,8 +124,15 @@ export class ReminderStore {
return; return;
} }
reminder.status = this.data.settings.afterSend === "mark-done" ? "done" : "sent"; const nextDueAt = getNextRepeatDate(reminder.dueAt, reminder.repeat);
reminder.completed = this.data.settings.afterSend === "mark-done"; if (nextDueAt) {
reminder.dueAt = nextDueAt;
reminder.status = "pending";
reminder.completed = false;
} else {
reminder.status = this.data.settings.afterSend === "mark-done" ? "done" : "sent";
reminder.completed = this.data.settings.afterSend === "mark-done";
}
reminder.updatedAt = new Date().toISOString(); reminder.updatedAt = new Date().toISOString();
reminder.lastError = undefined; reminder.lastError = undefined;
await this.save(); await this.save();
@@ -147,4 +180,30 @@ export class ReminderStore {
private find(id: string): Reminder | undefined { private find(id: string): Reminder | undefined {
return this.data.reminders.find((reminder) => reminder.id === id); return this.data.reminders.find((reminder) => reminder.id === id);
} }
private migrate(data: PluginData): PluginData {
const defaults = createDefaultSettings();
const settings = {
...defaults,
...data.settings,
notificationProviders: data.settings.notificationProviders ?? defaults.notificationProviders,
markTaskCompleteOnSend: data.settings.markTaskCompleteOnSend ?? defaults.markTaskCompleteOnSend,
};
const reminders = data.reminders.map((reminder) => ({
...reminder,
channels: reminder.channels ?? [],
priority: reminder.priority ?? "normal",
status: reminder.status ?? "pending",
completed: reminder.completed ?? false,
createdAt: reminder.createdAt ?? new Date().toISOString(),
updatedAt: reminder.updatedAt ?? new Date().toISOString(),
}));
return {
dataVersion: CURRENT_DATA_VERSION,
settings,
reminders,
};
}
} }
+125
View File
@@ -0,0 +1,125 @@
import { ItemView, Notice, Setting, WorkspaceLeaf } from "obsidian";
import type ReminderNotifierPlugin from "../main";
import { Reminder } from "../types";
import { formatDateTime } from "../utils/date";
import { ReminderModal } from "./ReminderModal";
export const REMINDER_VIEW_TYPE = "reminder-notifier-view";
export class ReminderView extends ItemView {
private showAll = false;
constructor(leaf: WorkspaceLeaf, private plugin: ReminderNotifierPlugin) {
super(leaf);
}
getViewType(): string {
return REMINDER_VIEW_TYPE;
}
getDisplayText(): string {
return "Reminders";
}
getIcon(): string {
return "list-checks";
}
async onOpen(): Promise<void> {
this.render();
}
refresh(): void {
this.render();
}
private render(): void {
const container = this.containerEl.children[1] as HTMLElement;
container.empty();
container.addClass("reminder-notifier-view");
new Setting(container)
.setName("Reminders")
.addButton((button) => button
.setButtonText("New")
.setIcon("bell-plus")
.onClick(() => {
new ReminderModal(this.app, this.plugin.store.getSettings().defaultChannels, async (input) => {
await this.plugin.store.add(input, this.plugin.store.getSettings().defaultChannels);
this.refresh();
new Notice("Reminder created.");
}).open();
}))
.addButton((button) => button
.setButtonText(this.showAll ? "Active" : "All")
.setIcon("filter")
.onClick(() => {
this.showAll = !this.showAll;
this.render();
}));
const reminders = this.showAll
? this.plugin.store.getAll()
: this.plugin.store.getAll().filter((reminder) => !["sent", "done", "cancelled"].includes(reminder.status));
if (!reminders.length) {
container.createEl("p", { text: this.showAll ? "No reminders." : "No active reminders." });
return;
}
const list = container.createDiv({ cls: "reminder-notifier-list" });
for (const reminder of reminders) {
this.renderReminder(list, reminder);
}
}
private renderReminder(list: HTMLElement, reminder: Reminder): void {
const item = list.createDiv({ cls: "reminder-notifier-item" });
item.createDiv({ cls: "reminder-notifier-item-title", text: reminder.title });
item.createDiv({
cls: "reminder-notifier-item-meta",
text: `${formatDateTime(reminder.dueAt)} - ${reminder.status} - ${reminder.channels.join(", ") || "all enabled"}`,
});
if (reminder.repeat?.frequency && reminder.repeat.frequency !== "none") {
item.createDiv({
cls: "reminder-notifier-item-meta",
text: `Repeats ${reminder.repeat.frequency} every ${reminder.repeat.interval}`,
});
}
const actions = item.createDiv({ cls: "reminder-notifier-actions" });
new Setting(actions)
.addButton((button) => button
.setButtonText("Edit")
.setIcon("pencil")
.onClick(() => {
new ReminderModal(this.app, reminder.channels, async (input) => {
await this.plugin.store.update(reminder.id, input);
this.refresh();
new Notice("Reminder updated.");
}, reminder.title, reminder).open();
}))
.addButton((button) => button
.setButtonText("15m")
.setIcon("clock")
.onClick(async () => {
await this.plugin.store.snooze(reminder.id, 15);
this.refresh();
}))
.addButton((button) => button
.setButtonText("Done")
.setIcon("check")
.onClick(async () => {
await this.plugin.store.complete(reminder.id);
this.refresh();
}))
.addButton((button) => button
.setButtonText("Delete")
.setIcon("trash")
.onClick(async () => {
await this.plugin.store.remove(reminder.id);
this.refresh();
}));
}
}
+10
View File
@@ -66,6 +66,16 @@ export class ReminderNotifierSettingTab extends PluginSettingTab {
await this.plugin.store.updateSettings(settings); await this.plugin.store.updateSettings(settings);
})); }));
new Setting(containerEl)
.setName("Mark Markdown tasks complete")
.setDesc("When a non-repeating reminder from a task line is sent, replace [ ] with [x].")
.addToggle((toggle) => toggle
.setValue(settings.markTaskCompleteOnSend)
.onChange(async (value) => {
settings.markTaskCompleteOnSend = value;
await this.plugin.store.updateSettings(settings);
}));
containerEl.createEl("h3", { text: "Notification providers" }); containerEl.createEl("h3", { text: "Notification providers" });
let selectedTemplate: ProviderType = PROVIDER_TEMPLATES[0].type; let selectedTemplate: ProviderType = PROVIDER_TEMPLATES[0].type;
+1
View File
@@ -69,6 +69,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
scanOnFileChange: true, scanOnFileChange: true,
checkIntervalSeconds: 60, checkIntervalSeconds: 60,
afterSend: "mark-sent", afterSend: "mark-sent",
markTaskCompleteOnSend: false,
reminderSyntax: "@remind(YYYY-MM-DD HH:mm, provider)", reminderSyntax: "@remind(YYYY-MM-DD HH:mm, provider)",
notificationProviders: [PROVIDER_TEMPLATES[0]], notificationProviders: [PROVIDER_TEMPLATES[0]],
}; };
+10
View File
@@ -1,6 +1,12 @@
export type ReminderStatus = "pending" | "sent" | "failed" | "snoozed" | "cancelled" | "done"; export type ReminderStatus = "pending" | "sent" | "failed" | "snoozed" | "cancelled" | "done";
export type ReminderPriority = "low" | "normal" | "high"; export type ReminderPriority = "low" | "normal" | "high";
export type ProviderType = "local" | "telegram" | "ntfy" | "webhook" | "discord"; export type ProviderType = "local" | "telegram" | "ntfy" | "webhook" | "discord";
export type RepeatFrequency = "none" | "daily" | "weekly" | "monthly";
export interface RepeatRule {
frequency: RepeatFrequency;
interval: number;
}
export interface Reminder { export interface Reminder {
id: string; id: string;
@@ -13,6 +19,7 @@ export interface Reminder {
channels: string[]; channels: string[];
priority: ReminderPriority; priority: ReminderPriority;
status: ReminderStatus; status: ReminderStatus;
repeat?: RepeatRule;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
lastError?: string; lastError?: string;
@@ -42,11 +49,13 @@ export interface PluginSettings {
scanOnFileChange: boolean; scanOnFileChange: boolean;
checkIntervalSeconds: number; checkIntervalSeconds: number;
afterSend: "keep" | "mark-sent" | "mark-done"; afterSend: "keep" | "mark-sent" | "mark-done";
markTaskCompleteOnSend: boolean;
reminderSyntax: string; reminderSyntax: string;
notificationProviders: NotificationProviderConfig[]; notificationProviders: NotificationProviderConfig[];
} }
export interface PluginData { export interface PluginData {
dataVersion: number;
settings: PluginSettings; settings: PluginSettings;
reminders: Reminder[]; reminders: Reminder[];
} }
@@ -59,6 +68,7 @@ export interface ParsedReminderInput {
sourceLine?: number; sourceLine?: number;
channels: string[]; channels: string[];
priority?: ReminderPriority; priority?: ReminderPriority;
repeat?: RepeatRule;
} }
export interface NotificationProvider { export interface NotificationProvider {
+26
View File
@@ -1,3 +1,5 @@
import { RepeatRule } from "../types";
const WEEKDAYS = new Map([ const WEEKDAYS = new Map([
["sunday", 0], ["sunday", 0],
["monday", 1], ["monday", 1],
@@ -66,3 +68,27 @@ export function formatDateTime(value: string): string {
} }
return date.toLocaleString(); return date.toLocaleString();
} }
export function getNextRepeatDate(value: string, repeat?: RepeatRule, from = new Date()): string | null {
if (!repeat || repeat.frequency === "none") {
return null;
}
const interval = Math.max(1, repeat.interval || 1);
const next = new Date(value);
if (Number.isNaN(next.getTime())) {
return null;
}
while (next.getTime() <= from.getTime()) {
if (repeat.frequency === "daily") {
next.setDate(next.getDate() + interval);
} else if (repeat.frequency === "weekly") {
next.setDate(next.getDate() + 7 * interval);
} else if (repeat.frequency === "monthly") {
next.setMonth(next.getMonth() + interval);
}
}
return next.toISOString();
}
+8
View File
@@ -37,3 +37,11 @@
min-height: 110px; min-height: 110px;
width: 100%; width: 100%;
} }
.reminder-notifier-view {
padding: 8px;
}
.reminder-notifier-view .setting-item {
padding: 8px 0;
}
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { getNextRepeatDate, parseReminderDate } from "../src/utils/date";
const base = new Date("2026-06-27T10:00:00");
const plusTwoHours = parseReminderDate("+2h", base);
assert.equal(plusTwoHours?.toISOString(), new Date("2026-06-27T12:00:00").toISOString());
const tomorrow = parseReminderDate("tomorrow 09:30", base);
assert.equal(tomorrow?.toISOString(), new Date("2026-06-28T09:30:00").toISOString());
const simple = parseReminderDate("2026-07-01 14:46", base);
assert.equal(simple?.getFullYear(), 2026);
assert.equal(simple?.getMonth(), 6);
assert.equal(simple?.getDate(), 1);
assert.equal(simple?.getHours(), 14);
assert.equal(simple?.getMinutes(), 46);
const nextDaily = getNextRepeatDate("2026-06-26T10:00:00.000Z", { frequency: "daily", interval: 1 }, new Date("2026-06-27T10:00:01.000Z"));
assert.equal(nextDaily, "2026-06-28T10:00:00.000Z");
const nextWeekly = getNextRepeatDate("2026-06-20T10:00:00.000Z", { frequency: "weekly", interval: 2 }, new Date("2026-06-27T10:00:01.000Z"));
assert.equal(nextWeekly, "2026-07-04T10:00:00.000Z");