From a88ed8da755a50213577e95620d54deee553f75c Mon Sep 17 00:00:00 2001 From: dinlo Date: Sat, 27 Jun 2026 15:12:40 +0800 Subject: [PATCH] Add reminder management improvements --- .gitignore | 3 +- README.md | 5 +- main.js | 9 ++- package.json | 4 +- scripts/create-release.ps1 | 25 ++++++ scripts/run-tests.mjs | 16 ++++ src/main.ts | 40 +++++++-- src/reminders/ReminderListModal.ts | 9 +++ src/reminders/ReminderModal.ts | 41 ++++++++-- src/reminders/ReminderParser.ts | 33 ++++++-- src/reminders/ReminderScheduler.ts | 27 +++++++ src/reminders/ReminderStore.ts | 67 +++++++++++++++- src/reminders/ReminderView.ts | 125 +++++++++++++++++++++++++++++ src/settings/SettingsTab.ts | 10 +++ src/settings/templates.ts | 1 + src/types.ts | 10 +++ src/utils/date.ts | 26 ++++++ styles.css | 8 ++ tests/date.test.ts | 24 ++++++ 19 files changed, 455 insertions(+), 28 deletions(-) create mode 100644 scripts/create-release.ps1 create mode 100644 scripts/run-tests.mjs create mode 100644 src/reminders/ReminderView.ts create mode 100644 tests/date.test.ts diff --git a/.gitignore b/.gitignore index 94eaa40..39d3f68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ +dist-tests/ +dist/ .DS_Store *.log npm-debug.log* @@ -6,4 +8,3 @@ yarn-debug.log* yarn-error.log* .env .env.* - diff --git a/README.md b/README.md index 4797082..1daffba 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ The list lets you snooze a reminder for 15 minutes, mark it done, or delete it. - [ ] Check server @remind(2026-07-01 09:00, ntfy) - [ ] Take a break @remind(+2h, local) - [ ] Weekly planning @remind(next monday 14:30, telegram, ntfy) +- [ ] Standup @remind(2026-07-01 09:00, ntfy, daily) ``` If no channel is provided, scanned reminders are sent through all enabled notification providers. @@ -122,6 +123,7 @@ title: Make a backup at: 2026-07-01 23:00 notify: ntfy priority: high +repeat: weekly ``` ```` @@ -265,6 +267,7 @@ discord - [ ] Проверить сервер @remind(2026-07-01 09:00, ntfy) - [ ] Сделать перерыв @remind(+2h, local) - [ ] Планирование недели @remind(next monday 14:30, telegram, ntfy) +- [ ] Стендап @remind(2026-07-01 09:00, ntfy, daily) ``` Если канал не указан, найденное в заметке напоминание отправляется во все включённые провайдеры уведомлений. @@ -290,6 +293,7 @@ title: Сделать бэкап at: 2026-07-01 23:00 notify: ntfy priority: high +repeat: weekly ``` ```` @@ -348,4 +352,3 @@ npm run build ``` The generated production bundle is `main.js`. - diff --git a/main.js b/main.js index 977cfad..4af989c 100644 --- a/main.js +++ b/main.js @@ -2,8 +2,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}* +var q=Object.defineProperty;var ee=Object.getOwnPropertyDescriptor;var te=Object.getOwnPropertyNames;var ie=Object.prototype.hasOwnProperty;var ne=(o,t)=>{for(var e in t)q(o,e,{get:t[e],enumerable:!0})},re=(o,t,e,i)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of te(t))!ie.call(o,r)&&r!==e&&q(o,r,{get:()=>t[r],enumerable:!(i=ee(t,r))||i.enumerable});return o};var se=o=>re(q({},"__esModule",{value:!0}),o);var ce={};ne(ce,{default:()=>B});module.exports=se(ce);var h=require("obsidian");var v=require("obsidian"),S=class extends v.Modal{constructor(e,i,r,n="",s){var d,c,l,p,g,y;super(e);this.defaultChannels=i;this.onSubmit=r;this.existing=s;this.title="";this.body="";this.year="";this.month="";this.day="";this.hour="";this.minute="";this.channels="";this.repeatFrequency="none";this.repeatInterval="1";let a=s?new Date(s.dueAt):new Date(Date.now()+60*60*1e3);this.title=(d=s==null?void 0:s.title)!=null?d:n,this.body=(c=s==null?void 0:s.body)!=null?c:n,this.channels=s?s.channels.join(", "):i.join(", "),this.repeatFrequency=(p=(l=s==null?void 0:s.repeat)==null?void 0:l.frequency)!=null?p:"none",this.repeatInterval=String((y=(g=s==null?void 0:s.repeat)==null?void 0:g.interval)!=null?y:1),this.year=String(a.getFullYear()),this.month=String(a.getMonth()+1).padStart(2,"0"),this.day=String(a.getDate()).padStart(2,"0"),this.hour=String(a.getHours()).padStart(2,"0"),this.minute=String(Math.ceil(a.getMinutes()/5)*5).padStart(2,"0"),this.minute==="60"&&(this.minute="00",this.hour=String((a.getHours()+1)%24).padStart(2,"0"))}onOpen(){let{contentEl:e}=this;e.empty(),e.createEl("h2",{text:this.existing?"Edit reminder":"Create reminder"}),this.renderReminderRow(e),new v.Setting(e).setName("Details").setDesc("Optional longer message.").addTextArea(i=>{i.setValue(this.body),i.inputEl.rows=3,i.onChange(r=>{this.body=r})}),new v.Setting(e).setName("Channels").setDesc("Comma separated provider IDs or types. Leave empty to use all enabled providers.").addText(i=>i.setValue(this.channels).onChange(r=>{this.channels=r})),new v.Setting(e).setName("Repeat").addDropdown(i=>i.addOption("none","None").addOption("daily","Daily").addOption("weekly","Weekly").addOption("monthly","Monthly").setValue(this.repeatFrequency).onChange(r=>{this.repeatFrequency=r})).addText(i=>i.setPlaceholder("Interval").setValue(this.repeatInterval).onChange(r=>{this.repeatInterval=r})),new v.Setting(e).addButton(i=>i.setButtonText(this.existing?"Save":"Create").setCta().onClick(async()=>{let r=this.getSelectedDate();if(!this.title.trim()||!r){new v.Notice("Title and valid due date are required.");return}await this.onSubmit({title:this.title.trim(),body:this.body.trim(),dueAt:r.toISOString(),channels:this.channels.split(",").map(n=>n.trim()).filter(Boolean),repeat:{frequency:this.repeatFrequency,interval:Math.max(1,Number(this.repeatInterval)||1)}}),this.close()}))}renderReminderRow(e){let i=new v.Setting(e).setName("Reminder").setDesc("Text, date, and time.");i.addText(r=>{r.setPlaceholder("Reminder text"),r.setValue(this.title),r.onChange(n=>{this.title=n,(!this.body||this.body===this.title)&&(this.body=n)})}),i.addDropdown(r=>{for(let n of this.range(new Date().getFullYear(),new Date().getFullYear()+3))r.addOption(String(n),String(n));r.setValue(this.year),r.onChange(n=>{this.year=n,this.ensureValidDay()})}),i.addDropdown(r=>{for(let n of this.range(1,12)){let s=String(n).padStart(2,"0");r.addOption(s,s)}r.setValue(this.month),r.onChange(n=>{this.month=n,this.ensureValidDay()})}),i.addDropdown(r=>{for(let n of this.range(1,31)){let s=String(n).padStart(2,"0");r.addOption(s,s)}r.setValue(this.day),r.onChange(n=>{this.day=n})}),i.addDropdown(r=>{for(let n of this.range(0,23)){let s=String(n).padStart(2,"0");r.addOption(s,s)}r.setValue(this.hour),r.onChange(n=>{this.hour=n})}),i.addDropdown(r=>{for(let n of this.range(0,55,5)){let s=String(n).padStart(2,"0");r.addOption(s,s)}r.setValue(this.minute),r.onChange(n=>{this.minute=n})})}getSelectedDate(){let e=new Date(`${this.year}-${this.month}-${this.day}T${this.hour}:${this.minute}:00`);return Number.isNaN(e.getTime())?null:e}ensureValidDay(){let e=new Date(Number(this.year),Number(this.month),0).getDate();Number(this.day)>e&&(this.day=String(e).padStart(2,"0"))}range(e,i,r=1){let n=[];for(let s=e;s<=i;s+=r)n.push(s);return n}};var N=require("obsidian");var ae=new Map([["sunday",0],["monday",1],["tuesday",2],["wednesday",3],["thursday",4],["friday",5],["saturday",6]]);function D(o,t=new Date){var d,c,l,p,g,y;let e=o.trim();if(!e)return null;let i=e.match(/^\+(\d+)\s*(m|min|minute|minutes|h|hour|hours|d|day|days)$/i);if(i){let u=Number(i[1]),w=i[2].toLowerCase(),R=w.startsWith("m")?u*6e4:w.startsWith("h")?u*36e5:u*864e5;return new Date(t.getTime()+R)}let r=e.match(/^tomorrow(?:\s+(\d{1,2})(?::(\d{2}))?)?$/i);if(r){let u=new Date(t);return u.setDate(u.getDate()+1),u.setHours(Number((d=r[1])!=null?d:9),Number((c=r[2])!=null?c:0),0,0),u}let n=e.match(/^next\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)(?:\s+(\d{1,2})(?::(\d{2}))?)?$/i);if(n){let u=ae.get(n[1].toLowerCase());if(u===void 0)return null;let w=new Date(t),R=u-w.getDay()+7||7;return w.setDate(w.getDate()+R),w.setHours(Number((l=n[2])!=null?l:9),Number((p=n[3])!=null?p:0),0,0),w}let s=e.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{1,2})(?::(\d{2}))?)?$/);if(s){let u=(g=s[2])!=null?g:"9",w=(y=s[3])!=null?y:"0",R=new Date(`${s[1]}T${u.padStart(2,"0")}:${w.padStart(2,"0")}:00`);return Number.isNaN(R.getTime())?null:R}let a=new Date(e);return Number.isNaN(a.getTime())?null:a}function j(o){let t=new Date(o);return Number.isNaN(t.getTime())?o:t.toLocaleString()}function U(o,t,e=new Date){if(!t||t.frequency==="none")return null;let i=Math.max(1,t.interval||1),r=new Date(o);if(Number.isNaN(r.getTime()))return null;for(;r.getTime()<=e.getTime();)t.frequency==="daily"?r.setDate(r.getDate()+i):t.frequency==="weekly"?r.setDate(r.getDate()+7*i):t.frequency==="monthly"&&r.setMonth(r.getMonth()+i);return r.toISOString()}var W=/@remind\(([^)]+)\)/gi,Y=/(?:\uD83D\uDD14|\[!\s*reminder\s*\])\s*(\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}(?::\d{2})?)?)/i,A=class{parseMarkdown(t,e){return[...this.parseInline(t,e),...this.parseFrontmatter(t,e),...this.parseReminderBlocks(t,e)]}parseInline(t,e){let i=[];return t.split(/\r?\n/).forEach((n,s)=>{for(let d of n.matchAll(W)){let c=d[1].split(",").map(g=>g.trim()).filter(Boolean),l=D(c[0]);if(!l)continue;let p=n.replace(W,"").replace(/^[-*]\s+\[[ xX]\]\s+/,"").trim();i.push({title:p||"Reminder",body:p,dueAt:l.toISOString(),sourceFile:e,sourceLine:s+1,channels:this.parseChannels(c.slice(1)),priority:this.parsePriority(c),repeat:this.parseRepeat(c)})}let a=n.match(Y);if(a){let d=D(a[1]);if(!d)return;let c=n.replace(Y,"").replace(/^[-*]\s+\[[ xX]\]\s+/,"").trim();i.push({title:c||"Reminder",body:c,dueAt:d.toISOString(),sourceFile:e,sourceLine:s+1,channels:[],priority:"normal",repeat:void 0})}}),i}parseFrontmatter(t,e){let i=t.match(/^---\r?\n([\s\S]*?)\r?\n---/);return i?this.parseYamlLike(i[1],e,1):[]}parseReminderBlocks(t,e){let i=[],r=/```reminder\r?\n([\s\S]*?)```/gi;for(let n of t.matchAll(r)){let s=t.slice(0,n.index).split(/\r?\n/).length;i.push(...this.parseYamlLike(n[1],e,s))}return i}parseYamlLike(t,e,i){return(t.includes("reminders:")?this.parseReminderList(t):[this.parseKeyValueBlock(t)]).flatMap(n=>{var c,l,p,g,y;let s=(l=(c=n.at)!=null?c:n.date)!=null?l:n.dueAt;if(!s)return[];let a=D(s);if(!a)return[];let d=((g=(p=n.notify)!=null?p:n.channels)!=null?g:"").split(",").map(u=>u.trim()).filter(Boolean);return[{title:n.title||n.message||"Reminder",body:n.message||n.body||n.title||"",dueAt:a.toISOString(),sourceFile:e,sourceLine:i,channels:d,priority:this.normalizePriority(n.priority),repeat:this.parseRepeat([(y=n.repeat)!=null?y:""])}]})}parseReminderList(t){let e=[],i=null;for(let r of t.split(/\r?\n/)){let n=r.match(/^\s*-\s+(\w+):\s*(.+)$/);if(n){i={[n[1]]:n[2].trim()},e.push(i);continue}let s=r.match(/^\s+(\w+):\s*(.+)$/);s&&i&&(i[s[1]]=s[2].trim())}return e}parseKeyValueBlock(t){let e={},i=t.split(/\r?\n/),r=!1,n="";for(let s of i){if(/^\s*reminder:\s*$/.test(s)){r=!0;continue}let a=s.match(/^\s*-\s+(.+)$/);if(a&&n){e[n]=[e[n],a[1].trim()].filter(Boolean).join(",");continue}let d=s.match(/^\s*(\w+):\s*(.*)$/);d&&(r||!s.startsWith(" "))&&(e[d[1]]=d[2].trim(),n=d[1])}return e}parsePriority(t){let e=t.find(i=>/^(low|normal|high)$/i.test(i));return this.normalizePriority(e)}parseChannels(t){return t.filter(e=>!/^(low|normal|high)$/i.test(e)&&!/^(daily|weekly|monthly)(?::\d+)?$/i.test(e))}parseRepeat(t){let e=t.find(n=>/^(daily|weekly|monthly)(?::\d+)?$/i.test(n));if(!e)return;let[i,r]=e.toLowerCase().split(":");return{frequency:i,interval:Math.max(1,Number(r)||1)}}normalizePriority(t){return t==="low"||t==="high"||t==="normal"?t:"normal"}};var O=class{constructor(t,e,i,r=()=>{}){this.vault=t;this.store=e;this.providers=i;this.onChange=r;this.timer=null;this.parser=new A}start(){this.stop();let t=Math.max(10,this.store.getSettings().checkIntervalSeconds)*1e3;this.timer=window.setInterval(()=>{this.tick()},t),this.tick()}stop(){this.timer!==null&&(window.clearInterval(this.timer),this.timer=null)}async tick(){for(let t of this.store.getDue())await this.sendReminder(t)}async scanFile(t){let e=await this.vault.read(t),i=this.parser.parseMarkdown(e,t.path);return this.store.addMany(i)}async scanVault(){let t=0,e=this.vault.getMarkdownFiles();for(let i of e)t+=await this.scanFile(i);return t}async sendReminder(t){let e=this.store.getSettings().notificationProviders.filter(n=>n.enabled),i=e.filter(n=>t.channels.includes(n.id)||t.channels.includes(n.type)),r=i.length?i:t.channels.length===0?e:[];if(!r.length){let n=t.channels.length?`Reminder has no matching enabled provider: ${t.title}`:`Reminder has no enabled providers: ${t.title}`;new N.Notice(n),await this.store.markFailed(t.id,n),this.onChange();return}try{for(let n of r){let s=this.providers.get(n.type);if(!s)throw new Error(`Unknown provider: ${n.type}`);await s.send({title:t.title,message:t.body||t.title,sourceFile:t.sourceFile,sourceLine:t.sourceLine,priority:t.priority,dueAt:t.dueAt},n)}await this.completeSourceTask(t),await this.store.markSent(t.id),this.onChange()}catch(n){await this.store.markFailed(t.id,n),this.onChange(),new N.Notice(`Reminder failed: ${t.title}`)}}async completeSourceTask(t){if(!this.store.getSettings().markTaskCompleteOnSend||t.repeat||!t.sourceFile||!t.sourceLine)return;let e=this.vault.getAbstractFileByPath(t.sourceFile);if(!(e instanceof N.TFile))return;let r=(await this.vault.read(e)).split(/\r?\n/),n=t.sourceLine-1,s=r[n];!s||!/^\s*[-*]\s+\[\s\]/.test(s)||(r[n]=s.replace(/^(\s*[-*]\s+\[)\s(\])/,"$1x$2"),await this.vault.modify(e,r.join(` +`)))}};function H(o){let t=o.join("|"),e=0;for(let i=0;ii.type===o);if(!t)return null;let e=Date.now().toString(36);return{...t,id:`${t.type}-${e}`,config:JSON.parse(JSON.stringify(t.config))}}var z=2,x=class{constructor(t){this.plugin=t;this.data={dataVersion:z,settings:I(),reminders:[]}}async load(){var i,r,n,s,a;let t=await this.plugin.loadData(),e=I();this.data=this.migrate({dataVersion:(i=t==null?void 0:t.dataVersion)!=null?i:1,settings:{...e,...(r=t==null?void 0:t.settings)!=null?r:{},notificationProviders:(s=(n=t==null?void 0:t.settings)==null?void 0:n.notificationProviders)!=null?s:e.notificationProviders},reminders:(a=t==null?void 0:t.reminders)!=null?a:[]}),await this.save()}async save(){await this.plugin.saveData(this.data)}getSettings(){return this.data.settings}async updateSettings(t){this.data.settings=t,await this.save()}getAll(){return[...this.data.reminders].sort((t,e)=>t.dueAt.localeCompare(e.dueAt))}getDue(t=new Date){return this.data.reminders.filter(e=>(e.status==="pending"||e.status==="snoozed"||e.status==="failed")&&new Date(e.dueAt).getTime()<=t.getTime())}async add(t,e){var s,a,d,c;let i=new Date().toISOString(),r={id:H([(s=t.sourceFile)!=null?s:"manual",String((a=t.sourceLine)!=null?a:""),t.title,t.dueAt]),title:t.title,body:t.body,dueAt:t.dueAt,sourceFile:t.sourceFile,sourceLine:t.sourceLine,completed:!1,channels:t.channels.length?t.channels:e,priority:(d=t.priority)!=null?d:"normal",status:"pending",repeat:(c=t.repeat)!=null&&c.frequency&&t.repeat.frequency!=="none"?t.repeat:void 0,createdAt:i,updatedAt:i},n=this.data.reminders.find(l=>l.id===r.id);return n?(Object.assign(n,{...r,status:n.status,completed:n.completed,createdAt:n.createdAt,updatedAt:i}),await this.save(),n):(this.data.reminders.push(r),await this.save(),r)}async update(t,e){var r,n;let i=this.find(t);i&&(i.title=e.title,i.body=e.body,i.dueAt=e.dueAt,i.channels=e.channels,i.priority=(r=e.priority)!=null?r:"normal",i.repeat=(n=e.repeat)!=null&&n.frequency&&e.repeat.frequency!=="none"?e.repeat:void 0,i.status="pending",i.completed=!1,i.updatedAt=new Date().toISOString(),i.lastError=void 0,await this.save())}async addMany(t){let e=0;for(let i of t)await this.add(i,[]),e+=1;return e}async markSent(t){let e=this.find(t);if(!e)return;let i=U(e.dueAt,e.repeat);i?(e.dueAt=i,e.status="pending",e.completed=!1):(e.status=this.data.settings.afterSend==="mark-done"?"done":"sent",e.completed=this.data.settings.afterSend==="mark-done"),e.updatedAt=new Date().toISOString(),e.lastError=void 0,await this.save()}async markFailed(t,e){let i=this.find(t);i&&(i.status="failed",i.lastError=e instanceof Error?e.message:String(e),i.updatedAt=new Date().toISOString(),await this.save())}async complete(t){let e=this.find(t);e&&(e.completed=!0,e.status="done",e.updatedAt=new Date().toISOString(),await this.save())}async snooze(t,e){let i=this.find(t);i&&(i.dueAt=new Date(Date.now()+e*6e4).toISOString(),i.status="snoozed",i.updatedAt=new Date().toISOString(),await this.save())}async remove(t){this.data.reminders=this.data.reminders.filter(e=>e.id!==t),await this.save()}find(t){return this.data.reminders.find(e=>e.id===t)}migrate(t){var n,s;let e=I(),i={...e,...t.settings,notificationProviders:(n=t.settings.notificationProviders)!=null?n:e.notificationProviders,markTaskCompleteOnSend:(s=t.settings.markTaskCompleteOnSend)!=null?s:e.markTaskCompleteOnSend},r=t.reminders.map(a=>{var d,c,l,p,g,y;return{...a,channels:(d=a.channels)!=null?d:[],priority:(c=a.priority)!=null?c:"normal",status:(l=a.status)!=null?l:"pending",completed:(p=a.completed)!=null?p:!1,createdAt:(g=a.createdAt)!=null?g:new Date().toISOString(),updatedAt:(y=a.updatedAt)!=null?y:new Date().toISOString()}});return{dataVersion:z,settings:i,reminders:r}}};var P=require("obsidian");var b="reminder-notifier-view",k=class extends P.ItemView{constructor(e,i){super(e);this.plugin=i;this.showAll=!1}getViewType(){return b}getDisplayText(){return"Reminders"}getIcon(){return"list-checks"}async onOpen(){this.render()}refresh(){this.render()}render(){let e=this.containerEl.children[1];e.empty(),e.addClass("reminder-notifier-view"),new P.Setting(e).setName("Reminders").addButton(n=>n.setButtonText("New").setIcon("bell-plus").onClick(()=>{new S(this.app,this.plugin.store.getSettings().defaultChannels,async s=>{await this.plugin.store.add(s,this.plugin.store.getSettings().defaultChannels),this.refresh(),new P.Notice("Reminder created.")}).open()})).addButton(n=>n.setButtonText(this.showAll?"Active":"All").setIcon("filter").onClick(()=>{this.showAll=!this.showAll,this.render()}));let i=this.showAll?this.plugin.store.getAll():this.plugin.store.getAll().filter(n=>!["sent","done","cancelled"].includes(n.status));if(!i.length){e.createEl("p",{text:this.showAll?"No reminders.":"No active reminders."});return}let r=e.createDiv({cls:"reminder-notifier-list"});for(let n of i)this.renderReminder(r,n)}renderReminder(e,i){var s;let r=e.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"}`}),(s=i.repeat)!=null&&s.frequency&&i.repeat.frequency!=="none"&&r.createDiv({cls:"reminder-notifier-item-meta",text:`Repeats ${i.repeat.frequency} every ${i.repeat.interval}`});let n=r.createDiv({cls:"reminder-notifier-actions"});new P.Setting(n).addButton(a=>a.setButtonText("Edit").setIcon("pencil").onClick(()=>{new S(this.app,i.channels,async d=>{await this.plugin.store.update(i.id,d),this.refresh(),new P.Notice("Reminder updated.")},i.title,i).open()})).addButton(a=>a.setButtonText("15m").setIcon("clock").onClick(async()=>{await this.plugin.store.snooze(i.id,15),this.refresh()})).addButton(a=>a.setButtonText("Done").setIcon("check").onClick(async()=>{await this.plugin.store.complete(i.id),this.refresh()})).addButton(a=>a.setButtonText("Delete").setIcon("trash").onClick(async()=>{await this.plugin.store.remove(i.id),this.refresh()}))}};var m=require("obsidian");var E=class extends m.PluginSettingTab{constructor(e,i){super(e,i);this.plugin=i}display(){let{containerEl:e}=this,i=this.plugin.store.getSettings();e.empty(),e.createEl("h2",{text:"Reminder Notifier"}),new m.Setting(e).setName("Scan vault on startup").addToggle(n=>n.setValue(i.scanVaultOnStartup).onChange(async s=>{i.scanVaultOnStartup=s,await this.plugin.store.updateSettings(i)})),new m.Setting(e).setName("Scan changed notes").addToggle(n=>n.setValue(i.scanOnFileChange).onChange(async s=>{i.scanOnFileChange=s,await this.plugin.store.updateSettings(i)})),new m.Setting(e).setName("Check interval").setDesc("Seconds between reminder checks.").addText(n=>n.setValue(String(i.checkIntervalSeconds)).onChange(async s=>{i.checkIntervalSeconds=Math.max(10,Number(s)||60),await this.plugin.store.updateSettings(i),this.plugin.restartScheduler()})),new m.Setting(e).setName("Default channels").setDesc("Comma separated provider IDs or types. Empty means every enabled provider.").addText(n=>n.setValue(i.defaultChannels.join(", ")).onChange(async s=>{i.defaultChannels=s.split(",").map(a=>a.trim()).filter(Boolean),await this.plugin.store.updateSettings(i)})),new m.Setting(e).setName("After send").addDropdown(n=>n.addOption("mark-sent","Mark as sent").addOption("mark-done","Mark as done").addOption("keep","Keep pending").setValue(i.afterSend).onChange(async s=>{i.afterSend=s,await this.plugin.store.updateSettings(i)})),new m.Setting(e).setName("Mark Markdown tasks complete").setDesc("When a non-repeating reminder from a task line is sent, replace [ ] with [x].").addToggle(n=>n.setValue(i.markTaskCompleteOnSend).onChange(async s=>{i.markTaskCompleteOnSend=s,await this.plugin.store.updateSettings(i)})),e.createEl("h3",{text:"Notification providers"});let r=T[0].type;new m.Setting(e).setName("Add from template").addDropdown(n=>{for(let s of T)n.addOption(s.type,s.name);n.onChange(s=>{r=s})}).addButton(n=>n.setButtonText("Add provider").onClick(async()=>{let s=J(r);s&&(i.notificationProviders.push(s),await this.plugin.store.updateSettings(i),this.display())}));for(let n of i.notificationProviders)this.renderProvider(n)}renderProvider(e){let i=this.plugin.store.getSettings(),r=this.containerEl.createDiv({cls:"reminder-notifier-provider"});r.createEl("h4",{text:e.name}),new m.Setting(r).setName("Enabled").addToggle(n=>n.setValue(e.enabled).onChange(async s=>{e.enabled=s,await this.plugin.store.updateSettings(i)})),new m.Setting(r).setName("ID").setDesc("Use this value in @remind(..., provider-id).").addText(n=>n.setValue(e.id).onChange(async s=>{e.id=s.trim(),await this.plugin.store.updateSettings(i)})),new m.Setting(r).setName("Name").addText(n=>n.setValue(e.name).onChange(async s=>{e.name=s,await this.plugin.store.updateSettings(i)})),new m.Setting(r).setName("Config JSON").setDesc("Secrets are stored locally in Obsidian plugin data.").addTextArea(n=>{n.inputEl.addClass("reminder-notifier-json"),n.setValue(JSON.stringify(e.config,null,2)),n.onChange(async s=>{try{e.config=JSON.parse(s),await this.plugin.store.updateSettings(i)}catch(a){}})}),new m.Setting(r).addButton(n=>n.setButtonText("Test notification").onClick(async()=>{try{await this.plugin.testProvider(e),new m.Notice("Test notification sent.")}catch(s){new m.Notice(s instanceof Error?s.message:String(s))}})).addButton(n=>n.setButtonText("Delete").onClick(async()=>{i.notificationProviders=i.notificationProviders.filter(s=>s!==e),await this.plugin.store.updateSettings(i),this.display()}))}};var K=require("obsidian");function C(o,t){var e,i,r,n,s,a;return typeof o=="string"?Object.entries({"{{title}}":t.title,"{{message}}":t.message,"{{body}}":t.message,"{{file}}":(e=t.sourceFile)!=null?e:"","{{sourceFile}}":(i=t.sourceFile)!=null?i:"","{{sourceLine}}":(n=(r=t.sourceLine)==null?void 0:r.toString())!=null?n:"","{{priority}}":(s=t.priority)!=null?s:"normal","{{dueAt}}":t.dueAt,"{{time}}":t.dueAt,"{{url}}":(a=t.url)!=null?a:""}).reduce((d,[c,l])=>d.split(c).join(l),o):Array.isArray(o)?o.map(d=>C(d,t)):o&&typeof o=="object"?Object.fromEntries(Object.entries(o).map(([d,c])=>[d,C(c,t)])):o}function f(o,t=""){return typeof o=="string"?o:t}var V=class{constructor(){this.id="discord";this.name="Discord webhook"}async send(t,e){let i=f(e.config.webhookUrl);if(!i)throw new Error("Discord webhookUrl is required.");await(0,K.requestUrl)({url:i,method:"POST",contentType:"application/json",body:JSON.stringify({username:f(e.config.username,"Obsidian Reminder"),content:`**${t.title}** +${t.message}`}),throw:!0})}async test(t){await this.send({title:"Obsidian Reminder Notifier",message:"Test notification",dueAt:new Date().toISOString()},t)}};var _=require("obsidian"),$=class{constructor(){this.id="local";this.name="Local Obsidian notice"}async send(t){new _.Notice(`${t.title} +${t.message}`,1e4)}async test(t){new _.Notice("Reminder Notifier test notification",5e3)}};var G=require("obsidian");var F=class{constructor(){this.id="ntfy";this.name="ntfy"}async send(t,e){var d;let i=f(e.config.serverUrl,"https://ntfy.sh").replace(/\/$/,""),r=f(e.config.topic);if(!r)throw new Error("ntfy topic is required.");let n={Title:t.title,Priority:String((d=e.config.priority)!=null?d:de(t.priority))},s=f(e.config.token);s&&(n.Authorization=`Bearer ${s}`);let a=e.config.tags;Array.isArray(a)&&(n.Tags=a.join(",")),await(0,G.requestUrl)({url:`${i}/${encodeURIComponent(r)}`,method:"POST",headers:n,body:t.message,throw:!0})}async test(t){await this.send({title:"Obsidian Reminder Notifier",message:"Test notification",dueAt:new Date().toISOString()},t)}};function de(o="normal"){return o==="high"?4:o==="low"?2:3}var X=require("obsidian");var M=class{constructor(){this.id="telegram";this.name="Telegram"}async send(t,e){let i=f(e.config.botToken),r=f(e.config.chatId);if(!i||!r)throw new Error("Telegram botToken and chatId are required.");let n=`*${t.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}.`)}}; +${t.message}`;await(0,X.requestUrl)({url:`https://api.telegram.org/bot${i}/sendMessage`,method:"POST",contentType:"application/json",body:JSON.stringify({chat_id:r,text:n,parse_mode:f(e.config.parseMode,"Markdown"),disable_web_page_preview:!0}),throw:!0})}async test(t){await this.send({title:"Obsidian Reminder Notifier",message:"Test notification",dueAt:new Date().toISOString()},t)}};var Q=require("obsidian");var L=class{constructor(){this.id="webhook";this.name="Generic webhook"}async send(t,e){var a,d;let i=f(e.config.url);if(!i)throw new Error("Webhook URL is required.");let r=f(e.config.method,"POST").toUpperCase();if(!["POST","PUT","PATCH"].includes(r))throw new Error("Webhook method must be POST, PUT, or PATCH.");let n=C((a=e.config.headers)!=null?a:{"Content-Type":"application/json"},t),s=C((d=e.config.bodyTemplate)!=null?d:{title:"{{title}}",message:"{{message}}",dueAt:"{{dueAt}}",sourceFile:"{{sourceFile}}"},t);await(0,Q.requestUrl)({url:i,method:r,headers:n,body:typeof s=="string"?s:JSON.stringify(s),throw:!0})}async test(t){await this.send({title:"Obsidian Reminder Notifier",message:"Test notification",dueAt:new Date().toISOString()},t)}};function Z(){let o=[new $,new M,new F,new L,new V];return new Map(o.map(t=>[t.id,t]))}var B=class extends h.Plugin{constructor(){super(...arguments);this.providers=Z()}async onload(){if(this.store=new x(this),await this.store.load(),this.scheduler=new O(this.app.vault,this.store,this.providers,()=>this.refreshReminderViews()),this.registerView(b,e=>new k(e,this)),this.addSettingTab(new E(this.app,this)),this.registerCommands(),this.registerRibbonActions(),this.registerContextMenus(),this.registerFileWatcher(),this.store.getSettings().scanVaultOnStartup){let e=await this.scheduler.scanVault();e>0&&new h.Notice(`Reminder Notifier indexed ${e} reminders.`)}this.scheduler.start()}onunload(){var e;(e=this.scheduler)==null||e.stop()}restartScheduler(){this.scheduler.stop(),this.scheduler.start()}async testProvider(e){let i=this.providers.get(e.type);if(!i)throw new Error(`Unknown provider: ${e.type}`);await i.test(e)}async activateReminderView(){let e=this.app.workspace.getLeavesOfType(b)[0];if(e){this.app.workspace.revealLeaf(e);return}let i=this.app.workspace.getRightLeaf(!1);i&&(await i.setViewState({type:b,active:!0}),this.app.workspace.revealLeaf(i))}refreshReminderViews(){for(let e of this.app.workspace.getLeavesOfType(b)){let i=e.view;i instanceof k&&i.refresh()}}registerCommands(){this.addCommand({id:"create-reminder",name:"Create reminder",callback:()=>{new S(this.app,this.store.getSettings().defaultChannels,async e=>{await this.store.add(e,this.store.getSettings().defaultChannels),this.refreshReminderViews(),new h.Notice("Reminder created.")}).open()}}),this.addCommand({id:"create-reminder-from-selection",name:"Create reminder from selected text",editorCallback:e=>{let i=e.getSelection();new S(this.app,this.store.getSettings().defaultChannels,async r=>{await this.store.add(r,this.store.getSettings().defaultChannels),this.refreshReminderViews(),new h.Notice("Reminder created.")},i).open()}}),this.addCommand({id:"show-active-reminders",name:"Show active reminders",callback:()=>{this.activateReminderView()}}),this.addCommand({id:"show-all-reminders",name:"Show all reminders",callback:()=>{this.activateReminderView()}}),this.addCommand({id:"scan-current-note",name:"Scan current note for reminders",checkCallback:e=>{let i=this.app.workspace.getActiveFile();return i?(e||this.scanCurrentFile(i),!0):!1}}),this.addCommand({id:"scan-vault",name:"Scan vault for reminders",callback:async()=>{let e=await this.scheduler.scanVault();new h.Notice(`Indexed ${e} reminders.`)}}),this.addCommand({id:"test-notification-providers",name:"Test notification providers",callback:async()=>{let e=this.store.getSettings().notificationProviders.filter(i=>i.enabled);for(let i of e)await this.testProvider(i);new h.Notice(`Tested ${e.length} providers.`)}})}registerRibbonActions(){this.addRibbonIcon("bell-plus","Create reminder",()=>{new S(this.app,this.store.getSettings().defaultChannels,async e=>{await this.store.add(e,this.store.getSettings().defaultChannels),this.refreshReminderViews(),new h.Notice("Reminder created.")}).open()}),this.addRibbonIcon("list-checks","Show reminders",()=>{this.activateReminderView()})}registerContextMenus(){this.registerEvent(this.app.workspace.on("editor-menu",(e,i)=>{e.addSeparator(),e.addItem(r=>r.setTitle("Create reminder").setIcon("bell-plus").onClick(()=>{let n=i.getSelection();new S(this.app,this.store.getSettings().defaultChannels,async s=>{await this.store.add(s,this.store.getSettings().defaultChannels),this.refreshReminderViews(),new h.Notice("Reminder created.")},n).open()})),e.addItem(r=>r.setTitle("Show reminders").setIcon("list-checks").onClick(()=>{this.activateReminderView()}))}))}registerFileWatcher(){this.registerEvent(this.app.vault.on("modify",e=>{!this.store.getSettings().scanOnFileChange||!(e instanceof h.TFile)||e.extension!=="md"||this.scheduler.scanFile(e)}))}async scanCurrentFile(e){var s;let i=this.app.workspace.getActiveViewOfType(h.MarkdownView),r=(s=i==null?void 0:i.file)!=null?s:e,n=await this.scheduler.scanFile(r);new h.Notice(`Indexed ${n} reminders from ${r.basename}.`)}}; diff --git a/package.json b/package.json index 8088c58..933744d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs --watch", - "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "test": "node scripts/run-tests.mjs", + "release": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/create-release.ps1" }, "keywords": [ "obsidian", diff --git a/scripts/create-release.ps1 b/scripts/create-release.ps1 new file mode 100644 index 0000000..882eb67 --- /dev/null +++ b/scripts/create-release.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$manifestPath = Join-Path $root "manifest.json" +$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json +$releaseDir = Join-Path $root "dist" +$stagingDir = Join-Path $releaseDir "reminder-notifier" +$zipPath = Join-Path $releaseDir ("reminder-notifier-" + $manifest.version + ".zip") + +if (Test-Path -LiteralPath $stagingDir) { + Remove-Item -LiteralPath $stagingDir -Recurse -Force +} + +New-Item -ItemType Directory -Path $stagingDir | Out-Null +Copy-Item -LiteralPath (Join-Path $root "manifest.json") -Destination $stagingDir +Copy-Item -LiteralPath (Join-Path $root "main.js") -Destination $stagingDir +Copy-Item -LiteralPath (Join-Path $root "styles.css") -Destination $stagingDir + +if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force +} + +Compress-Archive -Path (Join-Path $stagingDir "*") -DestinationPath $zipPath +Write-Host "Release archive created: $zipPath" + diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..e9d9f43 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,16 @@ +import esbuild from "esbuild"; +import { pathToFileURL } from "node:url"; + +await esbuild.build({ + entryPoints: ["tests/date.test.ts"], + bundle: true, + platform: "node", + target: "node20", + outfile: "dist-tests/date.test.mjs", + format: "esm", + logLevel: "silent", +}); + +await import(pathToFileURL(`${process.cwd()}/dist-tests/date.test.mjs`).href); +console.log("Tests passed."); + diff --git a/src/main.ts b/src/main.ts index 24d1b37..3c595e1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { ReminderListModal } from "./reminders/ReminderListModal"; import { ReminderModal } from "./reminders/ReminderModal"; import { ReminderScheduler } from "./reminders/ReminderScheduler"; import { ReminderStore } from "./reminders/ReminderStore"; +import { REMINDER_VIEW_TYPE, ReminderView } from "./reminders/ReminderView"; import { ReminderNotifierSettingTab } from "./settings/SettingsTab"; import { NotificationProviderConfig, ProviderType } from "./types"; import { createProviderRegistry } from "./notifiers"; @@ -16,7 +17,8 @@ export default class ReminderNotifierPlugin extends Plugin { this.store = new ReminderStore(this); await this.store.load(); - this.scheduler = new ReminderScheduler(this.app.vault, this.store, this.providers); + this.scheduler = new ReminderScheduler(this.app.vault, this.store, this.providers, () => this.refreshReminderViews()); + this.registerView(REMINDER_VIEW_TYPE, (leaf) => new ReminderView(leaf, this)); this.addSettingTab(new ReminderNotifierSettingTab(this.app, this)); this.registerCommands(); this.registerRibbonActions(); @@ -50,6 +52,30 @@ export default class ReminderNotifierPlugin extends Plugin { await notifier.test(provider); } + async activateReminderView(): Promise { + const existing = this.app.workspace.getLeavesOfType(REMINDER_VIEW_TYPE)[0]; + if (existing) { + this.app.workspace.revealLeaf(existing); + return; + } + + const leaf = this.app.workspace.getRightLeaf(false); + if (!leaf) { + return; + } + await leaf.setViewState({ type: REMINDER_VIEW_TYPE, active: true }); + this.app.workspace.revealLeaf(leaf); + } + + refreshReminderViews(): void { + for (const leaf of this.app.workspace.getLeavesOfType(REMINDER_VIEW_TYPE)) { + const view = leaf.view; + if (view instanceof ReminderView) { + view.refresh(); + } + } + } + private registerCommands(): void { this.addCommand({ id: "create-reminder", @@ -57,6 +83,7 @@ export default class ReminderNotifierPlugin extends Plugin { callback: () => { new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { await this.store.add(input, this.store.getSettings().defaultChannels); + this.refreshReminderViews(); new Notice("Reminder created."); }).open(); }, @@ -69,6 +96,7 @@ export default class ReminderNotifierPlugin extends Plugin { const selection = editor.getSelection(); new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { await this.store.add(input, this.store.getSettings().defaultChannels); + this.refreshReminderViews(); new Notice("Reminder created."); }, selection).open(); }, @@ -78,7 +106,7 @@ export default class ReminderNotifierPlugin extends Plugin { id: "show-active-reminders", name: "Show active reminders", callback: () => { - new ReminderListModal(this.app, this.store, false).open(); + void this.activateReminderView(); }, }); @@ -86,7 +114,7 @@ export default class ReminderNotifierPlugin extends Plugin { id: "show-all-reminders", name: "Show all reminders", callback: () => { - new ReminderListModal(this.app, this.store, true).open(); + void this.activateReminderView(); }, }); @@ -131,12 +159,13 @@ export default class ReminderNotifierPlugin extends Plugin { this.addRibbonIcon("bell-plus", "Create reminder", () => { new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { await this.store.add(input, this.store.getSettings().defaultChannels); + this.refreshReminderViews(); new Notice("Reminder created."); }).open(); }); this.addRibbonIcon("list-checks", "Show reminders", () => { - new ReminderListModal(this.app, this.store, true).open(); + void this.activateReminderView(); }); } @@ -150,6 +179,7 @@ export default class ReminderNotifierPlugin extends Plugin { const selection = editor.getSelection(); new ReminderModal(this.app, this.store.getSettings().defaultChannels, async (input) => { await this.store.add(input, this.store.getSettings().defaultChannels); + this.refreshReminderViews(); new Notice("Reminder created."); }, selection).open(); })); @@ -158,7 +188,7 @@ export default class ReminderNotifierPlugin extends Plugin { .setTitle("Show reminders") .setIcon("list-checks") .onClick(() => { - new ReminderListModal(this.app, this.store, true).open(); + void this.activateReminderView(); })); })); } diff --git a/src/reminders/ReminderListModal.ts b/src/reminders/ReminderListModal.ts index 8ca0eaf..7f7a440 100644 --- a/src/reminders/ReminderListModal.ts +++ b/src/reminders/ReminderListModal.ts @@ -1,6 +1,7 @@ import { App, Modal, Setting } from "obsidian"; import { ReminderStore } from "./ReminderStore"; import { formatDateTime } from "../utils/date"; +import { ReminderModal } from "./ReminderModal"; export class ReminderListModal extends Modal { constructor(app: App, private store: ReminderStore, private showCompleted = false) { @@ -52,6 +53,14 @@ export class ReminderListModal extends Modal { const actions = item.createDiv({ cls: "reminder-notifier-actions" }); new Setting(actions) + .addButton((button) => button + .setButtonText("Edit") + .onClick(() => { + new ReminderModal(this.app, reminder.channels, async (input) => { + await this.store.update(reminder.id, input); + this.render(); + }, reminder.title, reminder).open(); + })) .addButton((button) => button .setButtonText("Snooze 15m") .onClick(async () => { diff --git a/src/reminders/ReminderModal.ts b/src/reminders/ReminderModal.ts index cadc492..de706d9 100644 --- a/src/reminders/ReminderModal.ts +++ b/src/reminders/ReminderModal.ts @@ -1,5 +1,5 @@ import { App, Modal, Notice, Setting } from "obsidian"; -import { ParsedReminderInput } from "../types"; +import { ParsedReminderInput, Reminder, RepeatFrequency } from "../types"; export class ReminderModal extends Modal { private title = ""; @@ -10,18 +10,23 @@ export class ReminderModal extends Modal { private hour = ""; private minute = ""; private channels = ""; + private repeatFrequency: RepeatFrequency = "none"; + private repeatInterval = "1"; constructor( app: App, private defaultChannels: string[], private onSubmit: (input: ParsedReminderInput) => Promise, initialTitle = "", + private existing?: Reminder, ) { super(app); - const initialDate = new Date(Date.now() + 60 * 60 * 1000); - this.title = initialTitle; - this.body = initialTitle; - this.channels = defaultChannels.join(", "); + const initialDate = existing ? new Date(existing.dueAt) : new Date(Date.now() + 60 * 60 * 1000); + this.title = existing?.title ?? initialTitle; + this.body = existing?.body ?? initialTitle; + this.channels = existing ? existing.channels.join(", ") : defaultChannels.join(", "); + this.repeatFrequency = existing?.repeat?.frequency ?? "none"; + this.repeatInterval = String(existing?.repeat?.interval ?? 1); this.year = String(initialDate.getFullYear()); this.month = String(initialDate.getMonth() + 1).padStart(2, "0"); this.day = String(initialDate.getDate()).padStart(2, "0"); @@ -36,7 +41,7 @@ export class ReminderModal extends Modal { onOpen(): void { const { contentEl } = this; contentEl.empty(); - contentEl.createEl("h2", { text: "Create reminder" }); + contentEl.createEl("h2", { text: this.existing ? "Edit reminder" : "Create reminder" }); this.renderReminderRow(contentEl); @@ -60,9 +65,27 @@ export class ReminderModal extends Modal { this.channels = value; })); + new Setting(contentEl) + .setName("Repeat") + .addDropdown((dropdown) => dropdown + .addOption("none", "None") + .addOption("daily", "Daily") + .addOption("weekly", "Weekly") + .addOption("monthly", "Monthly") + .setValue(this.repeatFrequency) + .onChange((value) => { + this.repeatFrequency = value as RepeatFrequency; + })) + .addText((text) => text + .setPlaceholder("Interval") + .setValue(this.repeatInterval) + .onChange((value) => { + this.repeatInterval = value; + })); + new Setting(contentEl) .addButton((button) => button - .setButtonText("Create") + .setButtonText(this.existing ? "Save" : "Create") .setCta() .onClick(async () => { const dueAt = this.getSelectedDate(); @@ -76,6 +99,10 @@ export class ReminderModal extends Modal { body: this.body.trim(), dueAt: dueAt.toISOString(), channels: this.channels.split(",").map((value) => value.trim()).filter(Boolean), + repeat: { + frequency: this.repeatFrequency, + interval: Math.max(1, Number(this.repeatInterval) || 1), + }, }); this.close(); })); diff --git a/src/reminders/ReminderParser.ts b/src/reminders/ReminderParser.ts index 6e71a12..8652c61 100644 --- a/src/reminders/ReminderParser.ts +++ b/src/reminders/ReminderParser.ts @@ -1,4 +1,4 @@ -import { ParsedReminderInput, ReminderPriority } from "../types"; +import { ParsedReminderInput, ReminderPriority, RepeatRule } from "../types"; import { parseReminderDate } from "../utils/date"; const INLINE_PATTERN = /@remind\(([^)]+)\)/gi; @@ -36,8 +36,9 @@ export class ReminderParser { dueAt: date.toISOString(), sourceFile, sourceLine: index + 1, - channels: args.slice(1), + channels: this.parseChannels(args.slice(1)), priority: this.parsePriority(args), + repeat: this.parseRepeat(args), }); } @@ -57,9 +58,10 @@ export class ReminderParser { dueAt: date.toISOString(), sourceFile, sourceLine: index + 1, - channels: [], - priority: "normal", - }); + channels: [], + priority: "normal", + repeat: undefined, + }); } }); @@ -116,6 +118,7 @@ export class ReminderParser { sourceLine, channels, priority: this.normalizePriority(item.priority), + repeat: this.parseRepeat([item.repeat ?? ""]), }]; }); } @@ -174,6 +177,26 @@ export class ReminderParser { return this.normalizePriority(priority); } + private parseChannels(values: string[]): string[] { + return values.filter((value) => + !/^(low|normal|high)$/i.test(value) + && !/^(daily|weekly|monthly)(?::\d+)?$/i.test(value) + ); + } + + private parseRepeat(values: string[]): RepeatRule | undefined { + const repeat = values.find((value) => /^(daily|weekly|monthly)(?::\d+)?$/i.test(value)); + if (!repeat) { + return undefined; + } + + const [frequency, interval] = repeat.toLowerCase().split(":"); + return { + frequency: frequency as RepeatRule["frequency"], + interval: Math.max(1, Number(interval) || 1), + }; + } + private normalizePriority(value?: string): ReminderPriority { if (value === "low" || value === "high" || value === "normal") { return value; diff --git a/src/reminders/ReminderScheduler.ts b/src/reminders/ReminderScheduler.ts index db6b1bd..d0a044b 100644 --- a/src/reminders/ReminderScheduler.ts +++ b/src/reminders/ReminderScheduler.ts @@ -11,6 +11,7 @@ export class ReminderScheduler { private vault: Vault, private store: ReminderStore, private providers: Map, + private onChange: () => void = () => undefined, ) {} start(): void { @@ -68,6 +69,7 @@ export class ReminderScheduler { : `Reminder has no enabled providers: ${reminder.title}`; new Notice(message); await this.store.markFailed(reminder.id, message); + this.onChange(); return; } @@ -86,10 +88,35 @@ export class ReminderScheduler { dueAt: reminder.dueAt, }, config); } + await this.completeSourceTask(reminder); await this.store.markSent(reminder.id); + this.onChange(); } catch (error) { await this.store.markFailed(reminder.id, error); + this.onChange(); new Notice(`Reminder failed: ${reminder.title}`); } } + + private async completeSourceTask(reminder: Reminder): Promise { + if (!this.store.getSettings().markTaskCompleteOnSend || reminder.repeat || !reminder.sourceFile || !reminder.sourceLine) { + return; + } + + const file = this.vault.getAbstractFileByPath(reminder.sourceFile); + if (!(file instanceof TFile)) { + return; + } + + const content = await this.vault.read(file); + const lines = content.split(/\r?\n/); + const index = reminder.sourceLine - 1; + const line = lines[index]; + if (!line || !/^\s*[-*]\s+\[\s\]/.test(line)) { + return; + } + + lines[index] = line.replace(/^(\s*[-*]\s+\[)\s(\])/, "$1x$2"); + await this.vault.modify(file, lines.join("\n")); + } } diff --git a/src/reminders/ReminderStore.ts b/src/reminders/ReminderStore.ts index 1f1b5ef..9164ecf 100644 --- a/src/reminders/ReminderStore.ts +++ b/src/reminders/ReminderStore.ts @@ -2,9 +2,13 @@ import { Plugin } from "obsidian"; import { ParsedReminderInput, PluginData, PluginSettings, Reminder } from "../types"; import { createReminderId } from "../utils/ids"; import { createDefaultSettings } from "../settings/templates"; +import { getNextRepeatDate } from "../utils/date"; + +export const CURRENT_DATA_VERSION = 2; export class ReminderStore { private data: PluginData = { + dataVersion: CURRENT_DATA_VERSION, settings: createDefaultSettings(), reminders: [], }; @@ -14,14 +18,16 @@ export class ReminderStore { async load(): Promise { const loaded = await this.plugin.loadData() as Partial | null; const defaults = createDefaultSettings(); - this.data = { + this.data = this.migrate({ + dataVersion: loaded?.dataVersion ?? 1, settings: { ...defaults, ...(loaded?.settings ?? {}), notificationProviders: loaded?.settings?.notificationProviders ?? defaults.notificationProviders, }, reminders: loaded?.reminders ?? [], - }; + }); + await this.save(); } async save(): Promise { @@ -61,6 +67,7 @@ export class ReminderStore { channels: input.channels.length ? input.channels : defaultChannels, priority: input.priority ?? "normal", status: "pending", + repeat: input.repeat?.frequency && input.repeat.frequency !== "none" ? input.repeat : undefined, createdAt: now, updatedAt: now, }; @@ -83,6 +90,25 @@ export class ReminderStore { return reminder; } + async update(id: string, input: ParsedReminderInput): Promise { + const reminder = this.find(id); + if (!reminder) { + return; + } + + reminder.title = input.title; + reminder.body = input.body; + reminder.dueAt = input.dueAt; + reminder.channels = input.channels; + reminder.priority = input.priority ?? "normal"; + reminder.repeat = input.repeat?.frequency && input.repeat.frequency !== "none" ? input.repeat : undefined; + reminder.status = "pending"; + reminder.completed = false; + reminder.updatedAt = new Date().toISOString(); + reminder.lastError = undefined; + await this.save(); + } + async addMany(inputs: ParsedReminderInput[]): Promise { let count = 0; for (const input of inputs) { @@ -98,8 +124,15 @@ export class ReminderStore { return; } - reminder.status = this.data.settings.afterSend === "mark-done" ? "done" : "sent"; - reminder.completed = this.data.settings.afterSend === "mark-done"; + const nextDueAt = getNextRepeatDate(reminder.dueAt, reminder.repeat); + if (nextDueAt) { + reminder.dueAt = nextDueAt; + reminder.status = "pending"; + reminder.completed = false; + } else { + reminder.status = this.data.settings.afterSend === "mark-done" ? "done" : "sent"; + reminder.completed = this.data.settings.afterSend === "mark-done"; + } reminder.updatedAt = new Date().toISOString(); reminder.lastError = undefined; await this.save(); @@ -147,4 +180,30 @@ export class ReminderStore { private find(id: string): Reminder | undefined { return this.data.reminders.find((reminder) => reminder.id === id); } + + private migrate(data: PluginData): PluginData { + const defaults = createDefaultSettings(); + const settings = { + ...defaults, + ...data.settings, + notificationProviders: data.settings.notificationProviders ?? defaults.notificationProviders, + markTaskCompleteOnSend: data.settings.markTaskCompleteOnSend ?? defaults.markTaskCompleteOnSend, + }; + + const reminders = data.reminders.map((reminder) => ({ + ...reminder, + channels: reminder.channels ?? [], + priority: reminder.priority ?? "normal", + status: reminder.status ?? "pending", + completed: reminder.completed ?? false, + createdAt: reminder.createdAt ?? new Date().toISOString(), + updatedAt: reminder.updatedAt ?? new Date().toISOString(), + })); + + return { + dataVersion: CURRENT_DATA_VERSION, + settings, + reminders, + }; + } } diff --git a/src/reminders/ReminderView.ts b/src/reminders/ReminderView.ts new file mode 100644 index 0000000..b17c6c2 --- /dev/null +++ b/src/reminders/ReminderView.ts @@ -0,0 +1,125 @@ +import { ItemView, Notice, Setting, WorkspaceLeaf } from "obsidian"; +import type ReminderNotifierPlugin from "../main"; +import { Reminder } from "../types"; +import { formatDateTime } from "../utils/date"; +import { ReminderModal } from "./ReminderModal"; + +export const REMINDER_VIEW_TYPE = "reminder-notifier-view"; + +export class ReminderView extends ItemView { + private showAll = false; + + constructor(leaf: WorkspaceLeaf, private plugin: ReminderNotifierPlugin) { + super(leaf); + } + + getViewType(): string { + return REMINDER_VIEW_TYPE; + } + + getDisplayText(): string { + return "Reminders"; + } + + getIcon(): string { + return "list-checks"; + } + + async onOpen(): Promise { + this.render(); + } + + refresh(): void { + this.render(); + } + + private render(): void { + const container = this.containerEl.children[1] as HTMLElement; + container.empty(); + container.addClass("reminder-notifier-view"); + + new Setting(container) + .setName("Reminders") + .addButton((button) => button + .setButtonText("New") + .setIcon("bell-plus") + .onClick(() => { + new ReminderModal(this.app, this.plugin.store.getSettings().defaultChannels, async (input) => { + await this.plugin.store.add(input, this.plugin.store.getSettings().defaultChannels); + this.refresh(); + new Notice("Reminder created."); + }).open(); + })) + .addButton((button) => button + .setButtonText(this.showAll ? "Active" : "All") + .setIcon("filter") + .onClick(() => { + this.showAll = !this.showAll; + this.render(); + })); + + const reminders = this.showAll + ? this.plugin.store.getAll() + : this.plugin.store.getAll().filter((reminder) => !["sent", "done", "cancelled"].includes(reminder.status)); + + if (!reminders.length) { + container.createEl("p", { text: this.showAll ? "No reminders." : "No active reminders." }); + return; + } + + const list = container.createDiv({ cls: "reminder-notifier-list" }); + for (const reminder of reminders) { + this.renderReminder(list, reminder); + } + } + + private renderReminder(list: HTMLElement, reminder: Reminder): void { + const item = list.createDiv({ cls: "reminder-notifier-item" }); + item.createDiv({ cls: "reminder-notifier-item-title", text: reminder.title }); + item.createDiv({ + cls: "reminder-notifier-item-meta", + text: `${formatDateTime(reminder.dueAt)} - ${reminder.status} - ${reminder.channels.join(", ") || "all enabled"}`, + }); + + if (reminder.repeat?.frequency && reminder.repeat.frequency !== "none") { + item.createDiv({ + cls: "reminder-notifier-item-meta", + text: `Repeats ${reminder.repeat.frequency} every ${reminder.repeat.interval}`, + }); + } + + const actions = item.createDiv({ cls: "reminder-notifier-actions" }); + new Setting(actions) + .addButton((button) => button + .setButtonText("Edit") + .setIcon("pencil") + .onClick(() => { + new ReminderModal(this.app, reminder.channels, async (input) => { + await this.plugin.store.update(reminder.id, input); + this.refresh(); + new Notice("Reminder updated."); + }, reminder.title, reminder).open(); + })) + .addButton((button) => button + .setButtonText("15m") + .setIcon("clock") + .onClick(async () => { + await this.plugin.store.snooze(reminder.id, 15); + this.refresh(); + })) + .addButton((button) => button + .setButtonText("Done") + .setIcon("check") + .onClick(async () => { + await this.plugin.store.complete(reminder.id); + this.refresh(); + })) + .addButton((button) => button + .setButtonText("Delete") + .setIcon("trash") + .onClick(async () => { + await this.plugin.store.remove(reminder.id); + this.refresh(); + })); + } +} diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts index 8d3b4c6..5268e3b 100644 --- a/src/settings/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -66,6 +66,16 @@ export class ReminderNotifierSettingTab extends PluginSettingTab { await this.plugin.store.updateSettings(settings); })); + new Setting(containerEl) + .setName("Mark Markdown tasks complete") + .setDesc("When a non-repeating reminder from a task line is sent, replace [ ] with [x].") + .addToggle((toggle) => toggle + .setValue(settings.markTaskCompleteOnSend) + .onChange(async (value) => { + settings.markTaskCompleteOnSend = value; + await this.plugin.store.updateSettings(settings); + })); + containerEl.createEl("h3", { text: "Notification providers" }); let selectedTemplate: ProviderType = PROVIDER_TEMPLATES[0].type; diff --git a/src/settings/templates.ts b/src/settings/templates.ts index 72d5c22..d048222 100644 --- a/src/settings/templates.ts +++ b/src/settings/templates.ts @@ -69,6 +69,7 @@ export const DEFAULT_SETTINGS: PluginSettings = { scanOnFileChange: true, checkIntervalSeconds: 60, afterSend: "mark-sent", + markTaskCompleteOnSend: false, reminderSyntax: "@remind(YYYY-MM-DD HH:mm, provider)", notificationProviders: [PROVIDER_TEMPLATES[0]], }; diff --git a/src/types.ts b/src/types.ts index f0632a0..6f3b2f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,12 @@ export type ReminderStatus = "pending" | "sent" | "failed" | "snoozed" | "cancelled" | "done"; export type ReminderPriority = "low" | "normal" | "high"; export type ProviderType = "local" | "telegram" | "ntfy" | "webhook" | "discord"; +export type RepeatFrequency = "none" | "daily" | "weekly" | "monthly"; + +export interface RepeatRule { + frequency: RepeatFrequency; + interval: number; +} export interface Reminder { id: string; @@ -13,6 +19,7 @@ export interface Reminder { channels: string[]; priority: ReminderPriority; status: ReminderStatus; + repeat?: RepeatRule; createdAt: string; updatedAt: string; lastError?: string; @@ -42,11 +49,13 @@ export interface PluginSettings { scanOnFileChange: boolean; checkIntervalSeconds: number; afterSend: "keep" | "mark-sent" | "mark-done"; + markTaskCompleteOnSend: boolean; reminderSyntax: string; notificationProviders: NotificationProviderConfig[]; } export interface PluginData { + dataVersion: number; settings: PluginSettings; reminders: Reminder[]; } @@ -59,6 +68,7 @@ export interface ParsedReminderInput { sourceLine?: number; channels: string[]; priority?: ReminderPriority; + repeat?: RepeatRule; } export interface NotificationProvider { diff --git a/src/utils/date.ts b/src/utils/date.ts index f7082bc..9f97d02 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,3 +1,5 @@ +import { RepeatRule } from "../types"; + const WEEKDAYS = new Map([ ["sunday", 0], ["monday", 1], @@ -66,3 +68,27 @@ export function formatDateTime(value: string): string { } return date.toLocaleString(); } + +export function getNextRepeatDate(value: string, repeat?: RepeatRule, from = new Date()): string | null { + if (!repeat || repeat.frequency === "none") { + return null; + } + + const interval = Math.max(1, repeat.interval || 1); + const next = new Date(value); + if (Number.isNaN(next.getTime())) { + return null; + } + + while (next.getTime() <= from.getTime()) { + if (repeat.frequency === "daily") { + next.setDate(next.getDate() + interval); + } else if (repeat.frequency === "weekly") { + next.setDate(next.getDate() + 7 * interval); + } else if (repeat.frequency === "monthly") { + next.setMonth(next.getMonth() + interval); + } + } + + return next.toISOString(); +} diff --git a/styles.css b/styles.css index 2d4b2d6..e168c5c 100644 --- a/styles.css +++ b/styles.css @@ -37,3 +37,11 @@ min-height: 110px; width: 100%; } + +.reminder-notifier-view { + padding: 8px; +} + +.reminder-notifier-view .setting-item { + padding: 8px 0; +} diff --git a/tests/date.test.ts b/tests/date.test.ts new file mode 100644 index 0000000..5b0e696 --- /dev/null +++ b/tests/date.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { getNextRepeatDate, parseReminderDate } from "../src/utils/date"; + +const base = new Date("2026-06-27T10:00:00"); + +const plusTwoHours = parseReminderDate("+2h", base); +assert.equal(plusTwoHours?.toISOString(), new Date("2026-06-27T12:00:00").toISOString()); + +const tomorrow = parseReminderDate("tomorrow 09:30", base); +assert.equal(tomorrow?.toISOString(), new Date("2026-06-28T09:30:00").toISOString()); + +const simple = parseReminderDate("2026-07-01 14:46", base); +assert.equal(simple?.getFullYear(), 2026); +assert.equal(simple?.getMonth(), 6); +assert.equal(simple?.getDate(), 1); +assert.equal(simple?.getHours(), 14); +assert.equal(simple?.getMinutes(), 46); + +const nextDaily = getNextRepeatDate("2026-06-26T10:00:00.000Z", { frequency: "daily", interval: 1 }, new Date("2026-06-27T10:00:01.000Z")); +assert.equal(nextDaily, "2026-06-28T10:00:00.000Z"); + +const nextWeekly = getNextRepeatDate("2026-06-20T10:00:00.000Z", { frequency: "weekly", interval: 2 }, new Date("2026-06-27T10:00:01.000Z")); +assert.equal(nextWeekly, "2026-07-04T10:00:00.000Z"); +