Add reminder management improvements
This commit is contained in:
+2
-1
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
dist-tests/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -6,4 +8,3 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.env
|
||||
.env.*
|
||||
|
||||
|
||||
@@ -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)
|
||||
- [ ] Take a break @remind(+2h, local)
|
||||
- [ ] 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.
|
||||
@@ -122,6 +123,7 @@ title: Make a backup
|
||||
at: 2026-07-01 23:00
|
||||
notify: ntfy
|
||||
priority: high
|
||||
repeat: weekly
|
||||
```
|
||||
````
|
||||
|
||||
@@ -265,6 +267,7 @@ discord
|
||||
- [ ] Проверить сервер @remind(2026-07-01 09:00, ntfy)
|
||||
- [ ] Сделать перерыв @remind(+2h, local)
|
||||
- [ ] Планирование недели @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
|
||||
notify: ntfy
|
||||
priority: high
|
||||
repeat: weekly
|
||||
```
|
||||
````
|
||||
|
||||
@@ -348,4 +352,3 @@ npm run build
|
||||
```
|
||||
|
||||
The generated production bundle is `main.js`.
|
||||
|
||||
|
||||
+3
-1
@@ -5,7 +5,9 @@
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"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": [
|
||||
"obsidian",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -3,6 +3,7 @@ import { ReminderListModal } from "./reminders/ReminderListModal";
|
||||
import { ReminderModal } from "./reminders/ReminderModal";
|
||||
import { ReminderScheduler } from "./reminders/ReminderScheduler";
|
||||
import { ReminderStore } from "./reminders/ReminderStore";
|
||||
import { REMINDER_VIEW_TYPE, ReminderView } from "./reminders/ReminderView";
|
||||
import { ReminderNotifierSettingTab } from "./settings/SettingsTab";
|
||||
import { NotificationProviderConfig, ProviderType } from "./types";
|
||||
import { createProviderRegistry } from "./notifiers";
|
||||
@@ -16,7 +17,8 @@ export default class ReminderNotifierPlugin extends Plugin {
|
||||
this.store = new ReminderStore(this);
|
||||
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.registerCommands();
|
||||
this.registerRibbonActions();
|
||||
@@ -50,6 +52,30 @@ export default class ReminderNotifierPlugin extends Plugin {
|
||||
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 {
|
||||
this.addCommand({
|
||||
id: "create-reminder",
|
||||
@@ -57,6 +83,7 @@ export default class ReminderNotifierPlugin extends Plugin {
|
||||
callback: () => {
|
||||
new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => {
|
||||
await this.store.add(input, this.store.getSettings().defaultChannels);
|
||||
this.refreshReminderViews();
|
||||
new Notice("Reminder created.");
|
||||
}).open();
|
||||
},
|
||||
@@ -69,6 +96,7 @@ export default class ReminderNotifierPlugin extends Plugin {
|
||||
const selection = editor.getSelection();
|
||||
new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => {
|
||||
await this.store.add(input, this.store.getSettings().defaultChannels);
|
||||
this.refreshReminderViews();
|
||||
new Notice("Reminder created.");
|
||||
}, selection).open();
|
||||
},
|
||||
@@ -78,7 +106,7 @@ export default class ReminderNotifierPlugin extends Plugin {
|
||||
id: "show-active-reminders",
|
||||
name: "Show active reminders",
|
||||
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",
|
||||
name: "Show all reminders",
|
||||
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", () => {
|
||||
new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => {
|
||||
await this.store.add(input, this.store.getSettings().defaultChannels);
|
||||
this.refreshReminderViews();
|
||||
new Notice("Reminder created.");
|
||||
}).open();
|
||||
});
|
||||
|
||||
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();
|
||||
new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => {
|
||||
await this.store.add(input, this.store.getSettings().defaultChannels);
|
||||
this.refreshReminderViews();
|
||||
new Notice("Reminder created.");
|
||||
}, selection).open();
|
||||
}));
|
||||
@@ -158,7 +188,7 @@ export default class ReminderNotifierPlugin extends Plugin {
|
||||
.setTitle("Show reminders")
|
||||
.setIcon("list-checks")
|
||||
.onClick(() => {
|
||||
new ReminderListModal(this.app, this.store, true).open();
|
||||
void this.activateReminderView();
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { App, Modal, Setting } from "obsidian";
|
||||
import { ReminderStore } from "./ReminderStore";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
import { ReminderModal } from "./ReminderModal";
|
||||
|
||||
export class ReminderListModal extends Modal {
|
||||
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" });
|
||||
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
|
||||
.setButtonText("Snooze 15m")
|
||||
.onClick(async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App, Modal, Notice, Setting } from "obsidian";
|
||||
import { ParsedReminderInput } from "../types";
|
||||
import { ParsedReminderInput, Reminder, RepeatFrequency } from "../types";
|
||||
|
||||
export class ReminderModal extends Modal {
|
||||
private title = "";
|
||||
@@ -10,18 +10,23 @@ export class ReminderModal extends Modal {
|
||||
private hour = "";
|
||||
private minute = "";
|
||||
private channels = "";
|
||||
private repeatFrequency: RepeatFrequency = "none";
|
||||
private repeatInterval = "1";
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private defaultChannels: string[],
|
||||
private onSubmit: (input: ParsedReminderInput) => Promise<void>,
|
||||
initialTitle = "",
|
||||
private existing?: Reminder,
|
||||
) {
|
||||
super(app);
|
||||
const initialDate = new Date(Date.now() + 60 * 60 * 1000);
|
||||
this.title = initialTitle;
|
||||
this.body = initialTitle;
|
||||
this.channels = defaultChannels.join(", ");
|
||||
const initialDate = existing ? new Date(existing.dueAt) : new Date(Date.now() + 60 * 60 * 1000);
|
||||
this.title = existing?.title ?? initialTitle;
|
||||
this.body = existing?.body ?? initialTitle;
|
||||
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.month = String(initialDate.getMonth() + 1).padStart(2, "0");
|
||||
this.day = String(initialDate.getDate()).padStart(2, "0");
|
||||
@@ -36,7 +41,7 @@ export class ReminderModal extends Modal {
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.createEl("h2", { text: "Create reminder" });
|
||||
contentEl.createEl("h2", { text: this.existing ? "Edit reminder" : "Create reminder" });
|
||||
|
||||
this.renderReminderRow(contentEl);
|
||||
|
||||
@@ -60,9 +65,27 @@ export class ReminderModal extends Modal {
|
||||
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)
|
||||
.addButton((button) => button
|
||||
.setButtonText("Create")
|
||||
.setButtonText(this.existing ? "Save" : "Create")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
const dueAt = this.getSelectedDate();
|
||||
@@ -76,6 +99,10 @@ export class ReminderModal extends Modal {
|
||||
body: this.body.trim(),
|
||||
dueAt: dueAt.toISOString(),
|
||||
channels: this.channels.split(",").map((value) => value.trim()).filter(Boolean),
|
||||
repeat: {
|
||||
frequency: this.repeatFrequency,
|
||||
interval: Math.max(1, Number(this.repeatInterval) || 1),
|
||||
},
|
||||
});
|
||||
this.close();
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ParsedReminderInput, ReminderPriority } from "../types";
|
||||
import { ParsedReminderInput, ReminderPriority, RepeatRule } from "../types";
|
||||
import { parseReminderDate } from "../utils/date";
|
||||
|
||||
const INLINE_PATTERN = /@remind\(([^)]+)\)/gi;
|
||||
@@ -36,8 +36,9 @@ export class ReminderParser {
|
||||
dueAt: date.toISOString(),
|
||||
sourceFile,
|
||||
sourceLine: index + 1,
|
||||
channels: args.slice(1),
|
||||
channels: this.parseChannels(args.slice(1)),
|
||||
priority: this.parsePriority(args),
|
||||
repeat: this.parseRepeat(args),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,6 +60,7 @@ export class ReminderParser {
|
||||
sourceLine: index + 1,
|
||||
channels: [],
|
||||
priority: "normal",
|
||||
repeat: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -116,6 +118,7 @@ export class ReminderParser {
|
||||
sourceLine,
|
||||
channels,
|
||||
priority: this.normalizePriority(item.priority),
|
||||
repeat: this.parseRepeat([item.repeat ?? ""]),
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -174,6 +177,26 @@ export class ReminderParser {
|
||||
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 {
|
||||
if (value === "low" || value === "high" || value === "normal") {
|
||||
return value;
|
||||
|
||||
@@ -11,6 +11,7 @@ export class ReminderScheduler {
|
||||
private vault: Vault,
|
||||
private store: ReminderStore,
|
||||
private providers: Map<ProviderType, NotificationProvider>,
|
||||
private onChange: () => void = () => undefined,
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
@@ -68,6 +69,7 @@ export class ReminderScheduler {
|
||||
: `Reminder has no enabled providers: ${reminder.title}`;
|
||||
new Notice(message);
|
||||
await this.store.markFailed(reminder.id, message);
|
||||
this.onChange();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,10 +88,35 @@ export class ReminderScheduler {
|
||||
dueAt: reminder.dueAt,
|
||||
}, config);
|
||||
}
|
||||
await this.completeSourceTask(reminder);
|
||||
await this.store.markSent(reminder.id);
|
||||
this.onChange();
|
||||
} catch (error) {
|
||||
await this.store.markFailed(reminder.id, error);
|
||||
this.onChange();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ import { Plugin } from "obsidian";
|
||||
import { ParsedReminderInput, PluginData, PluginSettings, Reminder } from "../types";
|
||||
import { createReminderId } from "../utils/ids";
|
||||
import { createDefaultSettings } from "../settings/templates";
|
||||
import { getNextRepeatDate } from "../utils/date";
|
||||
|
||||
export const CURRENT_DATA_VERSION = 2;
|
||||
|
||||
export class ReminderStore {
|
||||
private data: PluginData = {
|
||||
dataVersion: CURRENT_DATA_VERSION,
|
||||
settings: createDefaultSettings(),
|
||||
reminders: [],
|
||||
};
|
||||
@@ -14,14 +18,16 @@ export class ReminderStore {
|
||||
async load(): Promise<void> {
|
||||
const loaded = await this.plugin.loadData() as Partial<PluginData> | null;
|
||||
const defaults = createDefaultSettings();
|
||||
this.data = {
|
||||
this.data = this.migrate({
|
||||
dataVersion: loaded?.dataVersion ?? 1,
|
||||
settings: {
|
||||
...defaults,
|
||||
...(loaded?.settings ?? {}),
|
||||
notificationProviders: loaded?.settings?.notificationProviders ?? defaults.notificationProviders,
|
||||
},
|
||||
reminders: loaded?.reminders ?? [],
|
||||
};
|
||||
});
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
@@ -61,6 +67,7 @@ export class ReminderStore {
|
||||
channels: input.channels.length ? input.channels : defaultChannels,
|
||||
priority: input.priority ?? "normal",
|
||||
status: "pending",
|
||||
repeat: input.repeat?.frequency && input.repeat.frequency !== "none" ? input.repeat : undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -83,6 +90,25 @@ export class ReminderStore {
|
||||
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> {
|
||||
let count = 0;
|
||||
for (const input of inputs) {
|
||||
@@ -98,8 +124,15 @@ export class ReminderStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDueAt = getNextRepeatDate(reminder.dueAt, reminder.repeat);
|
||||
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.lastError = undefined;
|
||||
await this.save();
|
||||
@@ -147,4 +180,30 @@ export class ReminderStore {
|
||||
private find(id: string): Reminder | undefined {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,16 @@ export class ReminderNotifierSettingTab extends PluginSettingTab {
|
||||
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" });
|
||||
|
||||
let selectedTemplate: ProviderType = PROVIDER_TEMPLATES[0].type;
|
||||
|
||||
@@ -69,6 +69,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
scanOnFileChange: true,
|
||||
checkIntervalSeconds: 60,
|
||||
afterSend: "mark-sent",
|
||||
markTaskCompleteOnSend: false,
|
||||
reminderSyntax: "@remind(YYYY-MM-DD HH:mm, provider)",
|
||||
notificationProviders: [PROVIDER_TEMPLATES[0]],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
export type ReminderStatus = "pending" | "sent" | "failed" | "snoozed" | "cancelled" | "done";
|
||||
export type ReminderPriority = "low" | "normal" | "high";
|
||||
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 {
|
||||
id: string;
|
||||
@@ -13,6 +19,7 @@ export interface Reminder {
|
||||
channels: string[];
|
||||
priority: ReminderPriority;
|
||||
status: ReminderStatus;
|
||||
repeat?: RepeatRule;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastError?: string;
|
||||
@@ -42,11 +49,13 @@ export interface PluginSettings {
|
||||
scanOnFileChange: boolean;
|
||||
checkIntervalSeconds: number;
|
||||
afterSend: "keep" | "mark-sent" | "mark-done";
|
||||
markTaskCompleteOnSend: boolean;
|
||||
reminderSyntax: string;
|
||||
notificationProviders: NotificationProviderConfig[];
|
||||
}
|
||||
|
||||
export interface PluginData {
|
||||
dataVersion: number;
|
||||
settings: PluginSettings;
|
||||
reminders: Reminder[];
|
||||
}
|
||||
@@ -59,6 +68,7 @@ export interface ParsedReminderInput {
|
||||
sourceLine?: number;
|
||||
channels: string[];
|
||||
priority?: ReminderPriority;
|
||||
repeat?: RepeatRule;
|
||||
}
|
||||
|
||||
export interface NotificationProvider {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RepeatRule } from "../types";
|
||||
|
||||
const WEEKDAYS = new Map([
|
||||
["sunday", 0],
|
||||
["monday", 1],
|
||||
@@ -66,3 +68,27 @@ export function formatDateTime(value: string): string {
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -37,3 +37,11 @@
|
||||
min-height: 110px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reminder-notifier-view {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.reminder-notifier-view .setting-item {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user