From 32daa33af60f394003002f4a48122158ae45a838 Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 4 Jan 2026 21:10:35 -0500 Subject: [PATCH] fix codeblock --- main.ts | 535 +++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 352 insertions(+), 183 deletions(-) diff --git a/main.ts b/main.ts index a613f60..3bac087 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,6 @@ import { App, Notice, Plugin, PluginSettingTab, Setting, parseYaml, moment } from "obsidian"; import { RangeSetBuilder } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; import { Decoration, DecorationSet, @@ -9,43 +10,70 @@ import { 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"); } -/** - * 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; @@ -67,24 +95,19 @@ export function parseMoment(input: unknown): M | null { } 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(); @@ -114,10 +137,12 @@ function formatSpan(from: M, to: M, verbose: boolean): { text: string; isNegativ } } + /* ========================= - Inline args parsing + Config parsing & normalization ========================= */ + function parseKv(params: string): Record { const cfg: Record = {}; const rx = /(\w+)=("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|\S+)/g; @@ -137,27 +162,26 @@ function parseKv(params: string): Record { return cfg; } -function parseInlineArgs(params: string): any { + +function parseConfig(params: string): DateCalcConfig | string { 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 } { + + +function normalizeConfig(paramsRaw: string, cfg: any): { type: string; cfg: DateCalcConfig } { 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 = {}; @@ -167,7 +191,6 @@ function normalizeType(paramsRaw: string, cfg: any): { type: string; cfg: any } 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"; @@ -175,7 +198,6 @@ function normalizeType(paramsRaw: string, cfg: any): { type: string; cfg: any } else if (cfg.from && cfg.to) type = "diff"; } - // Aliases if (type === "bday") type = "birthday"; if (type === "until") type = "countdown"; if (type === "difference") type = "diff"; @@ -183,115 +205,32 @@ function normalizeType(paramsRaw: string, cfg: any): { type: string; cfg: any } return { type, cfg }; } + /* ========================= - Inline processor + Core date calculation logic ========================= */ -// raw should be WITHOUT backticks: "date-calc: birthday=1992-08-16" -function processInlineDateCalc( - raw: string, + +function calculateDateResult( + type: string, + cfg: DateCalcConfig, app: App, sourcePath: string, - settings: DateCalcSettings -): { text: string; tooltip?: string } { - if (!/^date-calc\s*:/.test(raw)) return { text: "" }; + verbose: boolean +): DateCalcResult { - 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; + const useVerbose = cfg.verbose !== undefined ? cfg.verbose : 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 }; - } - + 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" }; } @@ -300,28 +239,160 @@ function processInlineDateCalc( } } + +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 }; +} + + /* ========================= - Live Preview decorations + Processing entry points ========================= */ -class DateCalcWidget extends WidgetType { + +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(); } + ) { + super(); + } - eq(other: DateCalcWidget) { + 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"); - // store source range for click-to-edit span.dataset.dcFrom = String(this.from); span.dataset.dcTo = String(this.to); @@ -332,13 +403,63 @@ class DateCalcWidget extends WidgetType { return span; } - ignoreEvent() { return false; } // important for interaction [web:64] + ignoreEvent() { + return false; + } } -export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSettings) { - const re = /`date-calc:[^`]*`/g; - // Scan past viewport boundaries so matches don't flicker/miss near edges +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( @@ -356,101 +477,133 @@ export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSetting } 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 sel = view.state.selection.main; const b = new RangeSetBuilder(); const used = new Set(); - let matchesSeen = 0; - let replaced = 0; - + // 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); - re.lastIndex = 0; + inlineRe.lastIndex = 0; let m: RegExpExecArray | null; - while ((m = re.exec(slice)) !== null) { - matchesSeen++; - + while ((m = inlineRe.exec(slice)) !== null) { const absStart = scanFrom + m.index; const absEnd = absStart + m[0].length; - - // De-dupe overlaps from overlapping scan windows - const key = `${absStart}:${absEnd}`; + const key = `inline:${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; + const intersects = sel.from <= absEnd && sel.to >= absStart; + if (settings.hideResultWhileCursorInside && intersects) continue; - if (settings.hideResultWhileCursorInside && intersects) continue; - - - const inner = m[0].slice(1, -1).trim(); // remove backticks - const result = processInlineDateCalc(inner, app, sourcePath, settings); + 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 DateCalcWidget(result.text, result.tooltip, absStart, absEnd), - inclusive: false, - }) + widget: new DateCalcInlineWidget(result.text, result.tooltip, absStart, absEnd), + inclusive: false, + }) ); - replaced++; } } + // 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] replace scan", { matchesSeen, replaced, sourcePath }); + console.log("[date-calc] decorations built", { sourcePath }); } return b.finish(); } }, - { - decorations: (v) => v.decorations, + { + 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; + 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; + 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(); + view.dispatch({ + selection: { anchor: from, head: to }, + scrollIntoView: true, + }); + view.focus(); - return true; // CodeMirror will preventDefault when handler returns true [web:64] + return true; + }, }, - }, - provide: (plugin) => - EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none), - } + provide: (plugin) => + EditorView.atomicRanges.of( + (view) => view.plugin(plugin)?.decorations ?? Decoration.none + ), + } ); } + /* ========================= Plugin ========================= */ + export default class DateCalcPlugin extends Plugin { settings: DateCalcSettings; @@ -458,10 +611,22 @@ export default class DateCalcPlugin extends Plugin { 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)"); + new Notice("Date Calc loaded (Live Preview + Reading)"); + // 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", @@ -486,10 +651,12 @@ export default class DateCalcPlugin extends Plugin { } } + /* ========================= Settings tab ========================= */ + class DateCalcSettingTab extends PluginSettingTab { constructor(app: App, private plugin: DateCalcPlugin) { super(app, plugin); @@ -503,7 +670,7 @@ class DateCalcSettingTab extends PluginSettingTab { new Setting(containerEl) .setName("Debug logging") - .setDesc("Logs how many `date-calc:` inline-code matches were seen and decorated.") + .setDesc("Logs decoration activity to the console.") .addToggle((t) => t.setValue(this.plugin.settings.debug).onChange(async (v) => { this.plugin.settings.debug = v; @@ -512,8 +679,8 @@ class DateCalcSettingTab extends PluginSettingTab { ); new Setting(containerEl) - .setName("Hide result while cursor inside inline code") - .setDesc("Prevents the widget from showing while you edit the inline backticks.") + .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; @@ -531,6 +698,8 @@ class DateCalcSettingTab extends PluginSettingTab { }) ); - containerEl.createEl("p", { text: 'Example: `date-calc: birthday=1992-08-16`' }); + containerEl.createEl("h3", { text: "Usage Examples" }); + containerEl.createEl("p", { text: "Inline: `date-calc: birthday=1992-08-16`" }); + containerEl.createEl("p", { text: "Block: ```date-calc with YAML inside" }); } }