Files
date-calculator/main.ts
2026-01-04 20:42:36 -05:00

537 lines
16 KiB
TypeScript

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<string, string> {
const cfg: Record<string, string> = {};
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<Decoration>();
const used = new Set<string>();
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`' });
}
}