From 44c6694e505146ccdcb6a3850e91dd6cfffdbcda Mon Sep 17 00:00:00 2001 From: dinlo Date: Sat, 27 Jun 2026 15:05:31 +0800 Subject: [PATCH] Initial Obsidian reminder notifier plugin --- .gitignore | 9 + README.md | 351 ++++++++++ esbuild.config.mjs | 51 ++ main.js | 9 + manifest.json | 10 + package-lock.json | 611 ++++++++++++++++ package.json | 27 + plan.md | 1050 ++++++++++++++++++++++++++++ src/main.ts | 181 +++++ src/notifiers/DiscordNotifier.ts | 34 + src/notifiers/LocalNotifier.ts | 15 + src/notifiers/NtfyNotifier.ts | 57 ++ src/notifiers/TelegramNotifier.ts | 38 + src/notifiers/WebhookNotifier.ts | 44 ++ src/notifiers/index.ts | 18 + src/reminders/ReminderListModal.ts | 75 ++ src/reminders/ReminderModal.ts | 176 +++++ src/reminders/ReminderParser.ts | 183 +++++ src/reminders/ReminderScheduler.ts | 95 +++ src/reminders/ReminderStore.ts | 150 ++++ src/settings/SettingsTab.ts | 167 +++++ src/settings/templates.ts | 92 +++ src/types.ts | 69 ++ src/utils/date.ts | 68 ++ src/utils/ids.ts | 8 + src/utils/template.ts | 34 + styles.css | 39 ++ tsconfig.json | 22 + versions.json | 3 + 29 files changed, 3686 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 esbuild.config.mjs create mode 100644 main.js create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 plan.md create mode 100644 src/main.ts create mode 100644 src/notifiers/DiscordNotifier.ts create mode 100644 src/notifiers/LocalNotifier.ts create mode 100644 src/notifiers/NtfyNotifier.ts create mode 100644 src/notifiers/TelegramNotifier.ts create mode 100644 src/notifiers/WebhookNotifier.ts create mode 100644 src/notifiers/index.ts create mode 100644 src/reminders/ReminderListModal.ts create mode 100644 src/reminders/ReminderModal.ts create mode 100644 src/reminders/ReminderParser.ts create mode 100644 src/reminders/ReminderScheduler.ts create mode 100644 src/reminders/ReminderStore.ts create mode 100644 src/settings/SettingsTab.ts create mode 100644 src/settings/templates.ts create mode 100644 src/types.ts create mode 100644 src/utils/date.ts create mode 100644 src/utils/ids.ts create mode 100644 src/utils/template.ts create mode 100644 styles.css create mode 100644 tsconfig.json create mode 100644 versions.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94eaa40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.DS_Store +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.env +.env.* + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4797082 --- /dev/null +++ b/README.md @@ -0,0 +1,351 @@ +# Obsidian Reminder Notifier + +Obsidian Reminder Notifier is an Obsidian plugin for creating reminders from notes, the command palette, editor selection, or the left ribbon. It can send notifications through local Obsidian notices, Telegram, ntfy, Discord webhooks, or generic webhooks. + +> Reminder delivery works while Obsidian is running. If Obsidian is closed at the due time, pending reminders are checked when Obsidian starts again. + +## English + +### Features + +- Create reminders from the command palette. +- Create reminders from selected text in the editor context menu. +- Create reminders from the left ribbon button. +- Show active, sent, completed, failed, and snoozed reminders. +- Scan the current note or the whole vault for `@remind(...)` syntax. +- Send reminders through local Obsidian notices, Telegram, ntfy, Discord, or generic webhooks. +- Configure notification providers from built-in templates. + +### Installation + +#### Manual installation + +1. Download or build the plugin files. +2. Create this folder inside your vault: + +```text +/.obsidian/plugins/reminder-notifier/ +``` + +3. Copy these files into that folder: + +```text +manifest.json +main.js +styles.css +``` + +4. Restart Obsidian or reload plugins. +5. Open `Settings -> Community plugins`. +6. Disable Safe mode if needed. +7. Enable `Reminder Notifier`. + +#### Build from source + +```bash +npm install +npm run build +``` + +After the build, copy `manifest.json`, `main.js`, and `styles.css` into your vault plugin folder. + +### Usage + +#### Create a reminder from the UI + +Use any of these entry points: + +- left ribbon button `Create reminder`; +- command palette command `Create reminder`; +- editor right-click menu item `Create reminder`; +- command palette command `Create reminder from selected text`. + +The create dialog contains: + +- reminder text input; +- year dropdown; +- month dropdown; +- day dropdown; +- hour dropdown; +- minute dropdown; +- optional details field; +- optional channels field. + +Leave the channels field empty to send through every enabled provider. Enter provider IDs or provider types separated by commas to target specific providers, for example: + +```text +ntfy +telegram, ntfy +discord +``` + +#### Show reminders + +Use any of these entry points: + +- left ribbon button `Show reminders`; +- editor right-click menu item `Show reminders`; +- command palette command `Show active reminders`; +- command palette command `Show all reminders`. + +The list lets you snooze a reminder for 15 minutes, mark it done, or delete it. + +#### Markdown syntax + +```md +- [ ] Call the doctor @remind(2026-07-01 10:00) +- [ ] Check server @remind(2026-07-01 09:00, ntfy) +- [ ] Take a break @remind(+2h, local) +- [ ] Weekly planning @remind(next monday 14:30, telegram, ntfy) +``` + +If no channel is provided, scanned reminders are sent through all enabled notification providers. + +Frontmatter is supported: + +```yaml +--- +reminder: + at: 2026-07-01 10:00 + title: Call the doctor + notify: + - telegram + - ntfy +--- +``` + +Reminder code blocks are supported: + +```` +```reminder +title: Make a backup +at: 2026-07-01 23:00 +notify: ntfy +priority: high +``` +```` + +After adding reminders to notes, run `Scan current note for reminders` or `Scan vault for reminders`. + +### Provider setup + +Open `Settings -> Reminder Notifier`. + +#### ntfy + +1. Click `Add provider`. +2. Choose `ntfy.sh`. +3. Enable the provider. +4. Set the topic in the JSON config: + +```json +{ + "serverUrl": "https://ntfy.sh", + "topic": "my-obsidian-reminders", + "token": "", + "priority": 3, + "tags": ["calendar", "obsidian"] +} +``` + +5. Click `Test notification`. + +For self-hosted ntfy, change `serverUrl` and set `token` if your server requires authentication. + +#### Telegram + +1. Create a bot with BotFather. +2. Add the Telegram provider from the template. +3. Enable it. +4. Set `botToken` and `chatId`. +5. Click `Test notification`. + +#### Webhook and Discord + +Use the webhook templates for Discord, Slack-compatible tools, Make, n8n, Home Assistant, or custom services. Webhook methods are limited to `POST`, `PUT`, and `PATCH`. + +### Troubleshooting + +- If a test notification works but a note reminder does not arrive, run `Scan current note for reminders` after editing the note. +- Check `Show all reminders` for failed reminders and error messages. +- Make sure the provider is enabled in settings. +- Make sure the reminder due time is in the future when created. +- Obsidian must be running for reminders to be delivered at the exact due time. + +## Русский + +### Возможности + +- Создание напоминаний из командной палитры. +- Создание напоминаний из выделенного текста через меню правой кнопки мыши. +- Создание напоминаний через кнопку в левой панели Obsidian. +- Просмотр активных, отправленных, выполненных, отложенных и ошибочных напоминаний. +- Сканирование текущей заметки или всего vault по синтаксису `@remind(...)`. +- Отправка уведомлений через локальные уведомления Obsidian, Telegram, ntfy, Discord или универсальный webhook. +- Настройка провайдеров из готовых шаблонов. + +### Установка + +#### Ручная установка + +1. Скачайте или соберите файлы плагина. +2. Создайте папку внутри вашего vault: + +```text +<ваш-vault>/.obsidian/plugins/reminder-notifier/ +``` + +3. Скопируйте туда файлы: + +```text +manifest.json +main.js +styles.css +``` + +4. Перезапустите Obsidian или перезагрузите плагины. +5. Откройте `Settings -> Community plugins`. +6. При необходимости отключите Safe mode. +7. Включите `Reminder Notifier`. + +#### Сборка из исходников + +```bash +npm install +npm run build +``` + +После сборки скопируйте `manifest.json`, `main.js` и `styles.css` в папку плагина внутри vault. + +### Использование + +#### Создание напоминания из интерфейса + +Доступные способы: + +- кнопка `Create reminder` в левой панели; +- команда `Create reminder` в командной палитре; +- пункт `Create reminder` в меню правой кнопки мыши в редакторе; +- команда `Create reminder from selected text`. + +В окне создания есть: + +- поле для текста напоминания; +- выпадающий список года; +- выпадающий список месяца; +- выпадающий список дня; +- выпадающий список часа; +- выпадающий список минут; +- дополнительное поле описания; +- поле каналов уведомлений. + +Если поле каналов оставить пустым, уведомление будет отправлено во все включённые провайдеры. Если нужно отправить только в конкретные каналы, укажите ID или типы провайдеров через запятую: + +```text +ntfy +telegram, ntfy +discord +``` + +#### Просмотр напоминаний + +Доступные способы: + +- кнопка `Show reminders` в левой панели; +- пункт `Show reminders` в меню правой кнопки мыши; +- команда `Show active reminders`; +- команда `Show all reminders`. + +В списке можно отложить напоминание на 15 минут, отметить выполненным или удалить. + +#### Синтаксис в Markdown + +```md +- [ ] Позвонить врачу @remind(2026-07-01 10:00) +- [ ] Проверить сервер @remind(2026-07-01 09:00, ntfy) +- [ ] Сделать перерыв @remind(+2h, local) +- [ ] Планирование недели @remind(next monday 14:30, telegram, ntfy) +``` + +Если канал не указан, найденное в заметке напоминание отправляется во все включённые провайдеры уведомлений. + +Поддерживается frontmatter: + +```yaml +--- +reminder: + at: 2026-07-01 10:00 + title: Позвонить врачу + notify: + - telegram + - ntfy +--- +``` + +Поддерживаются code blocks: + +```` +```reminder +title: Сделать бэкап +at: 2026-07-01 23:00 +notify: ntfy +priority: high +``` +```` + +После добавления напоминаний в заметки запустите `Scan current note for reminders` или `Scan vault for reminders`. + +### Настройка провайдеров + +Откройте `Settings -> Reminder Notifier`. + +#### ntfy + +1. Нажмите `Add provider`. +2. Выберите `ntfy.sh`. +3. Включите провайдер. +4. Укажите topic в JSON-конфиге: + +```json +{ + "serverUrl": "https://ntfy.sh", + "topic": "my-obsidian-reminders", + "token": "", + "priority": 3, + "tags": ["calendar", "obsidian"] +} +``` + +5. Нажмите `Test notification`. + +Для self-hosted ntfy измените `serverUrl` и заполните `token`, если сервер требует авторизацию. + +#### Telegram + +1. Создайте бота через BotFather. +2. Добавьте Telegram-провайдер из шаблона. +3. Включите его. +4. Укажите `botToken` и `chatId`. +5. Нажмите `Test notification`. + +#### Webhook и Discord + +Webhook-шаблоны можно использовать для Discord, Slack-совместимых сервисов, Make, n8n, Home Assistant и других HTTP-интеграций. Разрешённые методы webhook: `POST`, `PUT`, `PATCH`. + +### Если уведомление не пришло + +- Если тестовое уведомление работает, а напоминание из заметки не пришло, запустите `Scan current note for reminders` после изменения заметки. +- Откройте `Show all reminders` и проверьте статус и текст ошибки. +- Убедитесь, что нужный провайдер включён. +- Убедитесь, что время напоминания было в будущем при создании. +- Для точной отправки в заданное время Obsidian должен быть открыт. + +## Development + +```bash +npm install +npm run build +``` + +The generated production bundle is `main.js`. + diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..6405631 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,51 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +*/ +`; + +const prod = process.argv[2] === "production"; +const watch = process.argv.includes("--watch"); + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (watch) { + await context.watch(); + console.log("Watching for changes..."); +} else { + await context.rebuild(); + await context.dispose(); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..977cfad --- /dev/null +++ b/main.js @@ -0,0 +1,9 @@ +/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +*/ + +var V=Object.defineProperty;var X=Object.getOwnPropertyDescriptor;var Q=Object.getOwnPropertyNames;var Z=Object.prototype.hasOwnProperty;var tt=(o,e)=>{for(var t in e)V(o,t,{get:e[t],enumerable:!0})},et=(o,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Q(e))!Z.call(o,s)&&s!==t&&V(o,s,{get:()=>e[s],enumerable:!(n=X(e,s))||n.enumerable});return o};var it=o=>et(V({},"__esModule",{value:!0}),o);var ot={};tt(ot,{default:()=>M});module.exports=it(ot);var m=require("obsidian");var b=require("obsidian");var nt=new Map([["sunday",0],["monday",1],["tuesday",2],["wednesday",3],["thursday",4],["friday",5],["saturday",6]]);function D(o,e=new Date){var a,c,p,w,y,T;let t=o.trim();if(!t)return null;let n=t.match(/^\+(\d+)\s*(m|min|minute|minutes|h|hour|hours|d|day|days)$/i);if(n){let h=Number(n[1]),g=n[2].toLowerCase(),P=g.startsWith("m")?h*6e4:g.startsWith("h")?h*36e5:h*864e5;return new Date(e.getTime()+P)}let s=t.match(/^tomorrow(?:\s+(\d{1,2})(?::(\d{2}))?)?$/i);if(s){let h=new Date(e);return h.setDate(h.getDate()+1),h.setHours(Number((a=s[1])!=null?a:9),Number((c=s[2])!=null?c:0),0,0),h}let i=t.match(/^next\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)(?:\s+(\d{1,2})(?::(\d{2}))?)?$/i);if(i){let h=nt.get(i[1].toLowerCase());if(h===void 0)return null;let g=new Date(e),P=h-g.getDay()+7||7;return g.setDate(g.getDate()+P),g.setHours(Number((p=i[2])!=null?p:9),Number((w=i[3])!=null?w:0),0,0),g}let r=t.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{1,2})(?::(\d{2}))?)?$/);if(r){let h=(y=r[2])!=null?y:"9",g=(T=r[3])!=null?T:"0",P=new Date(`${r[1]}T${h.padStart(2,"0")}:${g.padStart(2,"0")}:00`);return Number.isNaN(P.getTime())?null:P}let d=new Date(t);return Number.isNaN(d.getTime())?null:d}function j(o){let e=new Date(o);return Number.isNaN(e.getTime())?o:e.toLocaleString()}var v=class extends b.Modal{constructor(t,n,s=!1){super(t);this.store=n;this.showCompleted=s}onOpen(){this.render()}render(){let{contentEl:t}=this;t.empty(),t.createEl("h2",{text:this.showCompleted?"All reminders":"Active reminders"}),new b.Setting(t).addButton(i=>i.setButtonText(this.showCompleted?"Show active only":"Show all").onClick(()=>{this.showCompleted=!this.showCompleted,this.render()}));let n=this.showCompleted?this.store.getAll():this.store.getAll().filter(i=>!i.completed&&i.status!=="cancelled"&&i.status!=="sent"&&i.status!=="done");if(!n.length){t.createEl("p",{text:this.showCompleted?"No reminders.":"No active reminders."});return}let s=t.createDiv({cls:"reminder-notifier-list"});for(let i of n){let r=s.createDiv({cls:"reminder-notifier-item"});r.createDiv({cls:"reminder-notifier-item-title",text:i.title}),r.createDiv({cls:"reminder-notifier-item-meta",text:`${j(i.dueAt)} - ${i.status} - ${i.channels.join(", ")||"all enabled"}`}),i.sourceFile&&r.createDiv({cls:"reminder-notifier-item-meta",text:`${i.sourceFile}${i.sourceLine?`:${i.sourceLine}`:""}`}),i.lastError&&r.createDiv({cls:"reminder-notifier-item-meta",text:`Last error: ${i.lastError}`});let d=r.createDiv({cls:"reminder-notifier-actions"});new b.Setting(d).addButton(a=>a.setButtonText("Snooze 15m").onClick(async()=>{await this.store.snooze(i.id,15),this.render()})).addButton(a=>a.setButtonText("Done").onClick(async()=>{await this.store.complete(i.id),this.render()})).addButton(a=>a.setButtonText("Delete").onClick(async()=>{await this.store.remove(i.id),this.render()}))}}};var f=require("obsidian"),S=class extends f.Modal{constructor(t,n,s,i=""){super(t);this.defaultChannels=n;this.onSubmit=s;this.title="";this.body="";this.year="";this.month="";this.day="";this.hour="";this.minute="";this.channels="";let r=new Date(Date.now()+60*60*1e3);this.title=i,this.body=i,this.channels=n.join(", "),this.year=String(r.getFullYear()),this.month=String(r.getMonth()+1).padStart(2,"0"),this.day=String(r.getDate()).padStart(2,"0"),this.hour=String(r.getHours()).padStart(2,"0"),this.minute=String(Math.ceil(r.getMinutes()/5)*5).padStart(2,"0"),this.minute==="60"&&(this.minute="00",this.hour=String((r.getHours()+1)%24).padStart(2,"0"))}onOpen(){let{contentEl:t}=this;t.empty(),t.createEl("h2",{text:"Create reminder"}),this.renderReminderRow(t),new f.Setting(t).setName("Details").setDesc("Optional longer message.").addTextArea(n=>{n.setValue(this.body),n.inputEl.rows=3,n.onChange(s=>{this.body=s})}),new f.Setting(t).setName("Channels").setDesc("Comma separated provider IDs or types. Leave empty to use all enabled providers.").addText(n=>n.setValue(this.channels).onChange(s=>{this.channels=s})),new f.Setting(t).addButton(n=>n.setButtonText("Create").setCta().onClick(async()=>{let s=this.getSelectedDate();if(!this.title.trim()||!s){new f.Notice("Title and valid due date are required.");return}await this.onSubmit({title:this.title.trim(),body:this.body.trim(),dueAt:s.toISOString(),channels:this.channels.split(",").map(i=>i.trim()).filter(Boolean)}),this.close()}))}renderReminderRow(t){let n=new f.Setting(t).setName("Reminder").setDesc("Text, date, and time.");n.addText(s=>{s.setPlaceholder("Reminder text"),s.setValue(this.title),s.onChange(i=>{this.title=i,(!this.body||this.body===this.title)&&(this.body=i)})}),n.addDropdown(s=>{for(let i of this.range(new Date().getFullYear(),new Date().getFullYear()+3))s.addOption(String(i),String(i));s.setValue(this.year),s.onChange(i=>{this.year=i,this.ensureValidDay()})}),n.addDropdown(s=>{for(let i of this.range(1,12)){let r=String(i).padStart(2,"0");s.addOption(r,r)}s.setValue(this.month),s.onChange(i=>{this.month=i,this.ensureValidDay()})}),n.addDropdown(s=>{for(let i of this.range(1,31)){let r=String(i).padStart(2,"0");s.addOption(r,r)}s.setValue(this.day),s.onChange(i=>{this.day=i})}),n.addDropdown(s=>{for(let i of this.range(0,23)){let r=String(i).padStart(2,"0");s.addOption(r,r)}s.setValue(this.hour),s.onChange(i=>{this.hour=i})}),n.addDropdown(s=>{for(let i of this.range(0,55,5)){let r=String(i).padStart(2,"0");s.addOption(r,r)}s.setValue(this.minute),s.onChange(i=>{this.minute=i})})}getSelectedDate(){let t=new Date(`${this.year}-${this.month}-${this.day}T${this.hour}:${this.minute}:00`);return Number.isNaN(t.getTime())?null:t}ensureValidDay(){let t=new Date(Number(this.year),Number(this.month),0).getDate();Number(this.day)>t&&(this.day=String(t).padStart(2,"0"))}range(t,n,s=1){let i=[];for(let r=t;r<=n;r+=s)i.push(r);return i}};var L=require("obsidian");var U=/@remind\(([^)]+)\)/gi,W=/(?:\uD83D\uDD14|\[!\s*reminder\s*\])\s*(\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}(?::\d{2})?)?)/i,R=class{parseMarkdown(e,t){return[...this.parseInline(e,t),...this.parseFrontmatter(e,t),...this.parseReminderBlocks(e,t)]}parseInline(e,t){let n=[];return e.split(/\r?\n/).forEach((i,r)=>{for(let a of i.matchAll(U)){let c=a[1].split(",").map(y=>y.trim()).filter(Boolean),p=D(c[0]);if(!p)continue;let w=i.replace(U,"").replace(/^[-*]\s+\[[ xX]\]\s+/,"").trim();n.push({title:w||"Reminder",body:w,dueAt:p.toISOString(),sourceFile:t,sourceLine:r+1,channels:c.slice(1),priority:this.parsePriority(c)})}let d=i.match(W);if(d){let a=D(d[1]);if(!a)return;let c=i.replace(W,"").replace(/^[-*]\s+\[[ xX]\]\s+/,"").trim();n.push({title:c||"Reminder",body:c,dueAt:a.toISOString(),sourceFile:t,sourceLine:r+1,channels:[],priority:"normal"})}}),n}parseFrontmatter(e,t){let n=e.match(/^---\r?\n([\s\S]*?)\r?\n---/);return n?this.parseYamlLike(n[1],t,1):[]}parseReminderBlocks(e,t){let n=[],s=/```reminder\r?\n([\s\S]*?)```/gi;for(let i of e.matchAll(s)){let r=e.slice(0,i.index).split(/\r?\n/).length;n.push(...this.parseYamlLike(i[1],t,r))}return n}parseYamlLike(e,t,n){return(e.includes("reminders:")?this.parseReminderList(e):[this.parseKeyValueBlock(e)]).flatMap(i=>{var c,p,w,y;let r=(p=(c=i.at)!=null?c:i.date)!=null?p:i.dueAt;if(!r)return[];let d=D(r);if(!d)return[];let a=((y=(w=i.notify)!=null?w:i.channels)!=null?y:"").split(",").map(T=>T.trim()).filter(Boolean);return[{title:i.title||i.message||"Reminder",body:i.message||i.body||i.title||"",dueAt:d.toISOString(),sourceFile:t,sourceLine:n,channels:a,priority:this.normalizePriority(i.priority)}]})}parseReminderList(e){let t=[],n=null;for(let s of e.split(/\r?\n/)){let i=s.match(/^\s*-\s+(\w+):\s*(.+)$/);if(i){n={[i[1]]:i[2].trim()},t.push(n);continue}let r=s.match(/^\s+(\w+):\s*(.+)$/);r&&n&&(n[r[1]]=r[2].trim())}return t}parseKeyValueBlock(e){let t={},n=e.split(/\r?\n/),s=!1,i="";for(let r of n){if(/^\s*reminder:\s*$/.test(r)){s=!0;continue}let d=r.match(/^\s*-\s+(.+)$/);if(d&&i){t[i]=[t[i],d[1].trim()].filter(Boolean).join(",");continue}let a=r.match(/^\s*(\w+):\s*(.*)$/);a&&(s||!r.startsWith(" "))&&(t[a[1]]=a[2].trim(),i=a[1])}return t}parsePriority(e){let t=e.find(n=>/^(low|normal|high)$/i.test(n));return this.normalizePriority(t)}normalizePriority(e){return e==="low"||e==="high"||e==="normal"?e:"normal"}};var k=class{constructor(e,t,n){this.vault=e;this.store=t;this.providers=n;this.timer=null;this.parser=new R}start(){this.stop();let e=Math.max(10,this.store.getSettings().checkIntervalSeconds)*1e3;this.timer=window.setInterval(()=>{this.tick()},e),this.tick()}stop(){this.timer!==null&&(window.clearInterval(this.timer),this.timer=null)}async tick(){for(let e of this.store.getDue())await this.sendReminder(e)}async scanFile(e){let t=await this.vault.read(e),n=this.parser.parseMarkdown(t,e.path);return this.store.addMany(n)}async scanVault(){let e=0,t=this.vault.getMarkdownFiles();for(let n of t)e+=await this.scanFile(n);return e}async sendReminder(e){let t=this.store.getSettings().notificationProviders.filter(i=>i.enabled),n=t.filter(i=>e.channels.includes(i.id)||e.channels.includes(i.type)),s=n.length?n:e.channels.length===0?t:[];if(!s.length){let i=e.channels.length?`Reminder has no matching enabled provider: ${e.title}`:`Reminder has no enabled providers: ${e.title}`;new L.Notice(i),await this.store.markFailed(e.id,i);return}try{for(let i of s){let r=this.providers.get(i.type);if(!r)throw new Error(`Unknown provider: ${i.type}`);await r.send({title:e.title,message:e.body||e.title,sourceFile:e.sourceFile,sourceLine:e.sourceLine,priority:e.priority,dueAt:e.dueAt},i)}await this.store.markSent(e.id)}catch(i){await this.store.markFailed(e.id,i),new L.Notice(`Reminder failed: ${e.title}`)}}};function Y(o){let e=o.join("|"),t=0;for(let n=0;nn.type===o);if(!e)return null;let t=Date.now().toString(36);return{...e,id:`${e.type}-${t}`,config:JSON.parse(JSON.stringify(e.config))}}var A=class{constructor(e){this.plugin=e;this.data={settings:B(),reminders:[]}}async load(){var n,s,i,r;let e=await this.plugin.loadData(),t=B();this.data={settings:{...t,...(n=e==null?void 0:e.settings)!=null?n:{},notificationProviders:(i=(s=e==null?void 0:e.settings)==null?void 0:s.notificationProviders)!=null?i:t.notificationProviders},reminders:(r=e==null?void 0:e.reminders)!=null?r:[]}}async save(){await this.plugin.saveData(this.data)}getSettings(){return this.data.settings}async updateSettings(e){this.data.settings=e,await this.save()}getAll(){return[...this.data.reminders].sort((e,t)=>e.dueAt.localeCompare(t.dueAt))}getDue(e=new Date){return this.data.reminders.filter(t=>(t.status==="pending"||t.status==="snoozed"||t.status==="failed")&&new Date(t.dueAt).getTime()<=e.getTime())}async add(e,t){var r,d,a;let n=new Date().toISOString(),s={id:Y([(r=e.sourceFile)!=null?r:"manual",String((d=e.sourceLine)!=null?d:""),e.title,e.dueAt]),title:e.title,body:e.body,dueAt:e.dueAt,sourceFile:e.sourceFile,sourceLine:e.sourceLine,completed:!1,channels:e.channels.length?e.channels:t,priority:(a=e.priority)!=null?a:"normal",status:"pending",createdAt:n,updatedAt:n},i=this.data.reminders.find(c=>c.id===s.id);return i?(Object.assign(i,{...s,status:i.status,completed:i.completed,createdAt:i.createdAt,updatedAt:n}),await this.save(),i):(this.data.reminders.push(s),await this.save(),s)}async addMany(e){let t=0;for(let n of e)await this.add(n,[]),t+=1;return t}async markSent(e){let t=this.find(e);t&&(t.status=this.data.settings.afterSend==="mark-done"?"done":"sent",t.completed=this.data.settings.afterSend==="mark-done",t.updatedAt=new Date().toISOString(),t.lastError=void 0,await this.save())}async markFailed(e,t){let n=this.find(e);n&&(n.status="failed",n.lastError=t instanceof Error?t.message:String(t),n.updatedAt=new Date().toISOString(),await this.save())}async complete(e){let t=this.find(e);t&&(t.completed=!0,t.status="done",t.updatedAt=new Date().toISOString(),await this.save())}async snooze(e,t){let n=this.find(e);n&&(n.dueAt=new Date(Date.now()+t*6e4).toISOString(),n.status="snoozed",n.updatedAt=new Date().toISOString(),await this.save())}async remove(e){this.data.reminders=this.data.reminders.filter(t=>t.id!==e),await this.save()}find(e){return this.data.reminders.find(t=>t.id===e)}};var l=require("obsidian");var x=class extends l.PluginSettingTab{constructor(t,n){super(t,n);this.plugin=n}display(){let{containerEl:t}=this,n=this.plugin.store.getSettings();t.empty(),t.createEl("h2",{text:"Reminder Notifier"}),new l.Setting(t).setName("Scan vault on startup").addToggle(i=>i.setValue(n.scanVaultOnStartup).onChange(async r=>{n.scanVaultOnStartup=r,await this.plugin.store.updateSettings(n)})),new l.Setting(t).setName("Scan changed notes").addToggle(i=>i.setValue(n.scanOnFileChange).onChange(async r=>{n.scanOnFileChange=r,await this.plugin.store.updateSettings(n)})),new l.Setting(t).setName("Check interval").setDesc("Seconds between reminder checks.").addText(i=>i.setValue(String(n.checkIntervalSeconds)).onChange(async r=>{n.checkIntervalSeconds=Math.max(10,Number(r)||60),await this.plugin.store.updateSettings(n),this.plugin.restartScheduler()})),new l.Setting(t).setName("Default channels").setDesc("Comma separated provider IDs or types. Empty means every enabled provider.").addText(i=>i.setValue(n.defaultChannels.join(", ")).onChange(async r=>{n.defaultChannels=r.split(",").map(d=>d.trim()).filter(Boolean),await this.plugin.store.updateSettings(n)})),new l.Setting(t).setName("After send").addDropdown(i=>i.addOption("mark-sent","Mark as sent").addOption("mark-done","Mark as done").addOption("keep","Keep pending").setValue(n.afterSend).onChange(async r=>{n.afterSend=r,await this.plugin.store.updateSettings(n)})),t.createEl("h3",{text:"Notification providers"});let s=N[0].type;new l.Setting(t).setName("Add from template").addDropdown(i=>{for(let r of N)i.addOption(r.type,r.name);i.onChange(r=>{s=r})}).addButton(i=>i.setButtonText("Add provider").onClick(async()=>{let r=J(s);r&&(n.notificationProviders.push(r),await this.plugin.store.updateSettings(n),this.display())}));for(let i of n.notificationProviders)this.renderProvider(i)}renderProvider(t){let n=this.plugin.store.getSettings(),s=this.containerEl.createDiv({cls:"reminder-notifier-provider"});s.createEl("h4",{text:t.name}),new l.Setting(s).setName("Enabled").addToggle(i=>i.setValue(t.enabled).onChange(async r=>{t.enabled=r,await this.plugin.store.updateSettings(n)})),new l.Setting(s).setName("ID").setDesc("Use this value in @remind(..., provider-id).").addText(i=>i.setValue(t.id).onChange(async r=>{t.id=r.trim(),await this.plugin.store.updateSettings(n)})),new l.Setting(s).setName("Name").addText(i=>i.setValue(t.name).onChange(async r=>{t.name=r,await this.plugin.store.updateSettings(n)})),new l.Setting(s).setName("Config JSON").setDesc("Secrets are stored locally in Obsidian plugin data.").addTextArea(i=>{i.inputEl.addClass("reminder-notifier-json"),i.setValue(JSON.stringify(t.config,null,2)),i.onChange(async r=>{try{t.config=JSON.parse(r),await this.plugin.store.updateSettings(n)}catch(d){}})}),new l.Setting(s).addButton(i=>i.setButtonText("Test notification").onClick(async()=>{try{await this.plugin.testProvider(t),new l.Notice("Test notification sent.")}catch(r){new l.Notice(r instanceof Error?r.message:String(r))}})).addButton(i=>i.setButtonText("Delete").onClick(async()=>{n.notificationProviders=n.notificationProviders.filter(r=>r!==t),await this.plugin.store.updateSettings(n),this.display()}))}};var q=require("obsidian");function C(o,e){var t,n,s,i,r,d;return typeof o=="string"?Object.entries({"{{title}}":e.title,"{{message}}":e.message,"{{body}}":e.message,"{{file}}":(t=e.sourceFile)!=null?t:"","{{sourceFile}}":(n=e.sourceFile)!=null?n:"","{{sourceLine}}":(i=(s=e.sourceLine)==null?void 0:s.toString())!=null?i:"","{{priority}}":(r=e.priority)!=null?r:"normal","{{dueAt}}":e.dueAt,"{{time}}":e.dueAt,"{{url}}":(d=e.url)!=null?d:""}).reduce((a,[c,p])=>a.split(c).join(p),o):Array.isArray(o)?o.map(a=>C(a,e)):o&&typeof o=="object"?Object.fromEntries(Object.entries(o).map(([a,c])=>[a,C(c,e)])):o}function u(o,e=""){return typeof o=="string"?o:e}var O=class{constructor(){this.id="discord";this.name="Discord webhook"}async send(e,t){let n=u(t.config.webhookUrl);if(!n)throw new Error("Discord webhookUrl is required.");await(0,q.requestUrl)({url:n,method:"POST",contentType:"application/json",body:JSON.stringify({username:u(t.config.username,"Obsidian Reminder"),content:`**${e.title}** +${e.message}`}),throw:!0})}async test(e){await this.send({title:"Obsidian Reminder Notifier",message:"Test notification",dueAt:new Date().toISOString()},e)}};var _=require("obsidian"),I=class{constructor(){this.id="local";this.name="Local Obsidian notice"}async send(e){new _.Notice(`${e.title} +${e.message}`,1e4)}async test(e){new _.Notice("Reminder Notifier test notification",5e3)}};var z=require("obsidian");var E=class{constructor(){this.id="ntfy";this.name="ntfy"}async send(e,t){var a;let n=u(t.config.serverUrl,"https://ntfy.sh").replace(/\/$/,""),s=u(t.config.topic);if(!s)throw new Error("ntfy topic is required.");let i={Title:e.title,Priority:String((a=t.config.priority)!=null?a:st(e.priority))},r=u(t.config.token);r&&(i.Authorization=`Bearer ${r}`);let d=t.config.tags;Array.isArray(d)&&(i.Tags=d.join(",")),await(0,z.requestUrl)({url:`${n}/${encodeURIComponent(s)}`,method:"POST",headers:i,body:e.message,throw:!0})}async test(e){await this.send({title:"Obsidian Reminder Notifier",message:"Test notification",dueAt:new Date().toISOString()},e)}};function st(o="normal"){return o==="high"?4:o==="low"?2:3}var H=require("obsidian");var $=class{constructor(){this.id="telegram";this.name="Telegram"}async send(e,t){let n=u(t.config.botToken),s=u(t.config.chatId);if(!n||!s)throw new Error("Telegram botToken and chatId are required.");let i=`*${e.title}* + +${e.message}`;await(0,H.requestUrl)({url:`https://api.telegram.org/bot${n}/sendMessage`,method:"POST",contentType:"application/json",body:JSON.stringify({chat_id:s,text:i,parse_mode:u(t.config.parseMode,"Markdown"),disable_web_page_preview:!0}),throw:!0})}async test(e){await this.send({title:"Obsidian Reminder Notifier",message:"Test notification",dueAt:new Date().toISOString()},e)}};var K=require("obsidian");var F=class{constructor(){this.id="webhook";this.name="Generic webhook"}async send(e,t){var d,a;let n=u(t.config.url);if(!n)throw new Error("Webhook URL is required.");let s=u(t.config.method,"POST").toUpperCase();if(!["POST","PUT","PATCH"].includes(s))throw new Error("Webhook method must be POST, PUT, or PATCH.");let i=C((d=t.config.headers)!=null?d:{"Content-Type":"application/json"},e),r=C((a=t.config.bodyTemplate)!=null?a:{title:"{{title}}",message:"{{message}}",dueAt:"{{dueAt}}",sourceFile:"{{sourceFile}}"},e);await(0,K.requestUrl)({url:n,method:s,headers:i,body:typeof r=="string"?r:JSON.stringify(r),throw:!0})}async test(e){await this.send({title:"Obsidian Reminder Notifier",message:"Test notification",dueAt:new Date().toISOString()},e)}};function G(){let o=[new I,new $,new E,new F,new O];return new Map(o.map(e=>[e.id,e]))}var M=class extends m.Plugin{constructor(){super(...arguments);this.providers=G()}async onload(){if(this.store=new A(this),await this.store.load(),this.scheduler=new k(this.app.vault,this.store,this.providers),this.addSettingTab(new x(this.app,this)),this.registerCommands(),this.registerRibbonActions(),this.registerContextMenus(),this.registerFileWatcher(),this.store.getSettings().scanVaultOnStartup){let t=await this.scheduler.scanVault();t>0&&new m.Notice(`Reminder Notifier indexed ${t} reminders.`)}this.scheduler.start()}onunload(){var t;(t=this.scheduler)==null||t.stop()}restartScheduler(){this.scheduler.stop(),this.scheduler.start()}async testProvider(t){let n=this.providers.get(t.type);if(!n)throw new Error(`Unknown provider: ${t.type}`);await n.test(t)}registerCommands(){this.addCommand({id:"create-reminder",name:"Create reminder",callback:()=>{new S(this.app,this.store.getSettings().defaultChannels,async t=>{await this.store.add(t,this.store.getSettings().defaultChannels),new m.Notice("Reminder created.")}).open()}}),this.addCommand({id:"create-reminder-from-selection",name:"Create reminder from selected text",editorCallback:t=>{let n=t.getSelection();new S(this.app,this.store.getSettings().defaultChannels,async s=>{await this.store.add(s,this.store.getSettings().defaultChannels),new m.Notice("Reminder created.")},n).open()}}),this.addCommand({id:"show-active-reminders",name:"Show active reminders",callback:()=>{new v(this.app,this.store,!1).open()}}),this.addCommand({id:"show-all-reminders",name:"Show all reminders",callback:()=>{new v(this.app,this.store,!0).open()}}),this.addCommand({id:"scan-current-note",name:"Scan current note for reminders",checkCallback:t=>{let n=this.app.workspace.getActiveFile();return n?(t||this.scanCurrentFile(n),!0):!1}}),this.addCommand({id:"scan-vault",name:"Scan vault for reminders",callback:async()=>{let t=await this.scheduler.scanVault();new m.Notice(`Indexed ${t} reminders.`)}}),this.addCommand({id:"test-notification-providers",name:"Test notification providers",callback:async()=>{let t=this.store.getSettings().notificationProviders.filter(n=>n.enabled);for(let n of t)await this.testProvider(n);new m.Notice(`Tested ${t.length} providers.`)}})}registerRibbonActions(){this.addRibbonIcon("bell-plus","Create reminder",()=>{new S(this.app,this.store.getSettings().defaultChannels,async t=>{await this.store.add(t,this.store.getSettings().defaultChannels),new m.Notice("Reminder created.")}).open()}),this.addRibbonIcon("list-checks","Show reminders",()=>{new v(this.app,this.store,!0).open()})}registerContextMenus(){this.registerEvent(this.app.workspace.on("editor-menu",(t,n)=>{t.addSeparator(),t.addItem(s=>s.setTitle("Create reminder").setIcon("bell-plus").onClick(()=>{let i=n.getSelection();new S(this.app,this.store.getSettings().defaultChannels,async r=>{await this.store.add(r,this.store.getSettings().defaultChannels),new m.Notice("Reminder created.")},i).open()})),t.addItem(s=>s.setTitle("Show reminders").setIcon("list-checks").onClick(()=>{new v(this.app,this.store,!0).open()}))}))}registerFileWatcher(){this.registerEvent(this.app.vault.on("modify",t=>{!this.store.getSettings().scanOnFileChange||!(t instanceof m.TFile)||t.extension!=="md"||this.scheduler.scanFile(t)}))}async scanCurrentFile(t){var r;let n=this.app.workspace.getActiveViewOfType(m.MarkdownView),s=(r=n==null?void 0:n.file)!=null?r:t,i=await this.scheduler.scanFile(s);new m.Notice(`Indexed ${i} reminders from ${s.basename}.`)}}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..08cb8c0 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "reminder-notifier", + "name": "Reminder Notifier", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Create reminders from notes and send notifications through Telegram, ntfy, webhooks, Discord, or local Obsidian notices.", + "author": "", + "authorUrl": "", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..575edac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,611 @@ +{ + "name": "obsidian-reminder-notifier", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-reminder-notifier", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.30", + "builtin-modules": "^3.3.0", + "esbuild": "^0.21.5", + "obsidian": "^1.5.12", + "tslib": "^2.6.2", + "typescript": "^5.4.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz", + "integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8088c58 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "obsidian-reminder-notifier", + "version": "0.1.0", + "description": "Obsidian plugin for note-based reminders and external notifications.", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs --watch", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" + }, + "keywords": [ + "obsidian", + "reminders", + "notifications", + "telegram", + "ntfy" + ], + "author": "", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.30", + "builtin-modules": "^3.3.0", + "esbuild": "^0.21.5", + "obsidian": "^1.5.12", + "tslib": "^2.6.2", + "typescript": "^5.4.5" + } +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..e12992a --- /dev/null +++ b/plan.md @@ -0,0 +1,1050 @@ +План создания Obsidian-плагина напоминаний +Определить базовую концепцию +Плагин: Obsidian Notify Reminders. +Основные возможности: +создавать напоминания из заметок; +хранить список активных напоминаний; +отправлять уведомления через разные сервисы; +поддерживать шаблоны настроек для Telegram, ntfy и других провайдеров; +показывать историю отправленных/пропущенных уведомлений. + +Форматы напоминаний в заметках +Поддержать несколько вариантов, чтобы плагин был удобен в разных стилях работы. +Пример inline-синтаксиса: +- [ ] Позвонить врачу @remind(2026-07-01 10:30) +Пример с сервисом: +- [ ] Проверить сервер @remind(2026-07-01 09:00, ntfy) +Пример через YAML/frontmatter: +reminders: + - title: Оплатить хостинг + at: 2026-07-05 12:00 + notify: telegram +Пример через code block: +```reminder +title: Сделать бэкап +at: 2026-07-01 23:00 +notify: ntfy +priority: high + + +Архитектура плагина +Основные модули: +ReminderParser +Ищет напоминания в markdown-файлах. + +ReminderStore +Хранит нормализованные напоминания, их статус, id заметки, строку, дату, канал уведомления. + +Scheduler +Проверяет, какие напоминания пора отправить. + +NotificationProvider +Общий интерфейс для разных сервисов. + +TelegramProvider +Отправка сообщений через Telegram Bot API. + +NtfyProvider +Отправка уведомлений через ntfy topic. + +WebhookProvider +Универсальная отправка на произвольный HTTP endpoint. + +SettingsTab +UI настроек внутри Obsidian. + +TemplatesManager +Готовые шаблоны конфигураций для сервисов. + + +Интерфейс провайдера уведомлений +Все сервисы должны работать через единый контракт: +interface NotificationProvider { + id: string; + name: string; + send(payload: NotificationPayload, config: ProviderConfig): Promise; + test(config: ProviderConfig): Promise; +} +Payload: +interface NotificationPayload { + title: string; + message: string; + sourceFile?: string; + sourceLine?: number; + priority?: "low" | "normal" | "high"; + url?: string; +} + +Поддерживаемые сервисы на первом этапе +MVP: +Telegram; +ntfy; +generic webhook; +локальное Obsidian notice-уведомление. +Позже можно добавить: +Discord webhook; +Slack webhook; +Gotify; +Pushover; +Email/SMTP; +Matrix; +Signal через bridge; +Home Assistant webhook. + +Шаблоны настроек +В настройках плагина сделать раздел: Notification Templates. +Пример шаблона для Telegram: +{ + "id": "telegram-default", + "provider": "telegram", + "name": "Telegram Bot", + "config": { + "botToken": "123456:ABCDEF...", + "chatId": "123456789", + "parseMode": "Markdown" + }, + "messageTemplate": "*{{title}}*\n\n{{message}}\n\nSource: {{file}}" +} +Пример для ntfy: +{ + "id": "ntfy-default", + "provider": "ntfy", + "name": "ntfy.sh topic", + "config": { + "serverUrl": "https://ntfy.sh", + "topic": "my-obsidian-reminders", + "token": "" + }, + "headers": { + "Priority": "{{priority}}", + "Tags": "calendar,obsidian" + }, + "messageTemplate": "{{title}}\n{{message}}\n{{file}}" +} +Пример для self-hosted ntfy: +{ + "id": "ntfy-self-hosted", + "provider": "ntfy", + "name": "Self-hosted ntfy", + "config": { + "serverUrl": "https://notify.example.com", + "topic": "personal", + "token": "tk_xxxxxxxxx" + } +} +Пример для webhook: +{ + "id": "webhook-default", + "provider": "webhook", + "name": "Generic Webhook", + "config": { + "url": "https://example.com/reminder", + "method": "POST", + "headers": { + "Authorization": "Bearer YOUR_TOKEN", + "Content-Type": "application/json" + }, + "body": { + "title": "{{title}}", + "message": "{{message}}", + "file": "{{file}}", + "time": "{{time}}" + } + } +} + +** + + + + + +Переподключение /5 + + + + + + +План создания Obsidian-плагина +Определить цель плагина +Плагин для Obsidian должен позволять создавать напоминания из заметок и отправлять уведомления во внешние сервисы: +Telegram +ntfy +Discord webhook +Slack webhook +Email через SMTP или внешний API +Webhook для любых кастомных сервисов +Рабочее название: Obsidian Reminders Notifier. +Основные функции +Создание напоминаний +Поддержать несколько способов задания напоминаний: +- [ ] Позвонить врачу @remind(2026-07-01 10:00) +--- +reminder: + at: 2026-07-01 10:00 + title: Позвонить врачу + notify: + - telegram + - ntfy +--- +> [!reminder] +> date: 2026-07-01 10:00 +> message: Проверить отчет +Также можно добавить команду через Command Palette: +Create reminder +Create reminder from selected text +Show upcoming reminders +Test notification service +Хранилище напоминаний +На первом этапе лучше хранить напоминания в JSON-файле внутри .obsidian/plugins/plugin-name/data.json. +Пример структуры: +{ + "reminders": [ + { + "id": "rem_001", + "notePath": "Tasks/Health.md", + "line": 12, + "title": "Позвонить врачу", + "message": "Позвонить врачу", + "scheduledAt": "2026-07-01T10:00:00+08:00", + "services": ["telegram", "ntfy"], + "status": "pending", + "createdAt": "2026-06-27T12:00:00+08:00" + } + ] +} +Статусы: +pending +sent +failed +snoozed +cancelled +Парсер напоминаний в заметках +Сделать модуль, который сканирует markdown-файлы и ищет напоминания. +Поддерживаемые форматы: +@remind(2026-07-01 10:00) +@remind(tomorrow 09:00) +@remind(+2h) +@remind(next monday 14:30) +Для начала можно реализовать только ISO/простые даты, а позже добавить natural language parsing. +Планировщик +Плагин должен периодически проверять ближайшие напоминания. +Логика: +при запуске Obsidian загрузить список напоминаний; +каждые 30-60 секунд проверять, есть ли просроченные pending; +отправить уведомление в выбранные сервисы; +записать результат отправки; +при ошибке повторить через заданный интервал. +Важно: Obsidian не всегда открыт. Поэтому стоит явно указать ограничение: напоминания отправляются, только когда Obsidian запущен. Для полноценной + + + + +Переподключение /5 + + + + + + +Сначала быстро сверю актуальную базу по Obsidian plugin API, чтобы план не опирался на устаревшие привычки. Потом соберу практичный roadmap с архитектурой, шаблонами сервисов и идеями расширения. + + + + +Использовано Context7 интеграциюResolve library id + + + + +Query docs + + + + + + + + + +План плагина Obsidian Reminders + Notifications +Цель MVP +Сделать плагин, который позволяет создавать напоминания прямо из Obsidian и отправлять уведомления во внешние сервисы: Telegram, ntfy, Discord/Webhook, email через SMTP/API, Slack/Webhook и другие через универсальный HTTP Webhook. + +Основные сценарии +Создать напоминание из командной палитры. +Создать напоминание из выделенного текста. +Распознать напоминание из Markdown-синтаксиса, например:- [ ] Позвонить врачу ⏰ 2026-07-01 09:30 + +Посмотреть список активных напоминаний. +Отложить, выполнить, удалить или повторить напоминание. +Отправить уведомление в один или несколько сервисов. + +Техническая основа +Использовать стандартный Obsidian plugin scaffold: manifest.json, main.ts, styles.css. +Основной класс плагина наследует Plugin. +В onload():загрузить настройки через loadData(); +зарегистрировать команды через addCommand(); +добавить страницу настроек через PluginSettingTab; +запустить планировщик напоминаний. + +В onunload():остановить таймеры; +сохранить состояние; +освободить ресурсы. + + +Структура данных +interface Reminder { + id: string; + title: string; + body?: string; + dueAt: string; + sourceFile?: string; + sourceLine?: number; + completed: boolean; + repeat?: RepeatRule; + channels: string[]; + createdAt: string; + updatedAt: string; +} +Настройки: +interface PluginSettings { + defaultChannels: string[]; + scanVaultOnStartup: boolean; + reminderSyntax: string; + notificationProviders: NotificationProviderConfig[]; +} + +Архитектура модулей +main.ts +Жизненный цикл плагина, команды, инициализация. +settings.ts +UI настроек. +reminder-store.ts +Хранение напоминаний через loadData() / saveData(). +parser.ts +Поиск напоминаний в Markdown. +scheduler.ts +Проверка ближайших напоминаний. +providers/telegram.ts +providers/ntfy.ts +providers/webhook.ts +providers/discord.ts +providers/base.ts +Общий интерфейс отправки уведомлений. + +Интерфейс провайдера +interface NotificationProvider { + id: string; + name: string; + send(message: NotificationMessage): Promise; + test(): Promise; +} + +Шаблоны настроек сервисов +Telegram Bot +{ + "type": "telegram", + "name": "Personal Telegram", + "enabled": true, + "botToken": "123456:ABCDEF_TOKEN", + "chatId": "123456789", + "parseMode": "Markdown" +} +ntfy +{ + "type": "ntfy", + "name": "Phone ntfy", + "enabled": true, + "serverUrl": "https://ntfy.sh", + "topic": "my-obsidian-reminders", + "token": "", + "priority": "default" +} +Discord Webhook +{ + "type": "discord", + "name": "Discord reminders", + "enabled": true, + "webhookUrl": "https://discord.com/api/webhooks/...", + "username": "Obsidian Reminder" +} +Generic Webhook +{ + "type": "webhook", + "name": "Custom service", + "enabled": true, + "method": "POST", + "url": "https://example.com/reminder", + "headers": { + "Authorization": "Bearer TOKEN", + "Content-Type": "application/json" + }, + "bodyTemplate": { + "title": "{{title}}", + "body": "{{body}}", + "dueAt": "{{dueAt}}", + "sourceFile": "{{sourceFile}}" + } +} + +Настройки UI +На странице настроек стоит сделать: +список подключенных сервисов; +кнопку “Add provider”; +выбор шаблона: Telegram, ntfy, Discord, Webhook; +кнопку “Test notification”; +настройку синтаксиса напоминаний; +интервал проверки; +дефолтные каналы уведомлений; +экспорт/импорт конфигурации без секретов. + +Команды Obsidian +Create reminder +Create reminder from selection +Show upcoming reminders +Scan current note for reminders +Scan vault for reminders +Send test notification +Snooze selected reminder + +План разработки +Подготовить scaffold плагина. +Реализовать настройки и хранение данных. +Сделать модель напоминаний. +Добавить ручное создание напоминаний. +Реализовать scheduler. +Добавить ntfy provider как самый простой. +Добавить Telegram provider. +Добавить generic webhook provider. +Сделать парсер Markdown-напоминаний. +Добавить UI списка напоминаний. +Добавить тестовые уведомления. +Подготовить документацию и примеры конфигов. +Собрать релиз для Obsidian community plugins. + +Идеи для улучшения +Поддержка повторяющихся напоминаний: каждый день, каждую неделю, по cron-like правилу. +Snooze: отложить на 5 минут, 1 час, завтра утром. +Уведомление до события: за 10 минут, за час, за день. +Интеграция с Daily Notes. +Син + + + + +Переподключение /5 + + + + + + +План Плагина +Определить концепцию +Рабочая идея: Obsidian-плагин для напоминаний, который хранит задачи/напоминания внутри vault и отправляет уведомления во внешние сервисы: Telegram, ntfy, Discord, email/webhook и т.п. +Основные сценарии: +создать напоминание из заметки; +создать напоминание через команду Obsidian; +указать дату, время, повтор, текст и канал уведомления; +получить уведомление в выбранном сервисе; +видеть список активных/прошедших напоминаний. + +Выбрать формат напоминаний +Возможные варианты: +- [ ] Позвонить врачу 🔔 2026-07-01 10:00 #reminder +Или YAML/frontmatter: +reminders: + - title: Позвонить врачу + at: 2026-07-01T10:00:00+08:00 + notify: telegram + repeat: none +Или отдельный JSON-файл плагина: +{ + "reminders": [ + { + "id": "rem_001", + "title": "Позвонить врачу", + "time": "2026-07-01T10:00:00+08:00", + "providers": ["telegram", "ntfy"], + "status": "pending" + } + ] +} +Лучший вариант для старта: хранить внутреннее состояние через Obsidian loadData() / saveData(), а затем добавить парсинг Markdown-задач. + +Создать базовую структуру плагина +Минимальные файлы: +manifest.json +package.json +tsconfig.json +esbuild.config.mjs +src/main.ts +src/settings.ts +src/reminders.ts +src/providers/ + telegram.ts + ntfy.ts + webhook.ts +В main.ts: +загрузка настроек; +регистрация команд через addCommand; +регистрация вкладки настроек через PluginSettingTab; +запуск планировщика напоминаний; +остановка таймеров в onunload. + +Команды Obsidian +Добавить команды: +Create reminder +Create reminder from selected text +Show active reminders +Snooze reminder +Test notification provider +Sync reminders from notes +Команда из выделенного текста должна брать текст задачи как заголовок напоминания. + +Настройки плагина +В настройках нужны разделы: +общие настройки; +часовой пояс; +интервал проверки напоминаний; +сервис по умолчанию; +шаблоны уведомлений; +настройки провайдеров; +кнопка тестовой отправки. +Пример структуры настроек: +interface PluginSettings { + defaultProvider: "telegram" | "ntfy" | "webhook"; + checkIntervalSeconds: number; + timezone: string; + providers: { + telegram?: TelegramSettings; + ntfy?: NtfySettings; + webhook?: WebhookSettings; + }; + templates: NotificationTemplate[]; +} + +Шаблоны настроек уведомлений +Telegram +{ + "name": "Telegram", + "enabled": true, + "botToken": "123456:ABCDEF", + "chatId": "123456789", + "messageTemplate": "🔔 Напоминание: {{title}}\nВремя: {{time}}\nЗаметка: {{note}}" +} +Отправка: +POST https://api.telegram.org/bot/sendMessage +ntfy +{ + "name": "ntfy", + "enabled": true, + "serverUrl": "https://ntfy.sh", + "topic": "my-reminders", + "token": "", + "priority": 3, + "tags": ["bell"], + "messageTemplate": "Напоминание: {{title}}" +} +Generic Webhook +{ + "name": "Custom Webhook", + "enabled": true, + "url": "https://example.com/webhook", + "method": "POST", + "headers": { + "Authorization": "Bearer YOUR_TOKEN", + "Content-Type": "application/json" + }, + "bodyTemplate": { + "title": "{{title}}", + "message": "{{message}}", + "time": "{{time}}", + "note": "{{note}}" + } +} +Discord Webhook +{ + "name": "Discord", + "enabled": true, + "webhookUrl": "https://discord.com/api/webhooks/...", + "messageTemplate": "🔔 **{{title}}**\n{{message}}\n{{note}}" +} + +Архитектура провайдеров +Сделать общий интерфейс: +interface NotificationProvider { + id: string; + name: string; + send(reminder: Reminder, settings: unknown): Promise; + test(settings: unknown): Promise; + validate(settings: unknown): string[]; +} +Тогда новые сервисы можно добавлять без переписывания ядра. + +Планировщик напоминаний +Логика: +при запуске плагина загрузить все напоминания; +каждые N секунд проверять просроченные; +отправлять уведомление; +помечать как отправленное; +для повторяющихся напоминаний рассчитывать следующую дату; +хран + + + + + +Переподключение /5 + + + + + + +План создания Obsidian-плагина напоминаний +Определить MVP +Плагин должен уметь: +создавать напоминания из заметки; +хранить список активных напоминаний; +проверять наступление времени; +отправлять уведомление в выбранный сервис; +иметь настройки сервисов уведомлений; +содержать готовые шаблоны конфигураций для Telegram, ntfy и расширяемых HTTP/webhook-сервисов. + +Базовая структура плагина +Стандартная структура Obsidian-плагина: +obsidian-reminders-notifier/ +├─ manifest.json +├─ package.json +├─ esbuild.config.mjs +├─ tsconfig.json +├─ src/ +│ ├─ main.ts +│ ├─ settings.ts +│ ├─ reminders/ +│ │ ├─ reminder-store.ts +│ │ ├─ reminder-parser.ts +│ │ └─ scheduler.ts +│ ├─ providers/ +│ │ ├─ notification-provider.ts +│ │ ├─ telegram-provider.ts +│ │ ├─ ntfy-provider.ts +│ │ └─ webhook-provider.ts +│ └─ templates/ +│ └─ provider-presets.ts +└─ README.md + +Модель данных +Напоминание: +interface Reminder { + id: string; + title: string; + body?: string; + dueAt: string; + sourceFile?: string; + sourceLine?: number; + providerIds: string[]; + status: 'pending' | 'sent' | 'failed' | 'snoozed'; + createdAt: string; + updatedAt: string; +} +Настройки провайдера: +interface NotificationProviderConfig { + id: string; + type: 'telegram' | 'ntfy' | 'webhook'; + name: string; + enabled: boolean; + config: Record; +} + +Способы создания напоминаний +На старте лучше поддержать 3 варианта: +команда из Command Palette: Create reminder; +синтаксис прямо в заметке; +контекстное действие для выделенного текста. +Пример синтаксиса в Markdown: +- [ ] Позвонить врачу @remind(2026-07-01 09:00) +- [ ] Проверить сервер @remind(+2h) @notify(ntfy) +- [ ] Отправить отчет @remind(tomorrow 18:00) @notify(telegram,ntfy) + +Планировщик +Внутри плагина нужен Scheduler, который: +при загрузке читает сохраненные напоминания через loadData(); +периодически проверяет pending-напоминания; +отправляет уведомления через выбранные провайдеры; +помечает напоминание как sent или failed; +при выгрузке плагина очищает таймеры. +Для MVP хватит проверки раз в 30-60 секунд. + +Провайдеры уведомлений +Общий интерфейс: +interface NotificationProvider { + type: string; + send(reminder: Reminder, config: Record): Promise; + test(config: Record): Promise; +} +Провайдеры: +Telegram: отправка через Bot API. +ntfy: отправка POST-запроса на topic. +Webhook: универсальный HTTP POST для Discord, Slack, Make, n8n, Home Assistant и подобных сервисов. + +Шаблоны настроек +В настройках плагина сделать кнопку Add from template. +Примеры шаблонов: +Telegram +{ + "type": "telegram", + "name": "Telegram Bot", + "config": { + "botToken": "123456:ABCDEF...", + "chatId": "123456789" + } +} +ntfy +{ + "type": "ntfy", + "name": "ntfy.sh", + "config": { + "serverUrl": "https://ntfy.sh", + "topic": "my-obsidian-reminders", + "token": "" + } +} +Self-hosted ntfy +{ + "type": "ntfy", + "name": "Self-hosted ntfy", + "config": { + "serverUrl": "https://ntfy.example.com", + "topic": "reminders", + "token": "tk_xxxxx" + } +} +Generic Webhook +{ + "type": "webhook", + "name": "Generic Webhook", + "config": { + "url": "https://example.com/webhook", + "method": "POST", + "headers": "{\"Content-Type\":\"application/json\"}", + "bodyTemplate": "{\"title\":\"{{title}}\",\"body\":\"{{body}}\",\"dueAt\":\"{{dueAt}}\"}" + } +} + +Настройки UI +В PluginSettingTab добавить: +список провайдеров; +включение/отключение каждого сервиса; +кнопку тестового уведомления; +импорт шаблона; +интервал проверки; +поведение после отправки: оставить, удалить, отметить выполненным; +timezone/формат даты; +дефолтный провайдер. + +Команды Obsidian +Добавить команды: +Create reminder; +Show active reminders; +Snooze reminder; +Test notification providers; +Scan current note for reminders; +Scan vault for reminders. + +Безопасность + + Важно: +не логировать токены; +маскировать секреты в UI; +хранить настройки только локально; +предупреждать пользователя, что Telegram bot token и webhook URL являются чувствительными данными; +для webhook ограничить методы на POST/PUT, если не нужен полный контроль. +Тестирование + Проверить: +парсинг разных форматов дат; +отправку в Telegram; +отправку в ntfy; +работу без интернета; +повтор после ошибки; +перезапуск Obsidian; +изменение/удаление напоминаний в заметке; +поведение при неверных токенах. +**README и документа + + + + +Переподключение /5 + + + + + + +План Создания Плагина Obsidian +Идея: плагин для напоминаний внутри Obsidian с отправкой уведомлений в Telegram, ntfy, Discord, Slack, email/webhook и другие сервисы + + + + +Переподключение /5 + + + + + + +Ниже план создания Obsidian-плагина для напоминаний с отправкой уведомлений в Telegram, ntfy и другие сервисы. +Цель Плагина +Плагин для Obsidian, который позволяет создавать напоминания прямо из заметок, хранить их в vault/settings и отправлять уведомления через выбранные каналы: +Telegram Bot +ntfy +Webhook +Discord +Slack +Email через SMTP/API, опционально +локальные уведомления Obsidian +Рабочее название: Obsidian Reminder Notifier. +Основной Сценарий +Пользователь пишет в заметке: +- [ ] Позвонить врачу 🔔 2026-07-01 10:00 #reminder +или через команду: +/remind Завтра в 9:00 проверить бэкап +Плагин распознает напоминание, сохраняет его, в нужное время отправляет уведомление через выбранный сервис и может отметить задачу как выполненную, просроченную или отправленную. +Функции MVP +Создание напоминанийЧерез команду Obsidian: Create reminder +Через контекстное меню выделенного текста +Через синтаксис в Markdown +Через frontmatter, например: + +reminder: + at: 2026-07-01 10:00 + title: Проверить сервер + notify: telegram +Просмотр напоминанийОтдельная панель со списком активных напоминаний +Фильтры: сегодня, неделя, просроченные, выполненные +Переход к заметке-источнику + +Отправка уведомленийTelegram +ntfy +Generic Webhook +Локальное уведомление внутри Obsidian + +НастройкиВыбор часового пояса +Интервал проверки напоминаний +Канал уведомлений по умолчанию +Несколько профилей уведомлений +Тестовая отправка уведомления +Шаблоны конфигураций для сервисов + +Хранение данныхНастройки через loadData() / saveData() +Напоминания можно хранить:в данных плагина +в Markdown-задачах +или гибридно: источник в Markdown, индекс в данных плагина + + +Архитектура +src/ + main.ts + settings.ts + reminders/ + ReminderParser.ts + ReminderStore.ts + ReminderScheduler.ts + ReminderView.ts + notifiers/ + Notifier.ts + TelegramNotifier.ts + NtfyNotifier.ts + WebhookNotifier.ts + LocalNotifier.ts + templates/ + telegram.ts + ntfy.ts + webhook.ts + utils/ + dates.ts + validation.ts +Ключевые Компоненты +ReminderParser +Ищет напоминания в Markdown +Поддерживает даты:2026-07-01 10:00 +tomorrow 9:00 +через 2 часа +каждый понедельник 09:00, в будущем + +ReminderStore +Хранит индекс напоминаний +Связывает напоминание с файлом и строкой +Отслеживает статусы: pending, sent, failed, done, ` + + + + +Переподключение /5 + + + + + + +Работал на протяжении 4m 15s + + + + +Ниже план создания Obsidian-плагина для напоминаний с отправкой уведомлений в Telegram, ntfy и другие сервисы. +Цель Плагина +Плагин для Obsidian, который позволяет создавать напоминания из заметок, командной палитры или настроек, а в нужное время отправляет уведомления через выбранные каналы: Telegram, ntfy, webhooks, email-провайдеры или другие интеграции. +Рабочее название: Obsidian Reminder Notifier. +Основные Возможности +Создание напоминаний: +через команду Obsidian: Create reminder; +из выделенного текста; +из строки в заметке, например:- [ ] Позвонить врачу @remind(2026-07-01 10:00) + +через frontmatter:reminder: + at: 2026-07-01T10:00:00+08:00 + title: Позвонить врачу + notify: telegram + + +Отправка уведомлений: +Telegram Bot API; +ntfy; +generic webhook; +Discord webhook; +Slack webhook; +Gotify; +Pushover; +Apprise как универсальный backend, если захочется поддержать много сервисов через один формат. + +Управление напоминаниями: +список активных напоминаний; +отложить: 5 мин, 15 мин, 1 час, завтра; +отметить выполненным; +открыть связанную заметку; +повторяющиеся напоминания: daily, weekly, monthly, custom cron-like. + +Шаблоны настроек: +встроенные примеры конфигурации для каждого сервиса; +кнопка “создать из шаблона”; +проверка подключения; +подсказки, какие поля обязательны. + +Локальное хранение: +настройки через Obsidian loadData() / saveData(); +напоминания можно хранить либо в data.json, либо прямо в markdown-заметках; +лучше поддержать оба режима: “из заметок” и “локальная база плагина”. + +Архитектура +src/ + main.ts + settings/ + SettingsTab.ts + templates.ts + reminders/ + ReminderParser.ts + ReminderStore.ts + ReminderScheduler.ts + ReminderModal.ts + ReminderListView.ts + notifiers/ + Notifier.ts + TelegramNotifier.ts + NtfyNotifier.ts + WebhookNotifier.ts + DiscordNotifier.ts + GotifyNotifier.ts + utils/ + date.ts + logger.ts + validation.ts +manifest.json +styles.css +Ключевые Модули +ReminderParser +ищет напоминания в markdown; +поддерживает remind(...), frontmatter и, возможно, Dataview-подобный синтаксис; +нормализует дату, текст, ссылку на файл, статус. +ReminderScheduler +при запуске плагина загружает будущие напоминания; +ставит таймеры через setTimeout; +периодически пересканирует vault; +учитывает, что Obsidian может быть закрыт: просроченные напоминания отправляются при следующем запуске. +ReminderStore +хранит созданные через UI напоминания; +синхронизирует состояние: выполнено, отложено, отправлено; +предотвращает повторную отправку одного и того же события. +Notifier +interface Notifier { + id: string; + name: string; + send(payload: NotificationPayload): Promise; + test(config: unknown): Promise; +} +SettingsTab +список каналов уведомлений; +добавление нового канала из шаблона; +включение/отключение каналов; +тестовая отправка; +выбор канала по умолчанию. +Примеры Шаблонов Настроек +Telegram: +{ + "type": "telegram", + "name": "Telegram personal", + "enabled": true, + "botToken": "123456:ABCDEF_your_bot_token", + "chatId": "123456789", + "parseMode": "Markdown" +} +ntfy: +{ + "type": "ntfy", + "name": "ntfy phone", + "enabled": true, + "serverUrl": "https://ntfy.sh", + "topic": "my-obsidian-reminders", + "token": "", + "priority": 3, + "tags": ["bell"] +} +Generic webhook: +{ + "type": "webhook", + "name": "Custom webhook", + "enabled": true, + "url": "https://example.com/reminder", + "method": "POST", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + }, + "bodyTemplate": { + "title": "{{title}}", + "message": "{{message}}", + "note": "{{notePath}}", + "time": "{{time}}" + } +} +Discord webhook: +{ + "type": "discord", + "name": "Discord reminders", + "enabled": true, + "webhookUrl": "https://discord.com/api/webhooks/...", + "username": "Obsidian Reminder" +} +Gotify: +{ + "type": "gotify", + "name": "Gotify server", + "enabled": true, + "serverUrl": "https://gotify.example.com", + "appToken": "YOUR_APP_TOKEN", + "priority": 5 +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..24d1b37 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,181 @@ +import { Editor, MarkdownView, Menu, Notice, Plugin, TFile } from "obsidian"; +import { ReminderListModal } from "./reminders/ReminderListModal"; +import { ReminderModal } from "./reminders/ReminderModal"; +import { ReminderScheduler } from "./reminders/ReminderScheduler"; +import { ReminderStore } from "./reminders/ReminderStore"; +import { ReminderNotifierSettingTab } from "./settings/SettingsTab"; +import { NotificationProviderConfig, ProviderType } from "./types"; +import { createProviderRegistry } from "./notifiers"; + +export default class ReminderNotifierPlugin extends Plugin { + store!: ReminderStore; + scheduler!: ReminderScheduler; + providers = createProviderRegistry(); + + async onload(): Promise { + this.store = new ReminderStore(this); + await this.store.load(); + + this.scheduler = new ReminderScheduler(this.app.vault, this.store, this.providers); + this.addSettingTab(new ReminderNotifierSettingTab(this.app, this)); + this.registerCommands(); + this.registerRibbonActions(); + this.registerContextMenus(); + this.registerFileWatcher(); + + if (this.store.getSettings().scanVaultOnStartup) { + const count = await this.scheduler.scanVault(); + if (count > 0) { + new Notice(`Reminder Notifier indexed ${count} reminders.`); + } + } + + this.scheduler.start(); + } + + onunload(): void { + this.scheduler?.stop(); + } + + restartScheduler(): void { + this.scheduler.stop(); + this.scheduler.start(); + } + + async testProvider(provider: NotificationProviderConfig): Promise { + const notifier = this.providers.get(provider.type as ProviderType); + if (!notifier) { + throw new Error(`Unknown provider: ${provider.type}`); + } + await notifier.test(provider); + } + + private registerCommands(): void { + this.addCommand({ + id: "create-reminder", + name: "Create reminder", + callback: () => { + new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { + await this.store.add(input, this.store.getSettings().defaultChannels); + new Notice("Reminder created."); + }).open(); + }, + }); + + this.addCommand({ + id: "create-reminder-from-selection", + name: "Create reminder from selected text", + editorCallback: (editor: Editor) => { + const selection = editor.getSelection(); + new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { + await this.store.add(input, this.store.getSettings().defaultChannels); + new Notice("Reminder created."); + }, selection).open(); + }, + }); + + this.addCommand({ + id: "show-active-reminders", + name: "Show active reminders", + callback: () => { + new ReminderListModal(this.app, this.store, false).open(); + }, + }); + + this.addCommand({ + id: "show-all-reminders", + name: "Show all reminders", + callback: () => { + new ReminderListModal(this.app, this.store, true).open(); + }, + }); + + this.addCommand({ + id: "scan-current-note", + name: "Scan current note for reminders", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveFile(); + if (!file) { + return false; + } + if (!checking) { + void this.scanCurrentFile(file); + } + return true; + }, + }); + + this.addCommand({ + id: "scan-vault", + name: "Scan vault for reminders", + callback: async () => { + const count = await this.scheduler.scanVault(); + new Notice(`Indexed ${count} reminders.`); + }, + }); + + this.addCommand({ + id: "test-notification-providers", + name: "Test notification providers", + callback: async () => { + const providers = this.store.getSettings().notificationProviders.filter((provider) => provider.enabled); + for (const provider of providers) { + await this.testProvider(provider); + } + new Notice(`Tested ${providers.length} providers.`); + }, + }); + } + + private registerRibbonActions(): void { + 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); + new Notice("Reminder created."); + }).open(); + }); + + this.addRibbonIcon("list-checks", "Show reminders", () => { + new ReminderListModal(this.app, this.store, true).open(); + }); + } + + private registerContextMenus(): void { + this.registerEvent(this.app.workspace.on("editor-menu", (menu: Menu, editor: Editor) => { + menu.addSeparator(); + menu.addItem((item) => item + .setTitle("Create reminder") + .setIcon("bell-plus") + .onClick(() => { + const selection = editor.getSelection(); + new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { + await this.store.add(input, this.store.getSettings().defaultChannels); + new Notice("Reminder created."); + }, selection).open(); + })); + + menu.addItem((item) => item + .setTitle("Show reminders") + .setIcon("list-checks") + .onClick(() => { + new ReminderListModal(this.app, this.store, true).open(); + })); + })); + } + + private registerFileWatcher(): void { + this.registerEvent(this.app.vault.on("modify", (file) => { + if (!this.store.getSettings().scanOnFileChange || !(file instanceof TFile) || file.extension !== "md") { + return; + } + void this.scheduler.scanFile(file); + })); + } + + private async scanCurrentFile(file: TFile): Promise { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + const target = view?.file ?? file; + const count = await this.scheduler.scanFile(target); + new Notice(`Indexed ${count} reminders from ${target.basename}.`); + } +} diff --git a/src/notifiers/DiscordNotifier.ts b/src/notifiers/DiscordNotifier.ts new file mode 100644 index 0000000..14fe0eb --- /dev/null +++ b/src/notifiers/DiscordNotifier.ts @@ -0,0 +1,34 @@ +import { requestUrl } from "obsidian"; +import { NotificationPayload, NotificationProvider, NotificationProviderConfig } from "../types"; +import { asString } from "../utils/template"; + +export class DiscordNotifier implements NotificationProvider { + id = "discord" as const; + name = "Discord webhook"; + + async send(payload: NotificationPayload, provider: NotificationProviderConfig): Promise { + const webhookUrl = asString(provider.config.webhookUrl); + if (!webhookUrl) { + throw new Error("Discord webhookUrl is required."); + } + + await requestUrl({ + url: webhookUrl, + method: "POST", + contentType: "application/json", + body: JSON.stringify({ + username: asString(provider.config.username, "Obsidian Reminder"), + content: `**${payload.title}**\n${payload.message}`, + }), + throw: true, + }); + } + + async test(provider: NotificationProviderConfig): Promise { + await this.send({ + title: "Obsidian Reminder Notifier", + message: "Test notification", + dueAt: new Date().toISOString(), + }, provider); + } +} diff --git a/src/notifiers/LocalNotifier.ts b/src/notifiers/LocalNotifier.ts new file mode 100644 index 0000000..36354dc --- /dev/null +++ b/src/notifiers/LocalNotifier.ts @@ -0,0 +1,15 @@ +import { Notice } from "obsidian"; +import { NotificationPayload, NotificationProvider, NotificationProviderConfig } from "../types"; + +export class LocalNotifier implements NotificationProvider { + id = "local" as const; + name = "Local Obsidian notice"; + + async send(payload: NotificationPayload): Promise { + new Notice(`${payload.title}\n${payload.message}`, 10_000); + } + + async test(_config: NotificationProviderConfig): Promise { + new Notice("Reminder Notifier test notification", 5_000); + } +} diff --git a/src/notifiers/NtfyNotifier.ts b/src/notifiers/NtfyNotifier.ts new file mode 100644 index 0000000..148a085 --- /dev/null +++ b/src/notifiers/NtfyNotifier.ts @@ -0,0 +1,57 @@ +import { requestUrl } from "obsidian"; +import { NotificationPayload, NotificationProvider, NotificationProviderConfig } from "../types"; +import { asString } from "../utils/template"; + +export class NtfyNotifier implements NotificationProvider { + id = "ntfy" as const; + name = "ntfy"; + + async send(payload: NotificationPayload, provider: NotificationProviderConfig): Promise { + const serverUrl = asString(provider.config.serverUrl, "https://ntfy.sh").replace(/\/$/, ""); + const topic = asString(provider.config.topic); + if (!topic) { + throw new Error("ntfy topic is required."); + } + + const headers: Record = { + Title: payload.title, + Priority: String(provider.config.priority ?? priorityToNtfy(payload.priority)), + }; + + const token = asString(provider.config.token); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const tags = provider.config.tags; + if (Array.isArray(tags)) { + headers.Tags = tags.join(","); + } + + await requestUrl({ + url: `${serverUrl}/${encodeURIComponent(topic)}`, + method: "POST", + headers, + body: payload.message, + throw: true, + }); + } + + async test(provider: NotificationProviderConfig): Promise { + await this.send({ + title: "Obsidian Reminder Notifier", + message: "Test notification", + dueAt: new Date().toISOString(), + }, provider); + } +} + +function priorityToNtfy(priority = "normal"): number { + if (priority === "high") { + return 4; + } + if (priority === "low") { + return 2; + } + return 3; +} diff --git a/src/notifiers/TelegramNotifier.ts b/src/notifiers/TelegramNotifier.ts new file mode 100644 index 0000000..cdce187 --- /dev/null +++ b/src/notifiers/TelegramNotifier.ts @@ -0,0 +1,38 @@ +import { requestUrl } from "obsidian"; +import { NotificationPayload, NotificationProvider, NotificationProviderConfig } from "../types"; +import { asString } from "../utils/template"; + +export class TelegramNotifier implements NotificationProvider { + id = "telegram" as const; + name = "Telegram"; + + async send(payload: NotificationPayload, provider: NotificationProviderConfig): Promise { + const botToken = asString(provider.config.botToken); + const chatId = asString(provider.config.chatId); + if (!botToken || !chatId) { + throw new Error("Telegram botToken and chatId are required."); + } + + const text = `*${payload.title}*\n\n${payload.message}`; + await requestUrl({ + url: `https://api.telegram.org/bot${botToken}/sendMessage`, + method: "POST", + contentType: "application/json", + body: JSON.stringify({ + chat_id: chatId, + text, + parse_mode: asString(provider.config.parseMode, "Markdown"), + disable_web_page_preview: true, + }), + throw: true, + }); + } + + async test(provider: NotificationProviderConfig): Promise { + await this.send({ + title: "Obsidian Reminder Notifier", + message: "Test notification", + dueAt: new Date().toISOString(), + }, provider); + } +} diff --git a/src/notifiers/WebhookNotifier.ts b/src/notifiers/WebhookNotifier.ts new file mode 100644 index 0000000..6a5fc80 --- /dev/null +++ b/src/notifiers/WebhookNotifier.ts @@ -0,0 +1,44 @@ +import { requestUrl } from "obsidian"; +import { NotificationPayload, NotificationProvider, NotificationProviderConfig } from "../types"; +import { asString, renderTemplate } from "../utils/template"; + +export class WebhookNotifier implements NotificationProvider { + id = "webhook" as const; + name = "Generic webhook"; + + async send(payload: NotificationPayload, provider: NotificationProviderConfig): Promise { + const url = asString(provider.config.url); + if (!url) { + throw new Error("Webhook URL is required."); + } + + const method = asString(provider.config.method, "POST").toUpperCase(); + if (!["POST", "PUT", "PATCH"].includes(method)) { + throw new Error("Webhook method must be POST, PUT, or PATCH."); + } + + const headers = renderTemplate(provider.config.headers ?? { "Content-Type": "application/json" }, payload) as Record; + const body = renderTemplate(provider.config.bodyTemplate ?? { + title: "{{title}}", + message: "{{message}}", + dueAt: "{{dueAt}}", + sourceFile: "{{sourceFile}}", + }, payload); + + await requestUrl({ + url, + method, + headers, + body: typeof body === "string" ? body : JSON.stringify(body), + throw: true, + }); + } + + async test(provider: NotificationProviderConfig): Promise { + await this.send({ + title: "Obsidian Reminder Notifier", + message: "Test notification", + dueAt: new Date().toISOString(), + }, provider); + } +} diff --git a/src/notifiers/index.ts b/src/notifiers/index.ts new file mode 100644 index 0000000..9a7ec19 --- /dev/null +++ b/src/notifiers/index.ts @@ -0,0 +1,18 @@ +import { NotificationProvider, ProviderType } from "../types"; +import { DiscordNotifier } from "./DiscordNotifier"; +import { LocalNotifier } from "./LocalNotifier"; +import { NtfyNotifier } from "./NtfyNotifier"; +import { TelegramNotifier } from "./TelegramNotifier"; +import { WebhookNotifier } from "./WebhookNotifier"; + +export function createProviderRegistry(): Map { + const providers: NotificationProvider[] = [ + new LocalNotifier(), + new TelegramNotifier(), + new NtfyNotifier(), + new WebhookNotifier(), + new DiscordNotifier(), + ]; + + return new Map(providers.map((provider) => [provider.id, provider])); +} diff --git a/src/reminders/ReminderListModal.ts b/src/reminders/ReminderListModal.ts new file mode 100644 index 0000000..8ca0eaf --- /dev/null +++ b/src/reminders/ReminderListModal.ts @@ -0,0 +1,75 @@ +import { App, Modal, Setting } from "obsidian"; +import { ReminderStore } from "./ReminderStore"; +import { formatDateTime } from "../utils/date"; + +export class ReminderListModal extends Modal { + constructor(app: App, private store: ReminderStore, private showCompleted = false) { + super(app); + } + + onOpen(): void { + this.render(); + } + + private render(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("h2", { text: this.showCompleted ? "All reminders" : "Active reminders" }); + + new Setting(contentEl) + .addButton((button) => button + .setButtonText(this.showCompleted ? "Show active only" : "Show all") + .onClick(() => { + this.showCompleted = !this.showCompleted; + this.render(); + })); + + const reminders = this.showCompleted + ? this.store.getAll() + : this.store.getAll().filter((reminder) => !reminder.completed && reminder.status !== "cancelled" && reminder.status !== "sent" && reminder.status !== "done"); + if (!reminders.length) { + contentEl.createEl("p", { text: this.showCompleted ? "No reminders." : "No active reminders." }); + return; + } + + const list = contentEl.createDiv({ cls: "reminder-notifier-list" }); + for (const reminder of reminders) { + 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.sourceFile) { + item.createDiv({ + cls: "reminder-notifier-item-meta", + text: `${reminder.sourceFile}${reminder.sourceLine ? `:${reminder.sourceLine}` : ""}`, + }); + } + if (reminder.lastError) { + item.createDiv({ cls: "reminder-notifier-item-meta", text: `Last error: ${reminder.lastError}` }); + } + + const actions = item.createDiv({ cls: "reminder-notifier-actions" }); + new Setting(actions) + .addButton((button) => button + .setButtonText("Snooze 15m") + .onClick(async () => { + await this.store.snooze(reminder.id, 15); + this.render(); + })) + .addButton((button) => button + .setButtonText("Done") + .onClick(async () => { + await this.store.complete(reminder.id); + this.render(); + })) + .addButton((button) => button + .setButtonText("Delete") + .onClick(async () => { + await this.store.remove(reminder.id); + this.render(); + })); + } + } +} diff --git a/src/reminders/ReminderModal.ts b/src/reminders/ReminderModal.ts new file mode 100644 index 0000000..cadc492 --- /dev/null +++ b/src/reminders/ReminderModal.ts @@ -0,0 +1,176 @@ +import { App, Modal, Notice, Setting } from "obsidian"; +import { ParsedReminderInput } from "../types"; + +export class ReminderModal extends Modal { + private title = ""; + private body = ""; + private year = ""; + private month = ""; + private day = ""; + private hour = ""; + private minute = ""; + private channels = ""; + + constructor( + app: App, + private defaultChannels: string[], + private onSubmit: (input: ParsedReminderInput) => Promise, + initialTitle = "", + ) { + super(app); + const initialDate = new Date(Date.now() + 60 * 60 * 1000); + this.title = initialTitle; + this.body = initialTitle; + this.channels = defaultChannels.join(", "); + this.year = String(initialDate.getFullYear()); + this.month = String(initialDate.getMonth() + 1).padStart(2, "0"); + this.day = String(initialDate.getDate()).padStart(2, "0"); + this.hour = String(initialDate.getHours()).padStart(2, "0"); + this.minute = String(Math.ceil(initialDate.getMinutes() / 5) * 5).padStart(2, "0"); + if (this.minute === "60") { + this.minute = "00"; + this.hour = String((initialDate.getHours() + 1) % 24).padStart(2, "0"); + } + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("h2", { text: "Create reminder" }); + + this.renderReminderRow(contentEl); + + new Setting(contentEl) + .setName("Details") + .setDesc("Optional longer message.") + .addTextArea((text) => { + text.setValue(this.body); + text.inputEl.rows = 3; + text.onChange((value) => { + this.body = value; + }); + }); + + new Setting(contentEl) + .setName("Channels") + .setDesc("Comma separated provider IDs or types. Leave empty to use all enabled providers.") + .addText((text) => text + .setValue(this.channels) + .onChange((value) => { + this.channels = value; + })); + + new Setting(contentEl) + .addButton((button) => button + .setButtonText("Create") + .setCta() + .onClick(async () => { + const dueAt = this.getSelectedDate(); + if (!this.title.trim() || !dueAt) { + new Notice("Title and valid due date are required."); + return; + } + + await this.onSubmit({ + title: this.title.trim(), + body: this.body.trim(), + dueAt: dueAt.toISOString(), + channels: this.channels.split(",").map((value) => value.trim()).filter(Boolean), + }); + this.close(); + })); + } + + private renderReminderRow(container: HTMLElement): void { + const setting = new Setting(container) + .setName("Reminder") + .setDesc("Text, date, and time."); + + setting.addText((text) => { + text.setPlaceholder("Reminder text"); + text.setValue(this.title); + text.onChange((value) => { + this.title = value; + if (!this.body || this.body === this.title) { + this.body = value; + } + }); + }); + + setting.addDropdown((dropdown) => { + for (const year of this.range(new Date().getFullYear(), new Date().getFullYear() + 3)) { + dropdown.addOption(String(year), String(year)); + } + dropdown.setValue(this.year); + dropdown.onChange((value) => { + this.year = value; + this.ensureValidDay(); + }); + }); + + setting.addDropdown((dropdown) => { + for (const month of this.range(1, 12)) { + const value = String(month).padStart(2, "0"); + dropdown.addOption(value, value); + } + dropdown.setValue(this.month); + dropdown.onChange((value) => { + this.month = value; + this.ensureValidDay(); + }); + }); + + setting.addDropdown((dropdown) => { + for (const day of this.range(1, 31)) { + const value = String(day).padStart(2, "0"); + dropdown.addOption(value, value); + } + dropdown.setValue(this.day); + dropdown.onChange((value) => { + this.day = value; + }); + }); + + setting.addDropdown((dropdown) => { + for (const hour of this.range(0, 23)) { + const value = String(hour).padStart(2, "0"); + dropdown.addOption(value, value); + } + dropdown.setValue(this.hour); + dropdown.onChange((value) => { + this.hour = value; + }); + }); + + setting.addDropdown((dropdown) => { + for (const minute of this.range(0, 55, 5)) { + const value = String(minute).padStart(2, "0"); + dropdown.addOption(value, value); + } + dropdown.setValue(this.minute); + dropdown.onChange((value) => { + this.minute = value; + }); + }); + } + + private getSelectedDate(): Date | null { + const date = new Date(`${this.year}-${this.month}-${this.day}T${this.hour}:${this.minute}:00`); + return Number.isNaN(date.getTime()) ? null : date; + } + + private ensureValidDay(): void { + const maxDay = new Date(Number(this.year), Number(this.month), 0).getDate(); + if (Number(this.day) > maxDay) { + this.day = String(maxDay).padStart(2, "0"); + } + } + + private range(from: number, to: number, step = 1): number[] { + const values: number[] = []; + for (let value = from; value <= to; value += step) { + values.push(value); + } + return values; + } +} diff --git a/src/reminders/ReminderParser.ts b/src/reminders/ReminderParser.ts new file mode 100644 index 0000000..6e71a12 --- /dev/null +++ b/src/reminders/ReminderParser.ts @@ -0,0 +1,183 @@ +import { ParsedReminderInput, ReminderPriority } from "../types"; +import { parseReminderDate } from "../utils/date"; + +const INLINE_PATTERN = /@remind\(([^)]+)\)/gi; +const BELL_PATTERN = /(?:\uD83D\uDD14|\[!\s*reminder\s*\])\s*(\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}(?::\d{2})?)?)/i; + +export class ReminderParser { + parseMarkdown(content: string, sourceFile?: string): ParsedReminderInput[] { + return [ + ...this.parseInline(content, sourceFile), + ...this.parseFrontmatter(content, sourceFile), + ...this.parseReminderBlocks(content, sourceFile), + ]; + } + + private parseInline(content: string, sourceFile?: string): ParsedReminderInput[] { + const reminders: ParsedReminderInput[] = []; + const lines = content.split(/\r?\n/); + + lines.forEach((line, index) => { + for (const match of line.matchAll(INLINE_PATTERN)) { + const args = match[1].split(",").map((part) => part.trim()).filter(Boolean); + const date = parseReminderDate(args[0]); + if (!date) { + continue; + } + + const rawTitle = line + .replace(INLINE_PATTERN, "") + .replace(/^[-*]\s+\[[ xX]\]\s+/, "") + .trim(); + + reminders.push({ + title: rawTitle || "Reminder", + body: rawTitle, + dueAt: date.toISOString(), + sourceFile, + sourceLine: index + 1, + channels: args.slice(1), + priority: this.parsePriority(args), + }); + } + + const bell = line.match(BELL_PATTERN); + if (bell) { + const date = parseReminderDate(bell[1]); + if (!date) { + return; + } + const title = line + .replace(BELL_PATTERN, "") + .replace(/^[-*]\s+\[[ xX]\]\s+/, "") + .trim(); + reminders.push({ + title: title || "Reminder", + body: title, + dueAt: date.toISOString(), + sourceFile, + sourceLine: index + 1, + channels: [], + priority: "normal", + }); + } + }); + + return reminders; + } + + private parseFrontmatter(content: string, sourceFile?: string): ParsedReminderInput[] { + const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!frontmatter) { + return []; + } + + return this.parseYamlLike(frontmatter[1], sourceFile, 1); + } + + private parseReminderBlocks(content: string, sourceFile?: string): ParsedReminderInput[] { + const reminders: ParsedReminderInput[] = []; + const pattern = /```reminder\r?\n([\s\S]*?)```/gi; + + for (const match of content.matchAll(pattern)) { + const line = content.slice(0, match.index).split(/\r?\n/).length; + reminders.push(...this.parseYamlLike(match[1], sourceFile, line)); + } + + return reminders; + } + + private parseYamlLike(input: string, sourceFile?: string, sourceLine?: number): ParsedReminderInput[] { + const objects = input.includes("reminders:") + ? this.parseReminderList(input) + : [this.parseKeyValueBlock(input)]; + + return objects.flatMap((item) => { + const at = item.at ?? item.date ?? item.dueAt; + if (!at) { + return []; + } + + const date = parseReminderDate(at); + if (!date) { + return []; + } + + const channels = (item.notify ?? item.channels ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + + return [{ + title: item.title || item.message || "Reminder", + body: item.message || item.body || item.title || "", + dueAt: date.toISOString(), + sourceFile, + sourceLine, + channels, + priority: this.normalizePriority(item.priority), + }]; + }); + } + + private parseReminderList(input: string): Record[] { + const items: Record[] = []; + let current: Record | null = null; + + for (const line of input.split(/\r?\n/)) { + const itemStart = line.match(/^\s*-\s+(\w+):\s*(.+)$/); + if (itemStart) { + current = { [itemStart[1]]: itemStart[2].trim() }; + items.push(current); + continue; + } + + const pair = line.match(/^\s+(\w+):\s*(.+)$/); + if (pair && current) { + current[pair[1]] = pair[2].trim(); + } + } + + return items; + } + + private parseKeyValueBlock(input: string): Record { + const values: Record = {}; + const lines = input.split(/\r?\n/); + let inReminderObject = false; + let lastListKey = ""; + + for (const line of lines) { + if (/^\s*reminder:\s*$/.test(line)) { + inReminderObject = true; + continue; + } + + const listItem = line.match(/^\s*-\s+(.+)$/); + if (listItem && lastListKey) { + values[lastListKey] = [values[lastListKey], listItem[1].trim()].filter(Boolean).join(","); + continue; + } + + const pair = line.match(/^\s*(\w+):\s*(.*)$/); + if (pair && (inReminderObject || !line.startsWith(" "))) { + values[pair[1]] = pair[2].trim(); + lastListKey = pair[1]; + } + } + + return values; + } + + private parsePriority(values: string[]): ReminderPriority { + const priority = values.find((value) => /^(low|normal|high)$/i.test(value)); + return this.normalizePriority(priority); + } + + private normalizePriority(value?: string): ReminderPriority { + if (value === "low" || value === "high" || value === "normal") { + return value; + } + return "normal"; + } +} diff --git a/src/reminders/ReminderScheduler.ts b/src/reminders/ReminderScheduler.ts new file mode 100644 index 0000000..db6b1bd --- /dev/null +++ b/src/reminders/ReminderScheduler.ts @@ -0,0 +1,95 @@ +import { Notice, TFile, Vault } from "obsidian"; +import { NotificationProvider, ProviderType, Reminder } from "../types"; +import { ReminderParser } from "./ReminderParser"; +import { ReminderStore } from "./ReminderStore"; + +export class ReminderScheduler { + private timer: number | null = null; + private parser = new ReminderParser(); + + constructor( + private vault: Vault, + private store: ReminderStore, + private providers: Map, + ) {} + + start(): void { + this.stop(); + const interval = Math.max(10, this.store.getSettings().checkIntervalSeconds) * 1000; + this.timer = window.setInterval(() => { + void this.tick(); + }, interval); + void this.tick(); + } + + stop(): void { + if (this.timer !== null) { + window.clearInterval(this.timer); + this.timer = null; + } + } + + async tick(): Promise { + for (const reminder of this.store.getDue()) { + await this.sendReminder(reminder); + } + } + + async scanFile(file: TFile): Promise { + const content = await this.vault.read(file); + const parsed = this.parser.parseMarkdown(content, file.path); + return this.store.addMany(parsed); + } + + async scanVault(): Promise { + let count = 0; + const files = this.vault.getMarkdownFiles(); + for (const file of files) { + count += await this.scanFile(file); + } + return count; + } + + private async sendReminder(reminder: Reminder): Promise { + const enabledProviders = this.store.getSettings().notificationProviders.filter((provider) => provider.enabled); + const configs = enabledProviders.filter((provider) => + reminder.channels.includes(provider.id) || reminder.channels.includes(provider.type) + ); + + const selected = configs.length + ? configs + : reminder.channels.length === 0 + ? enabledProviders + : []; + + if (!selected.length) { + const message = reminder.channels.length + ? `Reminder has no matching enabled provider: ${reminder.title}` + : `Reminder has no enabled providers: ${reminder.title}`; + new Notice(message); + await this.store.markFailed(reminder.id, message); + return; + } + + try { + for (const config of selected) { + const provider = this.providers.get(config.type); + if (!provider) { + throw new Error(`Unknown provider: ${config.type}`); + } + await provider.send({ + title: reminder.title, + message: reminder.body || reminder.title, + sourceFile: reminder.sourceFile, + sourceLine: reminder.sourceLine, + priority: reminder.priority, + dueAt: reminder.dueAt, + }, config); + } + await this.store.markSent(reminder.id); + } catch (error) { + await this.store.markFailed(reminder.id, error); + new Notice(`Reminder failed: ${reminder.title}`); + } + } +} diff --git a/src/reminders/ReminderStore.ts b/src/reminders/ReminderStore.ts new file mode 100644 index 0000000..1f1b5ef --- /dev/null +++ b/src/reminders/ReminderStore.ts @@ -0,0 +1,150 @@ +import { Plugin } from "obsidian"; +import { ParsedReminderInput, PluginData, PluginSettings, Reminder } from "../types"; +import { createReminderId } from "../utils/ids"; +import { createDefaultSettings } from "../settings/templates"; + +export class ReminderStore { + private data: PluginData = { + settings: createDefaultSettings(), + reminders: [], + }; + + constructor(private plugin: Plugin) {} + + async load(): Promise { + const loaded = await this.plugin.loadData() as Partial | null; + const defaults = createDefaultSettings(); + this.data = { + settings: { + ...defaults, + ...(loaded?.settings ?? {}), + notificationProviders: loaded?.settings?.notificationProviders ?? defaults.notificationProviders, + }, + reminders: loaded?.reminders ?? [], + }; + } + + async save(): Promise { + await this.plugin.saveData(this.data); + } + + getSettings(): PluginSettings { + return this.data.settings; + } + + async updateSettings(settings: PluginSettings): Promise { + this.data.settings = settings; + await this.save(); + } + + getAll(): Reminder[] { + return [...this.data.reminders].sort((a, b) => a.dueAt.localeCompare(b.dueAt)); + } + + getDue(now = new Date()): Reminder[] { + return this.data.reminders.filter((reminder) => + (reminder.status === "pending" || reminder.status === "snoozed" || reminder.status === "failed") + && new Date(reminder.dueAt).getTime() <= now.getTime() + ); + } + + async add(input: ParsedReminderInput, defaultChannels: string[]): Promise { + const now = new Date().toISOString(); + const reminder: Reminder = { + id: createReminderId([input.sourceFile ?? "manual", String(input.sourceLine ?? ""), input.title, input.dueAt]), + title: input.title, + body: input.body, + dueAt: input.dueAt, + sourceFile: input.sourceFile, + sourceLine: input.sourceLine, + completed: false, + channels: input.channels.length ? input.channels : defaultChannels, + priority: input.priority ?? "normal", + status: "pending", + createdAt: now, + updatedAt: now, + }; + + const existing = this.data.reminders.find((item) => item.id === reminder.id); + if (existing) { + Object.assign(existing, { + ...reminder, + status: existing.status, + completed: existing.completed, + createdAt: existing.createdAt, + updatedAt: now, + }); + await this.save(); + return existing; + } + + this.data.reminders.push(reminder); + await this.save(); + return reminder; + } + + async addMany(inputs: ParsedReminderInput[]): Promise { + let count = 0; + for (const input of inputs) { + await this.add(input, []); + count += 1; + } + return count; + } + + async markSent(id: string): Promise { + const reminder = this.find(id); + if (!reminder) { + return; + } + + 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(); + } + + async markFailed(id: string, error: unknown): Promise { + const reminder = this.find(id); + if (!reminder) { + return; + } + + reminder.status = "failed"; + reminder.lastError = error instanceof Error ? error.message : String(error); + reminder.updatedAt = new Date().toISOString(); + await this.save(); + } + + async complete(id: string): Promise { + const reminder = this.find(id); + if (!reminder) { + return; + } + reminder.completed = true; + reminder.status = "done"; + reminder.updatedAt = new Date().toISOString(); + await this.save(); + } + + async snooze(id: string, minutes: number): Promise { + const reminder = this.find(id); + if (!reminder) { + return; + } + reminder.dueAt = new Date(Date.now() + minutes * 60_000).toISOString(); + reminder.status = "snoozed"; + reminder.updatedAt = new Date().toISOString(); + await this.save(); + } + + async remove(id: string): Promise { + this.data.reminders = this.data.reminders.filter((reminder) => reminder.id !== id); + await this.save(); + } + + private find(id: string): Reminder | undefined { + return this.data.reminders.find((reminder) => reminder.id === id); + } +} diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts new file mode 100644 index 0000000..8d3b4c6 --- /dev/null +++ b/src/settings/SettingsTab.ts @@ -0,0 +1,167 @@ +import { App, Notice, PluginSettingTab, Setting } from "obsidian"; +import ReminderNotifierPlugin from "../main"; +import { NotificationProviderConfig, ProviderType } from "../types"; +import { cloneTemplate, PROVIDER_TEMPLATES } from "./templates"; + +export class ReminderNotifierSettingTab extends PluginSettingTab { + constructor(app: App, private plugin: ReminderNotifierPlugin) { + super(app, plugin); + } + + display(): void { + const { containerEl } = this; + const settings = this.plugin.store.getSettings(); + containerEl.empty(); + + containerEl.createEl("h2", { text: "Reminder Notifier" }); + + new Setting(containerEl) + .setName("Scan vault on startup") + .addToggle((toggle) => toggle + .setValue(settings.scanVaultOnStartup) + .onChange(async (value) => { + settings.scanVaultOnStartup = value; + await this.plugin.store.updateSettings(settings); + })); + + new Setting(containerEl) + .setName("Scan changed notes") + .addToggle((toggle) => toggle + .setValue(settings.scanOnFileChange) + .onChange(async (value) => { + settings.scanOnFileChange = value; + await this.plugin.store.updateSettings(settings); + })); + + new Setting(containerEl) + .setName("Check interval") + .setDesc("Seconds between reminder checks.") + .addText((text) => text + .setValue(String(settings.checkIntervalSeconds)) + .onChange(async (value) => { + settings.checkIntervalSeconds = Math.max(10, Number(value) || 60); + await this.plugin.store.updateSettings(settings); + this.plugin.restartScheduler(); + })); + + new Setting(containerEl) + .setName("Default channels") + .setDesc("Comma separated provider IDs or types. Empty means every enabled provider.") + .addText((text) => text + .setValue(settings.defaultChannels.join(", ")) + .onChange(async (value) => { + settings.defaultChannels = value.split(",").map((part) => part.trim()).filter(Boolean); + await this.plugin.store.updateSettings(settings); + })); + + new Setting(containerEl) + .setName("After send") + .addDropdown((dropdown) => dropdown + .addOption("mark-sent", "Mark as sent") + .addOption("mark-done", "Mark as done") + .addOption("keep", "Keep pending") + .setValue(settings.afterSend) + .onChange(async (value) => { + settings.afterSend = value as typeof settings.afterSend; + await this.plugin.store.updateSettings(settings); + })); + + containerEl.createEl("h3", { text: "Notification providers" }); + + let selectedTemplate: ProviderType = PROVIDER_TEMPLATES[0].type; + new Setting(containerEl) + .setName("Add from template") + .addDropdown((dropdown) => { + for (const template of PROVIDER_TEMPLATES) { + dropdown.addOption(template.type, template.name); + } + dropdown.onChange((value) => { + selectedTemplate = value as ProviderType; + }); + }) + .addButton((button) => button + .setButtonText("Add provider") + .onClick(async () => { + const provider = cloneTemplate(selectedTemplate); + if (!provider) { + return; + } + settings.notificationProviders.push(provider); + await this.plugin.store.updateSettings(settings); + this.display(); + })); + + for (const provider of settings.notificationProviders) { + this.renderProvider(provider); + } + } + + private renderProvider(provider: NotificationProviderConfig): void { + const settings = this.plugin.store.getSettings(); + const wrapper = this.containerEl.createDiv({ cls: "reminder-notifier-provider" }); + wrapper.createEl("h4", { text: provider.name }); + + new Setting(wrapper) + .setName("Enabled") + .addToggle((toggle) => toggle + .setValue(provider.enabled) + .onChange(async (value) => { + provider.enabled = value; + await this.plugin.store.updateSettings(settings); + })); + + new Setting(wrapper) + .setName("ID") + .setDesc("Use this value in @remind(..., provider-id).") + .addText((text) => text + .setValue(provider.id) + .onChange(async (value) => { + provider.id = value.trim(); + await this.plugin.store.updateSettings(settings); + })); + + new Setting(wrapper) + .setName("Name") + .addText((text) => text + .setValue(provider.name) + .onChange(async (value) => { + provider.name = value; + await this.plugin.store.updateSettings(settings); + })); + + new Setting(wrapper) + .setName("Config JSON") + .setDesc("Secrets are stored locally in Obsidian plugin data.") + .addTextArea((text) => { + text.inputEl.addClass("reminder-notifier-json"); + text.setValue(JSON.stringify(provider.config, null, 2)); + text.onChange(async (value) => { + try { + provider.config = JSON.parse(value) as Record; + await this.plugin.store.updateSettings(settings); + } catch { + // Allow editing incomplete JSON without immediately discarding input. + } + }); + }); + + new Setting(wrapper) + .addButton((button) => button + .setButtonText("Test notification") + .onClick(async () => { + try { + await this.plugin.testProvider(provider); + new Notice("Test notification sent."); + } catch (error) { + new Notice(error instanceof Error ? error.message : String(error)); + } + })) + .addButton((button) => button + .setButtonText("Delete") + .onClick(async () => { + settings.notificationProviders = settings.notificationProviders.filter((item) => item !== provider); + await this.plugin.store.updateSettings(settings); + this.display(); + })); + } +} diff --git a/src/settings/templates.ts b/src/settings/templates.ts new file mode 100644 index 0000000..72d5c22 --- /dev/null +++ b/src/settings/templates.ts @@ -0,0 +1,92 @@ +import { NotificationProviderConfig, PluginSettings } from "../types"; + +export const PROVIDER_TEMPLATES: NotificationProviderConfig[] = [ + { + id: "local", + type: "local", + name: "Local notice", + enabled: true, + config: {}, + }, + { + id: "telegram", + type: "telegram", + name: "Telegram Bot", + enabled: false, + config: { + botToken: "123456:ABCDEF_your_bot_token", + chatId: "123456789", + parseMode: "Markdown", + }, + }, + { + id: "ntfy", + type: "ntfy", + name: "ntfy.sh", + enabled: false, + config: { + serverUrl: "https://ntfy.sh", + topic: "my-obsidian-reminders", + token: "", + priority: 3, + tags: ["calendar", "obsidian"], + }, + }, + { + id: "discord", + type: "discord", + name: "Discord Webhook", + enabled: false, + config: { + webhookUrl: "https://discord.com/api/webhooks/...", + username: "Obsidian Reminder", + }, + }, + { + id: "webhook", + type: "webhook", + name: "Generic Webhook", + enabled: false, + config: { + method: "POST", + url: "https://example.com/reminder", + headers: { + "Content-Type": "application/json", + }, + bodyTemplate: { + title: "{{title}}", + message: "{{message}}", + dueAt: "{{dueAt}}", + sourceFile: "{{sourceFile}}", + }, + }, + }, +]; + +export const DEFAULT_SETTINGS: PluginSettings = { + defaultChannels: [], + scanVaultOnStartup: true, + scanOnFileChange: true, + checkIntervalSeconds: 60, + afterSend: "mark-sent", + reminderSyntax: "@remind(YYYY-MM-DD HH:mm, provider)", + notificationProviders: [PROVIDER_TEMPLATES[0]], +}; + +export function createDefaultSettings(): PluginSettings { + return JSON.parse(JSON.stringify(DEFAULT_SETTINGS)) as PluginSettings; +} + +export function cloneTemplate(type: string): NotificationProviderConfig | null { + const template = PROVIDER_TEMPLATES.find((item) => item.type === type); + if (!template) { + return null; + } + + const suffix = Date.now().toString(36); + return { + ...template, + id: `${template.type}-${suffix}`, + config: JSON.parse(JSON.stringify(template.config)) as Record, + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f0632a0 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,69 @@ +export type ReminderStatus = "pending" | "sent" | "failed" | "snoozed" | "cancelled" | "done"; +export type ReminderPriority = "low" | "normal" | "high"; +export type ProviderType = "local" | "telegram" | "ntfy" | "webhook" | "discord"; + +export interface Reminder { + id: string; + title: string; + body?: string; + dueAt: string; + sourceFile?: string; + sourceLine?: number; + completed: boolean; + channels: string[]; + priority: ReminderPriority; + status: ReminderStatus; + createdAt: string; + updatedAt: string; + lastError?: string; +} + +export interface NotificationPayload { + title: string; + message: string; + sourceFile?: string; + sourceLine?: number; + priority?: ReminderPriority; + url?: string; + dueAt: string; +} + +export interface NotificationProviderConfig { + id: string; + type: ProviderType; + name: string; + enabled: boolean; + config: Record; +} + +export interface PluginSettings { + defaultChannels: string[]; + scanVaultOnStartup: boolean; + scanOnFileChange: boolean; + checkIntervalSeconds: number; + afterSend: "keep" | "mark-sent" | "mark-done"; + reminderSyntax: string; + notificationProviders: NotificationProviderConfig[]; +} + +export interface PluginData { + settings: PluginSettings; + reminders: Reminder[]; +} + +export interface ParsedReminderInput { + title: string; + body?: string; + dueAt: string; + sourceFile?: string; + sourceLine?: number; + channels: string[]; + priority?: ReminderPriority; +} + +export interface NotificationProvider { + id: ProviderType; + name: string; + send(payload: NotificationPayload, config: NotificationProviderConfig): Promise; + test(config: NotificationProviderConfig): Promise; +} diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..f7082bc --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,68 @@ +const WEEKDAYS = new Map([ + ["sunday", 0], + ["monday", 1], + ["tuesday", 2], + ["wednesday", 3], + ["thursday", 4], + ["friday", 5], + ["saturday", 6], +]); + +export function parseReminderDate(input: string, now = new Date()): Date | null { + const value = input.trim(); + if (!value) { + return null; + } + + const relative = value.match(/^\+(\d+)\s*(m|min|minute|minutes|h|hour|hours|d|day|days)$/i); + if (relative) { + const amount = Number(relative[1]); + const unit = relative[2].toLowerCase(); + const millis = unit.startsWith("m") + ? amount * 60_000 + : unit.startsWith("h") + ? amount * 3_600_000 + : amount * 86_400_000; + return new Date(now.getTime() + millis); + } + + const tomorrow = value.match(/^tomorrow(?:\s+(\d{1,2})(?::(\d{2}))?)?$/i); + if (tomorrow) { + const date = new Date(now); + date.setDate(date.getDate() + 1); + date.setHours(Number(tomorrow[1] ?? 9), Number(tomorrow[2] ?? 0), 0, 0); + return date; + } + + const nextWeekday = value.match(/^next\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)(?:\s+(\d{1,2})(?::(\d{2}))?)?$/i); + if (nextWeekday) { + const wanted = WEEKDAYS.get(nextWeekday[1].toLowerCase()); + if (wanted === undefined) { + return null; + } + const date = new Date(now); + const days = (wanted - date.getDay() + 7) || 7; + date.setDate(date.getDate() + days); + date.setHours(Number(nextWeekday[2] ?? 9), Number(nextWeekday[3] ?? 0), 0, 0); + return date; + } + + const simple = value.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{1,2})(?::(\d{2}))?)?$/); + if (simple) { + const hours = simple[2] ?? "9"; + const minutes = simple[3] ?? "0"; + const date = new Date(`${simple[1]}T${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}:00`); + return Number.isNaN(date.getTime()) ? null : date; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +export function formatDateTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} diff --git a/src/utils/ids.ts b/src/utils/ids.ts new file mode 100644 index 0000000..05132f0 --- /dev/null +++ b/src/utils/ids.ts @@ -0,0 +1,8 @@ +export function createReminderId(parts: string[]): string { + const source = parts.join("|"); + let hash = 0; + for (let index = 0; index < source.length; index += 1) { + hash = ((hash << 5) - hash + source.charCodeAt(index)) | 0; + } + return `rem_${Math.abs(hash).toString(36)}`; +} diff --git a/src/utils/template.ts b/src/utils/template.ts new file mode 100644 index 0000000..4ded5d8 --- /dev/null +++ b/src/utils/template.ts @@ -0,0 +1,34 @@ +import { NotificationPayload } from "../types"; + +export function renderTemplate(value: unknown, payload: NotificationPayload): unknown { + if (typeof value === "string") { + return Object.entries({ + "{{title}}": payload.title, + "{{message}}": payload.message, + "{{body}}": payload.message, + "{{file}}": payload.sourceFile ?? "", + "{{sourceFile}}": payload.sourceFile ?? "", + "{{sourceLine}}": payload.sourceLine?.toString() ?? "", + "{{priority}}": payload.priority ?? "normal", + "{{dueAt}}": payload.dueAt, + "{{time}}": payload.dueAt, + "{{url}}": payload.url ?? "", + }).reduce((result, [token, replacement]) => result.split(token).join(replacement), value); + } + + if (Array.isArray(value)) { + return value.map((item) => renderTemplate(item, payload)); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [key, renderTemplate(item, payload)]) + ); + } + + return value; +} + +export function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..2d4b2d6 --- /dev/null +++ b/styles.css @@ -0,0 +1,39 @@ +.reminder-notifier-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.reminder-notifier-item { + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 10px; +} + +.reminder-notifier-item-title { + font-weight: 600; +} + +.reminder-notifier-item-meta { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + margin-top: 4px; +} + +.reminder-notifier-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.reminder-notifier-provider { + border-top: 1px solid var(--background-modifier-border); + margin-top: 14px; + padding-top: 14px; +} + +.reminder-notifier-json { + min-height: 110px; + width: 100%; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d92440c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2018", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": [ + "DOM", + "ES2018" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..708016d --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.5.0" +}