fix codeblock

This commit is contained in:
2026-01-04 21:10:35 -05:00
parent 83846e7602
commit 32daa33af6

397
main.ts
View File

@@ -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<string, string> {
const cfg: Record<string, string> = {};
const rx = /(\w+)=("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|\S+)/g;
@@ -137,27 +162,26 @@ function parseKv(params: string): Record<string, string> {
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,78 +205,84 @@ 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": {
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 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) {
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 in ${monthsUntil}mo`;
msg = verbose ? `Next birthday in ${monthsUntil} months.` : `Next bday in ${monthsUntil}mo`;
} else {
msg = verbose ? `Next birthday in ${daysUntil} days.` : `Next in ${daysUntil}d`;
msg = verbose ? `Next birthday in ${daysUntil} days.` : `Next bday in ${daysUntil}d`;
}
const ageStr = verbose ? `${age} years` : `${age}y`;
return {
text: `Age: ${ageStr}. ${msg}`,
tooltip: formatNiceDate(bd),
};
}
const ageStr = verbose ? `${age} years old` : `${age}y`;
return { text: `${ageStr}. ${msg}`, tooltip: formatNiceDate(bd) };
}
case "countdown": {
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
@@ -262,9 +290,10 @@ function processInlineDateCalc(
: `${label}${verbose ? "Event passed " : ""}${span.text}${verbose ? " ago" : ""}`;
return { text };
}
}
case "diff": {
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.` };
@@ -276,9 +305,10 @@ function processInlineDateCalc(
: `Diff: ${span.text}${span.isNegative ? " (reverse)" : ""}`;
return { text };
}
}
case "since": {
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.` };
@@ -290,38 +320,79 @@ function processInlineDateCalc(
: `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
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,63 +477,90 @@ 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<Decoration>();
const used = new Set<string>();
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;
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),
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();
@@ -423,34 +571,39 @@ export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSetting
eventHandlers: {
mousedown: (e, view) => {
const el = (e.target as HTMLElement | null)?.closest?.(".date-calc-inline") as HTMLElement | null;
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]
return true;
},
},
provide: (plugin) =>
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none),
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" });
}
}