import { App, Notice, Plugin, PluginSettingTab, Setting, parseYaml, moment } 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, }; /* ========================= Moment helpers ========================= */ type M = moment.Moment; function isLivePreviewEditor(cm: EditorView): boolean { const container = cm.dom.closest(".markdown-source-view"); return !!container?.classList.contains("is-live-preview"); } /** * Parse various inputs into a Moment. * - "YYYY-MM-DD" => local startOf("day") * - YAML Date objects that represent UTC midnight => keep same calendar date locally * - Other date-time inputs => moment(...) as-is */ export function parseMoment(input: unknown): M | null { if (input == null || input === "") return null; // YAML can produce Date objects (often representing UTC midnight for date-only scalars) if (input instanceof Date) { if (Number.isNaN(input.getTime())) return null; const isUtcMidnight = input.getUTCHours() === 0 && input.getUTCMinutes() === 0 && input.getUTCSeconds() === 0 && input.getUTCMilliseconds() === 0; if (isUtcMidnight) { return moment({ year: input.getUTCFullYear(), month: input.getUTCMonth(), day: input.getUTCDate(), }).startOf("day"); } return moment(input); } const s = String(input).trim(); // Strict date-only => local midnight const dateOnly = moment(s, "YYYY-MM-DD", true); if (dateOnly.isValid()) return dateOnly.startOf("day"); // Fallback: let Moment interpret other formats (ISO with time/offset, etc.) const any = moment(s); return any.isValid() ? any : null; } function formatNiceDate(m: M): string { return m.format("MMMM Do, YYYY"); } /** * Calendar-aware breakdown (years, months, days, hours) with remainder behavior. * Returns a formatted string (verbose or concise). */ function formatSpan(from: M, to: M, verbose: boolean): { text: string; isNegative: boolean } { let a = from.clone(); let b = to.clone(); const isNegative = b.isBefore(a); if (isNegative) [a, b] = [b, a]; const years = b.diff(a, "years"); a.add(years, "years"); const months = b.diff(a, "months"); a.add(months, "months"); const days = b.diff(a, "days"); a.add(days, "days"); const hours = b.diff(a, "hours"); if (verbose) { 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"}`); return { text: parts.join(", ") || "0 days", isNegative }; } else { const parts: string[] = []; if (years) parts.push(`${years}y`); if (months) parts.push(`${months}mo`); if (days) parts.push(`${days}d`); if (hours && parts.length < 3) parts.push(`${hours}h`); return { text: parts.join(" ") || "0d", isNegative }; } } /* ========================= Inline args parsing ========================= */ function parseKv(params: string): Record { const cfg: Record = {}; 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 { if (!params) return {}; // Prefer key=value (fast, predictable) if (params.includes("=")) return parseKv(params); // Otherwise attempt YAML (supports `{ type: diff, from: ..., to: ... }`) try { const v = parseYaml(params); return v ?? {}; } catch { // Fall back to raw string type alias like: `date-calc: birthday` return params.trim(); } } function normalizeType(paramsRaw: string, cfg: any): { type: string; cfg: any } { const params = paramsRaw.trim(); let type = (cfg?.type ?? "").toString().toLowerCase().trim(); // `date-calc: birthday` 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(); cfg = {}; } // Infer from fields if (!type && cfg && typeof cfg === "object") { 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"; return { type, cfg }; } /* ========================= Inline processor ========================= */ // raw should be WITHOUT backticks: "date-calc: birthday=1992-08-16" function processInlineDateCalc( raw: string, app: App, sourcePath: string, settings: DateCalcSettings ): { text: string; tooltip?: string } { if (!/^date-calc\s*:/.test(raw)) return { text: "" }; const paramsRaw = raw.replace(/^date-calc\s*:/, "").trim(); let cfg: any = parseInlineArgs(paramsRaw); const norm = normalizeType(paramsRaw, cfg); const type = norm.type; cfg = norm.cfg; if (!type) return { text: "" }; const verbose = settings.verbose; 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 bd = parseMoment(bstr)?.startOf("day"); if (!bd) return { text: `date-calc: Missing or invalid "birthday" date.` }; const today = moment().startOf("day"); const age = today.diff(bd, "years"); const next = bd.clone().year(today.year()); if (next.isBefore(today, "day")) next.add(1, "year"); const daysUntil = next.diff(today, "days"); let msg: string; if (daysUntil === 0) msg = verbose ? "Wish them Happy Birthday!" : "Birthday!"; else if (daysUntil === 1) msg = verbose ? "Their birthday is tomorrow!" : "Tomorrow!"; else if (daysUntil > 31) { const monthsUntil = next.diff(today, "months"); msg = verbose ? `Next birthday in ${monthsUntil} months.` : `Next in ${monthsUntil}mo`; } else { msg = verbose ? `Next birthday in ${daysUntil} days.` : `Next in ${daysUntil}d`; } const ageStr = verbose ? `${age} years` : `${age}y`; return { text: `Age: ${ageStr}. ${msg}`, tooltip: formatNiceDate(bd), }; } case "countdown": { const to = parseMoment(cfg.to || cfg.date || cfg.until); if (!to) return { text: `date-calc: Missing or invalid "to" date.` }; const from = parseMoment(cfg.from) ?? moment(); const label = cfg.label ? `${cfg.label}: ` : ""; const span = formatSpan(from, to, verbose); const text = !span.isNegative ? `${label}${verbose ? "Countdown: " : ""}${span.text}` : `${label}${verbose ? "Event passed " : ""}${span.text}${verbose ? " ago" : ""}`; return { text }; } case "diff": { const from = parseMoment(cfg.from || cfg.start); const to = parseMoment(cfg.to || cfg.end); if (!from || !to) return { text: `date-calc: Provide valid "from" and "to" dates.` }; const span = formatSpan(from, to, verbose); const text = verbose ? `Difference: ${span.text}${span.isNegative ? " (to is before from)" : ""}` : `Diff: ${span.text}${span.isNegative ? " (reverse)" : ""}`; return { text }; } case "since": { const since = parseMoment(cfg.since || cfg.from || cfg.date); if (!since) return { text: `date-calc: Missing or invalid "since" date.` }; const now = moment(); const span = formatSpan(since, now, verbose); const text = !span.isNegative ? `Since: ${span.text}${verbose ? " ago" : ""}` : `In: ${span.text}`; return { text }; } default: return { text: "date-calc: Unknown type. Supported: birthday, countdown, diff, since" }; } } catch (e: any) { return { text: `date-calc error: ${e?.message || e}` }; } } /* ========================= Live Preview decorations ========================= */ class DateCalcWidget extends WidgetType { constructor( private text: string, private tooltip: string | undefined, private from: number, private to: number ) { super(); } eq(other: DateCalcWidget) { return other.text === this.text && other.tooltip === this.tooltip; } toDOM() { const span = document.createElement("span"); span.className = "date-calc-inline"; span.textContent = this.text; span.setAttribute("contenteditable", "false"); // store source range for click-to-edit span.dataset.dcFrom = String(this.from); span.dataset.dcTo = String(this.to); if (this.tooltip) { span.className += " has-tooltip"; span.setAttribute("aria-label", this.tooltip); } return span; } ignoreEvent() { return false; } // important for interaction [web:64] } export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSettings) { const re = /`date-calc:[^`]*`/g; // Scan past viewport boundaries so matches don't flicker/miss near edges 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 { // Do nothing in Source mode so raw markdown stays visible if (!isLivePreviewEditor(view)) return Decoration.none; const settings = getSettings(); const sourcePath = app.workspace.getActiveFile()?.path ?? ""; const head = view.state.selection.main.head; const b = new RangeSetBuilder(); const used = new Set(); let matchesSeen = 0; let replaced = 0; 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); // Optional: keep raw inline code visible while editing it const sel = view.state.selection.main; const intersects = sel.from <= absEnd && sel.to >= absStart; if (settings.hideResultWhileCursorInside && intersects) continue; const inner = m[0].slice(1, -1).trim(); // remove backticks const result = processInlineDateCalc(inner, app, sourcePath, settings); if (!result.text) continue; b.add( absStart, absEnd, Decoration.replace({ widget: new DateCalcWidget(result.text, result.tooltip, absStart, absEnd), inclusive: false, }) ); replaced++; } } if (settings.debug) { console.log("[date-calc] replace scan", { matchesSeen, replaced, sourcePath }); } return b.finish(); } }, { decorations: (v) => v.decorations, eventHandlers: { mousedown: (e, view) => { const el = (e.target as HTMLElement | null)?.closest?.(".date-calc-inline") as HTMLElement | null; if (!el) return false; const from = Number(el.dataset.dcFrom); const to = Number(el.dataset.dcTo); if (!Number.isFinite(from) || !Number.isFinite(to)) return false; // Select the whole backticked expression so it immediately “reveals” for editing view.dispatch({ selection: { anchor: from, head: to }, scrollIntoView: true, }); view.focus(); return true; // CodeMirror will preventDefault when handler returns true [web:64] }, }, 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()); if (this.settings.debug) console.log("[date-calc] loaded"); new Notice("Date Calc loaded (Live Preview)"); this.registerEditorExtension(dateCalcLivePreview(this.app, () => this.settings)); 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"}`); }, }); this.addCommand({ id: "date-calc-toggle-verbose", name: "Date Calc: Toggle verbose output", callback: async () => { this.settings.verbose = !this.settings.verbose; await this.saveData(this.settings); new Notice(`Date Calc verbose: ${this.settings.verbose ? "ON" : "OFF"}`); }, }); 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 longer/wordier messages.") .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`' }); } }