import { App, Notice, Plugin, PluginSettingTab, Setting, parseYaml } from "obsidian"; import { RangeSetBuilder } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType, } from "@codemirror/view"; /* ========================= Settings ========================= */ interface DateCalcSettings { debug: boolean; hideResultWhileCursorInside: boolean; verbose: boolean; } const DEFAULT_SETTINGS: DateCalcSettings = { debug: false, hideResultWhileCursorInside: true, verbose: false, // default to concise }; /* ========================= Helpers ========================= */ 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); const years = Math.floor(days / 365); days = days % 365; const 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(", "); } function isLivePreviewEditor(cm: EditorView): boolean { // In Obsidian, the editor is inside .markdown-source-view // Live Preview adds .is-live-preview on that container. const container = cm.dom.closest(".markdown-source-view"); return !!container?.classList.contains("is-live-preview"); } /* ========================= Inline processor ========================= */ function parseKv(params: string): Record { const cfg: Record = {}; // key=value where value can be "..." or '...' or a bare token const rx = /(\w+)=("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|\S+)/g; let m: RegExpExecArray | null; while ((m = rx.exec(params)) !== null) { const key = m[1]; let val = m[2]; if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } cfg[key] = val; } return cfg; } function parseInlineArgs(params: string): any { // 1) If it looks like key=value syntax, parse that first (fast + reliable) if (params.includes("=")) return parseKv(params); // 2) Otherwise try YAML (for `{type: diff, from: ..., to: ...}` and similar) try { const v = parseYaml(params); // If YAML returns a string, just return it (supports `date-calc: birthday`) return v ?? {}; } catch { // Last resort: empty return {}; } } // raw should be WITHOUT backticks: "date-calc: birthday=1992-08-16" 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 = params ? parseInlineArgs(params) : {}; // Support `date-calc: birthday` let type = (cfg?.type ?? "").toString().toLowerCase().trim(); if (!type && typeof cfg === "string" && /^[A-Za-z][A-Za-z-]*$/.test(cfg.trim())) { type = cfg.trim().toLowerCase(); cfg = {}; } if (!type && /^[A-Za-z][A-Za-z-]*$/.test(params)) type = params.toLowerCase(); // Infer if (!type) { if (cfg.birthday || cfg.birthdate) type = "birthday"; else if (cfg.to || cfg.until) type = "countdown"; else if (cfg.since) type = "since"; else if (cfg.from && cfg.to) type = "diff"; } // Aliases if (type === "bday") type = "birthday"; if (type === "until") type = "countdown"; if (type === "difference") type = "diff"; if (!type) return ""; 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) return `date-calc: Missing or invalid "birthday" date.`; 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--; const 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 = ""; if (daysUntil > 31) { let monthsUntil = (nextBirthday.getFullYear() - today.getFullYear()) * 12 + (nextBirthday.getMonth() - today.getMonth()); if (today.getDate() > nextBirthday.getDate()) monthsUntil--; 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.`; return `Age: ${age} years. ${msg}`; } case "countdown": { const toStr = cfg.to || cfg.date || cfg.until; const toDate = parseDate(toStr); if (!toDate) return `date-calc: Missing or invalid "to" date.`; const fromDate = parseDate(cfg.from) || new Date(); const diffMs = toDate.getTime() - fromDate.getTime(); const label = cfg.label ? `${cfg.label}: ` : ""; return diffMs >= 0 ? `${label}Countdown: ${humanizeDuration(diffMs)}` : `${label}Event passed ${humanizeDuration(-diffMs)} ago`; } case "diff": { const fromD = parseDate(cfg.from || cfg.start); const toD = parseDate(cfg.to || cfg.end); if (!fromD || !toD) return `date-calc: Provide valid "from" and "to" dates.`; const diffMs = toD.getTime() - fromD.getTime(); return `Difference: ${humanizeDuration(Math.abs(diffMs))}${diffMs < 0 ? " (to is before from)" : ""}`; } case "since": { const sinceDate = parseDate(cfg.since || cfg.from || cfg.date); if (!sinceDate) return `date-calc: Missing or invalid "since" date.`; const diffMs = Date.now() - sinceDate.getTime(); return diffMs >= 0 ? `Since: ${humanizeDuration(diffMs)} ago` : `In: ${humanizeDuration(-diffMs)}`; } default: return "date-calc: Unknown type. Supported: birthday, countdown, diff, since"; } } catch (e: any) { return `date-calc error: ${e?.message || e}`; } } /* ========================= Live Preview decorations ========================= */ class DateCalcWidget extends WidgetType { constructor(private text: string) { super(); } eq(other: DateCalcWidget) { return other.text === this.text; } toDOM() { const span = document.createElement("span"); span.className = "date-calc-inline"; span.textContent = this.text; span.setAttribute("contenteditable", "false"); return span; } } export function dateCalcLivePreview(app: any, getSettings: () => { debug: boolean }) { // Match only inline-code expressions (backticked) const re = /`date-calc:[^`]*`/g; // Key fix: scan beyond visibleRanges so matches don't get missed at viewport boundaries const margin = 2000; return ViewPlugin.fromClass( class { decorations: DecorationSet = Decoration.none; constructor(view: EditorView) { this.decorations = this.build(view); } update(u: ViewUpdate) { if (u.docChanged || u.viewportChanged || u.selectionSet) { this.decorations = this.build(u.view); } } build(view: EditorView): DecorationSet { // IMPORTANT: do nothing in Source mode so raw markdown stays visible if (!isLivePreviewEditor(view)) return Decoration.none; const b = new RangeSetBuilder(); const sourcePath = app.workspace.getActiveFile()?.path ?? ""; const head = view.state.selection.main.head; let matchesSeen = 0; let replaced = 0; const used = new Set(); for (const r of view.visibleRanges) { const scanFrom = Math.max(0, r.from - margin); const scanTo = Math.min(view.state.doc.length, r.to + margin); const slice = view.state.doc.sliceString(scanFrom, scanTo); re.lastIndex = 0; let m: RegExpExecArray | null; while ((m = re.exec(slice)) !== null) { matchesSeen++; const absStart = scanFrom + m.index; const absEnd = absStart + m[0].length; // de-dupe overlaps from overlapping scan windows const key = `${absStart}:${absEnd}`; if (used.has(key)) continue; used.add(key); // If cursor is inside, don't replace (so user can edit the raw inline code) if (head >= absStart && head <= absEnd) continue; const inner = m[0].slice(1, -1).trim(); // remove backticks const result = processInlineDateCalc(inner, app, sourcePath); if (!result) continue; // Replace the entire backticked expression with a widget b.add( absStart, absEnd, Decoration.replace({ widget: new DateCalcWidget(result), inclusive: false, }) ); replaced++; } } if (getSettings().debug) { console.log("[date-calc] replace scan", { matchesSeen, replaced, sourcePath }); } return b.finish(); } }, { decorations: (v) => v.decorations, // Important for replace widgets: treat them as “atomic” so cursor navigation behaves sanely // (same pattern as CodeMirror’s decoration examples) provide: (plugin) => EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none), } ); } /* ========================= Plugin ========================= */ export default class DateCalcPlugin extends Plugin { settings: DateCalcSettings; async onload() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); console.log("[date-calc] loaded"); new Notice("Date Calc loaded (Live Preview)"); // Live Preview requires editor extensions + decorations this.registerEditorExtension(dateCalcLivePreview(this.app, () => this.settings)); // [page:2][page:1] this.addCommand({ id: "date-calc-toggle-debug", name: "Date Calc: Toggle debug", callback: async () => { this.settings.debug = !this.settings.debug; await this.saveData(this.settings); new Notice(`Date Calc debug: ${this.settings.debug ? "ON" : "OFF"}`); console.log("[date-calc] debug =", this.settings.debug); }, }); this.addSettingTab(new DateCalcSettingTab(this.app, this)); } } /* ========================= Settings tab ========================= */ class DateCalcSettingTab extends PluginSettingTab { constructor(app: App, private plugin: DateCalcPlugin) { super(app, plugin); } display(): void { const { containerEl } = this; containerEl.empty(); containerEl.createEl("h2", { text: "Date Calc" }); new Setting(containerEl) .setName("Debug logging") .setDesc("Logs how many `date-calc:` inline-code matches were seen and decorated.") .addToggle((t) => t.setValue(this.plugin.settings.debug).onChange(async (v) => { this.plugin.settings.debug = v; await this.plugin.saveData(this.plugin.settings); }) ); new Setting(containerEl) .setName("Hide result while cursor inside inline code") .setDesc("Prevents the widget from showing while you edit the inline backticks.") .addToggle((t) => t.setValue(this.plugin.settings.hideResultWhileCursorInside).onChange(async (v) => { this.plugin.settings.hideResultWhileCursorInside = v; await this.plugin.saveData(this.plugin.settings); }) ); new Setting(containerEl) .setName("Verbose output") .setDesc("When enabled, show the longer/wordier messages (your current output).") .addToggle(t => t.setValue(this.plugin.settings.verbose).onChange(async (v) => { this.plugin.settings.verbose = v; await this.plugin.saveData(this.plugin.settings); }) ); containerEl.createEl("p", { text: 'Example: `date-calc: birthday=1992-08-16`' }); } }