import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, parseYaml, Component, MarkdownPostProcessorContext, MarkdownRenderChild } from 'obsidian'; import { EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, WidgetType } from '@codemirror/view'; import { RangeSetBuilder } from '@codemirror/state'; // Remember to rename these classes and interfaces! interface MyPluginSettings { mySetting: string; } const DEFAULT_SETTINGS: MyPluginSettings = { mySetting: 'default' } // ===== Helper functions for date calculations ===== function zeroTime(d: Date): Date { const nd = new Date(d); nd.setHours(0, 0, 0, 0); return nd; } function parseDate(input: any): Date | null { if (!input) return null; const d = new Date(input); if (isNaN(d.getTime())) return null; return d; } function humanizeDuration(ms: number): string { const abs = Math.abs(ms); let seconds = Math.floor(abs / 1000); let minutes = Math.floor(seconds / 60); let hours = Math.floor(minutes / 60); let days = Math.floor(hours / 24); let years = Math.floor(days / 365); days = days % 365; let months = Math.floor(days / 30); days = days % 30; hours = hours % 24; minutes = minutes % 60; seconds = seconds % 60; const parts: string[] = []; if (years) parts.push(`${years} year${years !== 1 ? 's' : ''}`); if (months) parts.push(`${months} month${months !== 1 ? 's' : ''}`); if (days) parts.push(`${days} day${days !== 1 ? 's' : ''}`); if (hours && parts.length < 3) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`); if (minutes && parts.length < 3) parts.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`); if (seconds && parts.length < 3) parts.push(`${seconds} second${seconds !== 1 ? 's' : ''}`); if (!parts.length) return '0 seconds'; return parts.join(', '); } // Shared function to process inline date-calc code function processInlineDateCalc(raw: string, app: App, sourcePath: string): string { if (!/^date-calc\s*:/.test(raw)) return ''; const params = raw.replace(/^date-calc\s*:/, '').trim(); let cfg: any = {}; if (params) { // Try YAML first (supports inline map like {a: b, c: d} or multiline pasted) try { cfg = parseYaml(params) || {}; } catch { // Fallback: parse key=value pairs separated by whitespace cfg = {}; params.split(/\s+/).forEach(pair => { const eq = pair.indexOf('='); if (eq > 0) { const k = pair.slice(0, eq).trim(); const v = pair.slice(eq + 1).trim(); if (k) (cfg as any)[k] = v; } }); } } // Infer type if missing (also support bare-word like `date-calc:birthday`) let type = (cfg as any)?.type ? (cfg.type as any).toString().toLowerCase().trim() : ''; if (!type) { // If YAML parsed into a string, decide if it's a bare type or a key=value expression if (typeof cfg === 'string') { const s = cfg.trim(); if (/^[A-Za-z][A-Za-z-]*$/.test(s)) { type = s.toLowerCase(); cfg = {}; } else if (s.includes('=')) { // Parse as key=value pairs const tmp: any = {}; s.split(/\s+/).forEach(pair => { const eq = pair.indexOf('='); if (eq > 0) { const k = pair.slice(0, eq).trim(); const v = pair.slice(eq + 1).trim(); if (k) tmp[k] = v; } }); cfg = tmp; } } } if (!type) { // If params is a single bare word without separators, treat as type if (/^[A-Za-z][A-Za-z-]*$/.test(params)) { type = params.toLowerCase(); } } if (!type) { if ((cfg as any).birthday || (cfg as any).birthdate) type = 'birthday'; else if ((cfg as any).to || (cfg as any).until) type = 'countdown'; else if ((cfg as any).since) type = 'since'; else if ((cfg as any).from && (cfg as any).to) type = 'diff'; } // Normalize type aliases to match fenced code block processor if (type === 'bday') type = 'birthday'; if (type === 'until') type = 'countdown'; if (type === 'difference') type = 'diff'; if (!type) return ''; // Render result similarly to the block processor let out = ''; try { switch (type) { case 'birthday': { const fileCache = app.metadataCache.getCache(sourcePath); const fm = fileCache?.frontmatter || {} as any; const bstr = cfg.birthday || cfg.birthdate || cfg.date || fm?.birthday || fm?.birthdate; const birthDate = parseDate(bstr); if (!birthDate) { out = 'date-calc: Missing or invalid "birthday" date.'; break; } const today = zeroTime(new Date()); const bd = zeroTime(birthDate); let age = today.getFullYear() - bd.getFullYear(); const m = today.getMonth() - bd.getMonth(); if (m < 0 || (m === 0 && today.getDate() < bd.getDate())) age--; let nextBirthday = zeroTime(new Date(today.getFullYear(), bd.getMonth(), bd.getDate())); if (today > nextBirthday) nextBirthday.setFullYear(today.getFullYear() + 1); const oneDay = 24 * 60 * 60 * 1000; const isBirthdayToday = (today.getMonth() === bd.getMonth() && today.getDate() === bd.getDate()); const daysUntil = isBirthdayToday ? 0 : Math.round((nextBirthday.getTime() - today.getTime()) / oneDay); let msg: string; if (daysUntil > 31) { let monthsUntil = (nextBirthday.getFullYear() - today.getFullYear()) * 12 + (nextBirthday.getMonth() - today.getMonth()); if (today.getDate() > nextBirthday.getDate()) monthsUntil--; const refDate = zeroTime(new Date(nextBirthday.getFullYear(), nextBirthday.getMonth() - monthsUntil, today.getDate())); const partialMonthDays = Math.round((nextBirthday.getTime() - refDate.getTime()) / oneDay); if (partialMonthDays > 15) monthsUntil += 0.5; msg = `Next birthday in ${monthsUntil} months.`; } else if (daysUntil === 0) { msg = 'Wish them Happy Birthday!'; } else if (daysUntil === 1) { msg = 'Their birthday is tomorrow!'; } else { msg = `Next birthday in ${daysUntil} days.`; } out = `Age: ${age} years. ${msg}`; break; } case 'countdown': case 'until': { const toStr = cfg.to || cfg.date || cfg.until; const toDate = parseDate(toStr); if (!toDate) { out = 'date-calc: Missing or invalid "to" date.'; break; } const fromDate = parseDate(cfg.from) || new Date(); const diffMs = toDate.getTime() - fromDate.getTime(); const label = cfg.label ? `${cfg.label}: ` : ''; out = diffMs >= 0 ? `${label}Countdown: ${humanizeDuration(diffMs)}` : `${label}Event passed ${humanizeDuration(-diffMs)} ago`; break; } case 'diff': case 'difference': { const fromStr = cfg.from || cfg.start; const toStr = cfg.to || cfg.end; const fromD = parseDate(fromStr); const toD = parseDate(toStr); if (!fromD || !toD) { out = 'date-calc: Provide valid "from" and "to" dates.'; break; } const diffMs = toD.getTime() - fromD.getTime(); out = `Difference: ${humanizeDuration(Math.abs(diffMs))}${diffMs < 0 ? ' (to is before from)' : ''}`; break; } case 'since': { const sinceStr = cfg.since || cfg.from || cfg.date; const sinceDate = parseDate(sinceStr); if (!sinceDate) { out = 'date-calc: Missing or invalid "since" date.'; break; } const diffMs = new Date().getTime() - sinceDate.getTime(); out = diffMs >= 0 ? `Since: ${humanizeDuration(diffMs)} ago` : `In: ${humanizeDuration(-diffMs)}`; break; } default: { out = 'date-calc: Unknown type. Supported types: birthday, countdown, diff, since'; } } } catch (e: any) { out = `date-calc error: ${e?.message || e}`; } return out; } // Widget for displaying date-calc results in Live Preview class DateCalcWidget extends WidgetType { constructor( private result: string, private app: App ) { super(); } eq(other: DateCalcWidget) { return other.result === this.result; } toDOM() { const span = document.createElement('span'); span.classList.add('date-calc-inline'); span.setAttribute('contenteditable', 'false'); span.textContent = this.result; return span; } ignoreEvent() { return false; } } // Live Preview ViewPlugin for inline date-calc processing const dateCalcLivePreviewPlugin = (app: App) => ViewPlugin.fromClass(class { decorations: DecorationSet = Decoration.none; constructor(view: EditorView) { this.buildDecorations(view); } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged) { this.buildDecorations(update.view); } } buildDecorations(view: EditorView) { const builder = new RangeSetBuilder(); const text = view.state.doc.toString(); // Find inline code spans that start with date-calc: // This regex looks for inline code between backticks const inlineCodeRegex = /`([^`]+)`/g; let match; while ((match = inlineCodeRegex.exec(text)) !== null) { const codeContent = match[1]; if (/^date-calc\s*:/.test(codeContent)) { const from = match.index; const to = match.index + match[0].length; // Get the active file path for processing const activeFile = app.workspace.getActiveFile(); const sourcePath = activeFile?.path || ''; // Process the date calculation const result = processInlineDateCalc(codeContent, app, sourcePath); if (result) { // Create a decoration that shows the result after the inline code const widget = Decoration.widget({ widget: new DateCalcWidget(` → ${result}`, app), side: 1 }); builder.add(to, to, widget); // Add a debug log to verify when a widget is being created console.log(`Live Preview widget created for: ${codeContent} with result: ${result}`); } } } this.decorations = builder.finish(); } }, { decorations: v => v.decorations }); // DateCalc Inline Renderer - following dataview's component-based approach class DateCalcInlineRenderer extends MarkdownRenderChild { constructor( public raw: string, containerEl: HTMLElement, public target: HTMLElement, public app: App, public sourcePath: string ) { super(containerEl); } async onload() { await this.render(); } async render() { const result = processInlineDateCalc(this.raw, this.app, this.sourcePath); if (result) { console.log(`Rendering inline date-calc: ${this.raw} -> ${result}`); // In Live Preview, just replace the code element directly const span = document.createElement('span'); span.classList.add('date-calc-inline'); span.setAttribute('contenteditable', 'false'); span.textContent = result; // Ensure we properly replace the target element try { this.target.parentNode?.replaceChild(span, this.target); console.log('Successfully replaced code element with result'); } catch (e) { console.error('Failed to replace code element:', e); // Fallback: try the original method this.target.replaceWith(span); } } else { console.log(`No result for inline date-calc: ${this.raw}`); } } } // Note: Removed CodeMirror live preview plugin to preserve editability // Following dataview's approach of using only post-processors export default class MyPlugin extends Plugin { settings: MyPluginSettings; async onload() { await this.loadSettings(); // Add a status bar item showing the plugin is enabled const statusBarItemEl = this.addStatusBarItem(); statusBarItemEl.setText('Date Calculator Enabled'); // Debugging command to test plugin functionality this.addCommand({ id: 'date-calc-debug', name: 'Debug Date Calculator', callback: () => { new Notice('Date Calculator plugin is working! Check console for more details.'); console.log('Date Calculator plugin debug info:'); console.log('Plugin version: 1.0.0'); console.log('Plugin enabled: true'); console.log('Current file:', this.app.workspace.getActiveFile()?.path); } }); // Command to refresh inline calculations (useful for troubleshooting) this.addCommand({ id: 'refresh-date-calc-inline', name: 'Refresh Date Calculations', callback: () => { // Force refresh by triggering layout this.app.workspace.trigger('layout-change'); new Notice('Date calculations refreshed!'); } }); // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new SampleSettingTab(this.app, this)); // CRITICAL: Register Live Preview extension FIRST and ensure it's properly loaded console.log('Registering Live Preview editor extension for date-calc...'); this.registerEditorExtension(dateCalcLivePreviewPlugin(this.app)); console.log('Date Calculator Live Preview extension registered successfully!'); // Add more comprehensive debugging new Notice('Date Calculator loaded with Live Preview support!'); // Register the `date-calc` code block processor this.registerMarkdownCodeBlockProcessor('date-calc', (source, el, ctx) => { let cfg: any = {}; try { cfg = parseYaml(source) || {}; } catch (e: any) { el.createEl('pre', { text: `date-calc: Invalid YAML: ${e?.message || e}` }); return; } const type = (cfg.type || '').toString().toLowerCase().trim(); const container = el.createDiv({ cls: 'date-calc' }); try { switch (type) { case 'bday': case 'birthday': { // Accept from config or frontmatter fallback const fileCache = this.app.metadataCache.getCache(ctx.sourcePath); const fm = fileCache?.frontmatter || {} as any; const bstr = cfg.birthday || cfg.birthdate || cfg.date || fm?.birthday || fm?.birthdate; const birthDate = bstr ? new Date(bstr + "T00:00:00") : null; if (!birthDate) { container.setText('date-calc: Missing or invalid "birthday" date.'); return; } // Get name and gender from config or frontmatter const name = cfg.name || fm?.name || "Person"; const gender = cfg.gender || fm?.gender || "neutral"; // Configurable label with default const ageLabel = cfg.label || "Age:"; // Format birthday as "July 6, 1998" const birthdayFormatted = birthDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); // Determine pronouns based on gender let pronoun = "their"; let possessive = "their"; if (gender.toLowerCase() === "male" || gender.toLowerCase() === "m") { pronoun = "his"; possessive = "his"; } else if (gender.toLowerCase() === "female" || gender.toLowerCase() === "f") { pronoun = "her"; possessive = "her"; } const today = zeroTime(new Date()); const bd = zeroTime(birthDate); let age = today.getFullYear() - bd.getFullYear(); const m = today.getMonth() - bd.getMonth(); if (m < 0 || (m === 0 && today.getDate() < bd.getDate())) age--; let nextBirthday = zeroTime(new Date(today.getFullYear(), bd.getMonth(), bd.getDate())); if (today > nextBirthday) nextBirthday.setFullYear(today.getFullYear() + 1); const oneDay = 24 * 60 * 60 * 1000; const daysUntil = Math.round((nextBirthday.getTime() - today.getTime()) / oneDay); let msg: string; if (daysUntil > 31) { let monthsUntil = (nextBirthday.getFullYear() - today.getFullYear()) * 12 + (nextBirthday.getMonth() - today.getMonth()); if (today.getDate() > nextBirthday.getDate()) monthsUntil--; const refDate = zeroTime(new Date(nextBirthday.getFullYear(), nextBirthday.getMonth() - monthsUntil, today.getDate())); const partialMonthDays = Math.round((nextBirthday.getTime() - refDate.getTime()) / oneDay); if (partialMonthDays > 15) monthsUntil += 0.5; msg = `next one is in ${monthsUntil} months.`; } else if (daysUntil === 0) { msg = `today! Wish ${pronoun} Happy Birthday!`; } else if (daysUntil === 1) { msg = `that's tomorrow!`; } else { msg = `next one is in ${daysUntil} days.`; } container.setText(`${name !== null ? name + ": " : ageLabel } ${age} years old. Birthday is ${birthdayFormatted}—${msg}`); break; } case 'countdown': case 'until': { const toStr = cfg.to || cfg.date || cfg.until; const toDate = parseDate(toStr); if (!toDate) { container.setText('date-calc: Missing or invalid "to" date.'); return; } const fromDate = parseDate(cfg.from) || new Date(); const diffMs = toDate.getTime() - fromDate.getTime(); const label = cfg.label ? `${cfg.label}: ` : ''; container.setText(diffMs >= 0 ? `${label}Countdown: ${humanizeDuration(diffMs)}` : `${label}Event passed ${humanizeDuration(-diffMs)} ago`); break; } case 'diff': case 'difference': { const fromStr = cfg.from || cfg.start; const toStr = cfg.to || cfg.end; const fromD = parseDate(fromStr); const toD = parseDate(toStr); if (!fromD || !toD) { container.setText('date-calc: Provide valid "from" and "to" dates.'); return; } const diffMs = toD.getTime() - fromD.getTime(); container.setText(`Difference: ${humanizeDuration(Math.abs(diffMs))}${diffMs < 0 ? ' (to is before from)' : ''}`); break; } case 'since': { const sinceStr = cfg.since || cfg.from || cfg.date; const sinceDate = parseDate(sinceStr); if (!sinceDate) { container.setText('date-calc: Missing or invalid "since" date.'); return; } const diffMs = new Date().getTime() - sinceDate.getTime(); container.setText(diffMs >= 0 ? `Since: ${humanizeDuration(diffMs)} ago` : `In: ${humanizeDuration(-diffMs)}`); break; } default: { container.setText('date-calc: Unknown type. Supported types: birthday, countdown, diff, since'); } } } catch (e: any) { container.setText(`date-calc error: ${e?.message || e}`); } }); // Inline code support: `date-calc: key=value ...` or YAML/inline-map // Following dataview's component-based approach - works in both reading view and live preview this.registerMarkdownPostProcessor((el, ctx) => { const codeNodes = el.querySelectorAll('code'); codeNodes.forEach((codeEl) => { // Skip code inside of pre elements (fenced code blocks) if (codeEl.parentElement && codeEl.parentElement.nodeName.toLowerCase() === 'pre') return; const raw = codeEl.textContent?.trim() || ''; if (/^date-calc\s*:/.test(raw)) { const renderer = new DateCalcInlineRenderer(raw, el, codeEl, this.app, ctx.sourcePath); ctx.addChild(renderer); } }); }); } onunload() { } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } } class SampleModal extends Modal { constructor(app: App) { super(app); } onOpen() { const {contentEl} = this; contentEl.setText('Woah!'); } onClose() { const {contentEl} = this; contentEl.empty(); } } class SampleSettingTab extends PluginSettingTab { plugin: MyPlugin; constructor(app: App, plugin: MyPlugin) { super(app, plugin); this.plugin = plugin; } display(): void { const {containerEl} = this; containerEl.empty(); containerEl.createEl('h2', {text: 'Date Calc Plugin Syntax'}); containerEl.createEl('p', {text: 'This plugin allows you to embed dynamic date calculations in your notes using code blocks or inline code.'}); // Fenced code block examples const blockSection = containerEl.createDiv(); blockSection.createEl('h3', {text: 'Fenced Code Block Examples'}); blockSection.createEl('h4', {text: '1. Birthday Calculation'}); blockSection.createEl('pre').createEl('code', {text: `\`\`\`date-calc type: birthday birthday: 1992-08-16 \`\`\``}); blockSection.createEl('p', {text: 'Shows age and time until next birthday. Can also use "birthdate" or "date" field.'}); blockSection.createEl('h4', {text: '2. Countdown to Event'}); blockSection.createEl('pre').createEl('code', {text: `\`\`\`date-calc type: countdown label: New Year to: 2025-12-31 23:59 \`\`\``}); blockSection.createEl('p', {text: 'Shows time remaining until the target date. Can also use "until" type.'}); blockSection.createEl('h4', {text: '3. Time Since Event'}); blockSection.createEl('pre').createEl('code', {text: `\`\`\`date-calc type: since since: 2024-01-01 \`\`\``}); blockSection.createEl('p', {text: 'Shows how much time has passed since an event.'}); blockSection.createEl('h4', {text: '4. Date Difference'}); blockSection.createEl('pre').createEl('code', {text: `\`\`\`date-calc type: diff from: 2024-01-01 to: 2024-12-31 \`\`\``}); blockSection.createEl('p', {text: 'Shows the difference between two specific dates.'}); // Inline examples const inlineSection = containerEl.createDiv(); inlineSection.createEl('h3', {text: 'Inline Code Examples'}); inlineSection.createEl('p', {text: 'You can also use inline code for quick calculations:'}); const inlineExamples = inlineSection.createEl('ul'); inlineExamples.createEl('li').innerHTML = '`date-calc: birthday=1992-08-16` → Birthday summary'; inlineExamples.createEl('li').innerHTML = '`date-calc: to=2025-12-31 label="New Year"` → Countdown'; inlineExamples.createEl('li').innerHTML = '`date-calc: since=2024-01-01` → Time since'; inlineExamples.createEl('li').innerHTML = '`date-calc: {type: diff, from: 2024-01-01, to: 2024-12-31}` → YAML format'; // Notes section const notesSection = containerEl.createDiv(); notesSection.createEl('h3', {text: 'Important Notes'}); const notesList = notesSection.createEl('ul'); notesList.createEl('li', {text: 'Date formats: Use YYYY-MM-DD or YYYY-MM-DD HH:mm format'}); notesList.createEl('li', {text: 'For birthdays: Can use frontmatter properties (birthday/birthdate) if not specified in the block'}); notesList.createEl('li', {text: 'Inline code only renders in Reading view or Live Preview mode'}); notesList.createEl('li', {text: 'All calculations use your local timezone'}); notesList.createEl('li', {text: 'YAML syntax must be valid - the plugin will show errors for invalid syntax'}); // Troubleshooting const troubleSection = containerEl.createDiv(); troubleSection.createEl('h3', {text: 'Troubleshooting'}); const troubleList = troubleSection.createEl('ul'); troubleList.createEl('li', {text: 'If inline code isn\'t rendering, try switching to Reading view'}); troubleList.createEl('li', {text: 'Use single backticks for inline code (not triple backticks)'}); troubleList.createEl('li', {text: 'If calculations seem wrong, check your date format and timezone'}); troubleList.createEl('li', {text: 'Error messages will appear in place of the calculation if something is wrong'}); } }