import { App, Notice, Plugin, PluginSettingTab, Setting, parseYaml, moment } from "obsidian"; import { RangeSetBuilder } from "@codemirror/state"; import { syntaxTree } from "@codemirror/language"; 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, }; /* ========================= Types & Interfaces ========================= */ interface DateCalcResult { text: string; tooltip?: string; } interface DateCalcConfig { type?: string; birthday?: string; birthdate?: string; date?: string; from?: string; to?: string; until?: string; since?: string; start?: string; end?: string; label?: string; verbose?: boolean; } /* ========================= 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"); } export function parseMoment(input: unknown): M | null { if (input == null || input === "") return null; 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(); const dateOnly = moment(s, "YYYY-MM-DD", true); if (dateOnly.isValid()) return dateOnly.startOf("day"); const any = moment(s); return any.isValid() ? any : null; } function formatNiceDate(m: M): string { return m.format("MMMM Do, YYYY"); } 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"); a.add(hours, "hours"); const minutes = b.diff(a, "minutes"); 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"}`); if (minutes && parts.length < 3) parts.push(`${minutes} minute${minutes === 1 ? "" : "s"}`); return { text: parts.join(", ") || "0 minutes", 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`); if (minutes && parts.length < 3) parts.push(`${minutes}m`); return { text: parts.join(" ") || "0m", isNegative }; } } /* ========================= Config parsing & normalization ========================= */ 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 parseConfig(params: string): DateCalcConfig | string { if (!params) return {}; if (params.includes("=")) return parseKv(params); try { const v = parseYaml(params); return v ?? {}; } catch { return params.trim(); } } function normalizeConfig(paramsRaw: string, cfg: any): { type: string; cfg: DateCalcConfig } { const params = paramsRaw.trim(); 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(); cfg = {}; } 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"; } if (type === "bday") type = "birthday"; if (type === "until") type = "countdown"; if (type === "difference") type = "diff"; return { type, cfg }; } /* ========================= Core date calculation logic ========================= */ function calculateDateResult( type: string, cfg: DateCalcConfig, app: App, sourcePath: string, verbose: boolean ): DateCalcResult { const useVerbose = cfg.verbose !== undefined ? cfg.verbose : verbose; try { switch (type) { case "birthday": return calculateBirthday(cfg, app, sourcePath, useVerbose); case "countdown": return calculateCountdown(cfg, useVerbose); case "diff": return calculateDiff(cfg, useVerbose); case "since": return calculateSince(cfg, useVerbose); default: return { text: "date-calc: Unknown type. Supported: birthday, countdown, diff, since" }; } } catch (e: any) { return { text: `date-calc error: ${e?.message || e}` }; } } function calculateBirthday( cfg: DateCalcConfig, app: App, sourcePath: string, verbose: boolean ): DateCalcResult { 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!" : "Happy bday!"; } else if (daysUntil === 1) { msg = verbose ? "Their birthday is tomorrow!" : "Bday's tomorrow!"; } else if (daysUntil > 31) { const monthsUntil = next.diff(today, "months"); msg = verbose ? `Next birthday in ${monthsUntil} months.` : `Next bday in ${monthsUntil}mo`; } else { msg = verbose ? `Next birthday in ${daysUntil} days.` : `Next bday in ${daysUntil}d`; } const ageStr = verbose ? `${age} years old` : `${age}y`; return { text: `${ageStr}. ${msg}`, tooltip: formatNiceDate(bd) }; } function calculateCountdown(cfg: DateCalcConfig, verbose: boolean): DateCalcResult { 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 }; } function calculateDiff(cfg: DateCalcConfig, verbose: boolean): DateCalcResult { 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 }; } function calculateSince(cfg: DateCalcConfig, verbose: boolean): DateCalcResult { 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 }; } /* ========================= Processing entry points ========================= */ function processInlineCode( raw: string, app: App, sourcePath: string, settings: DateCalcSettings ): DateCalcResult { if (!/^date-calc\s*:/.test(raw)) return { text: "" }; const paramsRaw = raw.replace(/^date-calc\s*:/, "").trim(); const cfg: any = parseConfig(paramsRaw); const norm = normalizeConfig(paramsRaw, cfg); if (!norm.type) return { text: "" }; return calculateDateResult(norm.type, norm.cfg, app, sourcePath, settings.verbose); } function processFencedBlock( source: string, app: App, sourcePath: string, settings: DateCalcSettings ): DateCalcResult { let cfg: any = {}; try { cfg = parseYaml(source) ?? {}; } catch { cfg = parseConfig(source.trim()); } const norm = normalizeConfig("", cfg); if (!norm.type) { return { text: 'date-calc: Missing "type" (birthday/countdown/diff/since).' }; } return calculateDateResult(norm.type, norm.cfg, app, sourcePath, settings.verbose); } /* ========================= Widgets ========================= */ class DateCalcInlineWidget extends WidgetType { constructor( private text: string, private tooltip: string | undefined, private from: number, private to: number ) { super(); } eq(other: DateCalcInlineWidget) { 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"); 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; } } class DateCalcBlockWidget extends WidgetType { constructor(private text: string, private tooltip?: string) { super(); } eq(other: DateCalcBlockWidget) { return other.text === this.text && other.tooltip === this.tooltip; } toDOM() { const div = document.createElement("div"); div.className = "date-calc-block"; div.textContent = this.text; div.setAttribute("contenteditable", "false"); if (this.tooltip) div.setAttribute("aria-label", this.tooltip); return div; } } /* ========================= Helpers for fenced code ========================= */ function parseFencedCode(text: string): { lang: string; body: string } | null { const lines = text.split("\n"); if (lines.length < 2) return null; const m = lines[0].match(/^(\s*)(`{3,}|~{3,})(.*)$/); if (!m) return null; const fence = m[2]; const info = (m[3] ?? "").trim(); const lang = (info.split(/\s+/)[0] ?? "").toLowerCase(); const last = lines[lines.length - 1].trim(); if (!last.startsWith(fence)) return null; const body = lines.slice(1, -1).join("\n"); return { lang, body }; } /* ========================= Live Preview decorations ========================= */ export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSettings) { const inlineRe = /`date-calc:[^`]*`/g; 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 { if (!isLivePreviewEditor(view)) return Decoration.none; const settings = getSettings(); const sourcePath = app.workspace.getActiveFile()?.path ?? ""; const sel = view.state.selection.main; const b = new RangeSetBuilder(); const used = new Set(); // Process inline code 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); inlineRe.lastIndex = 0; let m: RegExpExecArray | null; while ((m = inlineRe.exec(slice)) !== null) { const absStart = scanFrom + m.index; const absEnd = absStart + m[0].length; const key = `inline:${absStart}:${absEnd}`; if (used.has(key)) continue; used.add(key); const intersects = sel.from <= absEnd && sel.to >= absStart; if (settings.hideResultWhileCursorInside && intersects) continue; const inner = m[0].slice(1, -1).trim(); const result = processInlineCode(inner, app, sourcePath, settings); if (!result.text) continue; b.add( absStart, absEnd, Decoration.replace({ widget: new DateCalcInlineWidget(result.text, result.tooltip, absStart, absEnd), inclusive: false, }) ); } } // Process fenced code blocks 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); syntaxTree(view.state).iterate({ from: scanFrom, to: scanTo, enter: (node) => { if (node.name !== "FencedCode") return; const absStart = node.from; const absEnd = node.to; const key = `block:${absStart}:${absEnd}`; if (used.has(key)) return; used.add(key); const intersects = sel.from <= absEnd && sel.to >= absStart; if (settings.hideResultWhileCursorInside && intersects) return; const blockText = view.state.doc.sliceString(absStart, absEnd); const parsed = parseFencedCode(blockText); if (!parsed || parsed.lang !== "date-calc") return; const result = processFencedBlock(parsed.body, app, sourcePath, settings); if (!result.text) return; b.add( absEnd, absEnd, Decoration.widget({ widget: new DateCalcBlockWidget(result.text, result.tooltip), side: 1, block: true, }) ); }, }); } if (settings.debug) { console.log("[date-calc] decorations built", { 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; view.dispatch({ selection: { anchor: from, head: to }, scrollIntoView: true, }); view.focus(); return true; }, }, 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"); // Register Live Preview editor extension this.registerEditorExtension(dateCalcLivePreview(this.app, () => this.settings)); // Register Reading mode fenced code block processor this.registerMarkdownCodeBlockProcessor("date-calc", (source, el, ctx) => { const result = processFencedBlock(source, this.app, ctx.sourcePath, this.settings); el.empty(); el.createDiv({ cls: "date-calc-block", text: result.text }); if (result.tooltip) { el.setAttribute("aria-label", result.tooltip); } }); // Commands 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" }); // Add documentation link const docsDiv = containerEl.createDiv({ cls: "setting-item-description" }); docsDiv.createEl("a", { text: "📖 View Full Documentation", href: "https://github.com/thelegend09/date-calculator", }); docsDiv.style.marginBottom = "1em"; new Setting(containerEl) .setName("Debug logging") .setDesc("Logs decoration activity to the console.") .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") .setDesc("Prevents the widget from showing while you edit inline/fenced code.") .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. Can be overridden per-calculation with verbose=true/false.") .addToggle((t) => t.setValue(this.plugin.settings.verbose).onChange(async (v) => { this.plugin.settings.verbose = v; await this.plugin.saveData(this.plugin.settings); }) ); // Usage examples section containerEl.createEl("h3", { text: "Usage Examples" }); const examplesDiv = containerEl.createDiv({ cls: "date-calc-examples" }); examplesDiv.style.fontSize = "0.9em"; examplesDiv.style.lineHeight = "1.6"; // Inline examples examplesDiv.createEl("h4", { text: "Inline Code (single backticks)" }); const inlineList = examplesDiv.createEl("ul"); inlineList.createEl("li").setText("`date-calc: birthday=2003-02-15`"); inlineList.createEl("li").setText("`date-calc: countdown to=2026-12-31 label=\"New Year\"`"); inlineList.createEl("li").setText("`date-calc: since=2024-01-01`"); inlineList.createEl("li").setText("`date-calc: diff from=2024-01-01 to=2024-12-31`"); inlineList.createEl("li").setText("`date-calc: birthday=1992-08-16 verbose=true`"); // Block examples examplesDiv.createEl("h4", { text: "Fenced Blocks (triple backticks)" }); const blockExample1 = examplesDiv.createEl("pre"); blockExample1.style.background = "var(--background-secondary)"; blockExample1.style.padding = "8px"; blockExample1.style.borderRadius = "4px"; blockExample1.style.marginBottom = "8px"; blockExample1.setText( "```date-calc\n" + "type: birthday\n" + "birthday: 1992-08-16\n" + "```" ); const blockExample2 = examplesDiv.createEl("pre"); blockExample2.style.background = "var(--background-secondary)"; blockExample2.style.padding = "8px"; blockExample2.style.borderRadius = "4px"; blockExample2.style.marginBottom = "8px"; blockExample2.setText( "```date-calc\n" + "type: countdown\n" + "to: 2026-12-31 23:59\n" + "label: New Year\n" + "verbose: true\n" + "```" ); const blockExample3 = examplesDiv.createEl("pre"); blockExample3.style.background = "var(--background-secondary)"; blockExample3.style.padding = "8px"; blockExample3.style.borderRadius = "4px"; blockExample3.setText( "```date-calc\n" + "type: diff\n" + "from: 2024-01-01\n" + "to: 2024-12-31\n" + "```" ); // Tips section examplesDiv.createEl("h4", { text: "Tips" }); const tipsList = examplesDiv.createEl("ul"); tipsList.createEl("li").setText("Use YYYY-MM-DD format for dates"); tipsList.createEl("li").setText("Add time with YYYY-MM-DD HH:mm for precise countdowns"); tipsList.createEl("li").setText("Supported types: birthday, countdown, since, diff"); tipsList.createEl("li").setText("Add verbose=true/false to override global setting"); } }