Files
date-calculator/main.ts
Olivier e5552e2d4c chore: update dependencies and clean up styles
- Added @codemirror/language and @lezer/common as devDependencies in package.json and package-lock.json.
- Removed inline date-calc styling and specific CodeMirror styles from styles.css for a cleaner design.
2026-01-04 19:59:31 -05:00

409 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Notice, Plugin, PluginSettingTab, Setting, parseYaml } 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, // default to concise
};
/* =========================
Helpers
========================= */
function zeroTime(d: Date): Date {
const nd = new Date(d);
nd.setHours(0, 0, 0, 0);
return nd;
}
function parseDate(input: any): Date | null {
if (!input) return null;
const d = new Date(input);
if (isNaN(d.getTime())) return null;
return d;
}
function humanizeDuration(ms: number): string {
const abs = Math.abs(ms);
let seconds = Math.floor(abs / 1000);
let minutes = Math.floor(seconds / 60);
let hours = Math.floor(minutes / 60);
let days = Math.floor(hours / 24);
const years = Math.floor(days / 365);
days = days % 365;
const months = Math.floor(days / 30);
days = days % 30;
hours = hours % 24;
minutes = minutes % 60;
seconds = seconds % 60;
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" : ""}`);
if (seconds && parts.length < 3) parts.push(`${seconds} second${seconds !== 1 ? "s" : ""}`);
if (!parts.length) return "0 seconds";
return parts.join(", ");
}
function isLivePreviewEditor(cm: EditorView): boolean {
// In Obsidian, the editor is inside .markdown-source-view
// Live Preview adds .is-live-preview on that container.
const container = cm.dom.closest(".markdown-source-view");
return !!container?.classList.contains("is-live-preview");
}
/* =========================
Inline processor
========================= */
function parseKv(params: string): Record<string, string> {
const cfg: Record<string, string> = {};
// key=value where value can be "..." or '...' or a bare token
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 {
// 1) If it looks like key=value syntax, parse that first (fast + reliable)
if (params.includes("=")) return parseKv(params);
// 2) Otherwise try YAML (for `{type: diff, from: ..., to: ...}` and similar)
try {
const v = parseYaml(params);
// If YAML returns a string, just return it (supports `date-calc: birthday`)
return v ?? {};
} catch {
// Last resort: empty
return {};
}
}
// raw should be WITHOUT backticks: "date-calc: birthday=1992-08-16"
function processInlineDateCalc(raw: string, app: App, sourcePath: string): string {
if (!/^date-calc\s*:/.test(raw)) return "";
const params = raw.replace(/^date-calc\s*:/, "").trim();
let cfg: any = params ? parseInlineArgs(params) : {};
// Support `date-calc: birthday`
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();
// Infer
if (!type) {
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";
if (!type) return "";
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 birthDate = parseDate(bstr);
if (!birthDate) return `date-calc: Missing or invalid "birthday" date.`;
const today = zeroTime(new Date());
const bd = zeroTime(birthDate);
let age = today.getFullYear() - bd.getFullYear();
const m = today.getMonth() - bd.getMonth();
if (m < 0 || (m === 0 && today.getDate() < bd.getDate())) age--;
const nextBirthday = zeroTime(new Date(today.getFullYear(), bd.getMonth(), bd.getDate()));
if (today > nextBirthday) nextBirthday.setFullYear(today.getFullYear() + 1);
const oneDay = 24 * 60 * 60 * 1000;
const isBirthdayToday = today.getMonth() === bd.getMonth() && today.getDate() === bd.getDate();
const daysUntil = isBirthdayToday ? 0 : Math.round((nextBirthday.getTime() - today.getTime()) / oneDay);
let msg = "";
if (daysUntil > 31) {
let monthsUntil =
(nextBirthday.getFullYear() - today.getFullYear()) * 12 +
(nextBirthday.getMonth() - today.getMonth());
if (today.getDate() > nextBirthday.getDate()) monthsUntil--;
msg = `Next birthday in ${monthsUntil} months.`;
} else if (daysUntil === 0) msg = "Wish them Happy Birthday!";
else if (daysUntil === 1) msg = "Their birthday is tomorrow!";
else msg = `Next birthday in ${daysUntil} days.`;
return `Age: ${age} years. ${msg}`;
}
case "countdown": {
const toStr = cfg.to || cfg.date || cfg.until;
const toDate = parseDate(toStr);
if (!toDate) return `date-calc: Missing or invalid "to" date.`;
const fromDate = parseDate(cfg.from) || new Date();
const diffMs = toDate.getTime() - fromDate.getTime();
const label = cfg.label ? `${cfg.label}: ` : "";
return diffMs >= 0
? `${label}Countdown: ${humanizeDuration(diffMs)}`
: `${label}Event passed ${humanizeDuration(-diffMs)} ago`;
}
case "diff": {
const fromD = parseDate(cfg.from || cfg.start);
const toD = parseDate(cfg.to || cfg.end);
if (!fromD || !toD) return `date-calc: Provide valid "from" and "to" dates.`;
const diffMs = toD.getTime() - fromD.getTime();
return `Difference: ${humanizeDuration(Math.abs(diffMs))}${diffMs < 0 ? " (to is before from)" : ""}`;
}
case "since": {
const sinceDate = parseDate(cfg.since || cfg.from || cfg.date);
if (!sinceDate) return `date-calc: Missing or invalid "since" date.`;
const diffMs = Date.now() - sinceDate.getTime();
return diffMs >= 0 ? `Since: ${humanizeDuration(diffMs)} ago` : `In: ${humanizeDuration(-diffMs)}`;
}
default:
return "date-calc: Unknown type. Supported: birthday, countdown, diff, since";
}
} catch (e: any) {
return `date-calc error: ${e?.message || e}`;
}
}
/* =========================
Live Preview decorations
========================= */
class DateCalcWidget extends WidgetType {
constructor(private text: string) { super(); }
eq(other: DateCalcWidget) { return other.text === this.text; }
toDOM() {
const span = document.createElement("span");
span.className = "date-calc-inline";
span.textContent = this.text;
span.setAttribute("contenteditable", "false");
return span;
}
}
export function dateCalcLivePreview(app: any, getSettings: () => { debug: boolean }) {
// Match only inline-code expressions (backticked)
const re = /`date-calc:[^`]*`/g;
// Key fix: scan beyond visibleRanges so matches don't get missed at viewport boundaries
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 {
// IMPORTANT: do nothing in Source mode so raw markdown stays visible
if (!isLivePreviewEditor(view)) return Decoration.none;
const b = new RangeSetBuilder<Decoration>();
const sourcePath = app.workspace.getActiveFile()?.path ?? "";
const head = view.state.selection.main.head;
let matchesSeen = 0;
let replaced = 0;
const used = new Set<string>();
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);
// If cursor is inside, don't replace (so user can edit the raw inline code)
if (head >= absStart && head <= absEnd) continue;
const inner = m[0].slice(1, -1).trim(); // remove backticks
const result = processInlineDateCalc(inner, app, sourcePath);
if (!result) continue;
// Replace the entire backticked expression with a widget
b.add(
absStart,
absEnd,
Decoration.replace({
widget: new DateCalcWidget(result),
inclusive: false,
})
);
replaced++;
}
}
if (getSettings().debug) {
console.log("[date-calc] replace scan", { matchesSeen, replaced, sourcePath });
}
return b.finish();
}
},
{
decorations: (v) => v.decorations,
// Important for replace widgets: treat them as “atomic” so cursor navigation behaves sanely
// (same pattern as CodeMirrors decoration examples)
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());
console.log("[date-calc] loaded");
new Notice("Date Calc loaded (Live Preview)");
// Live Preview requires editor extensions + decorations
this.registerEditorExtension(dateCalcLivePreview(this.app, () => this.settings)); // [page:2][page:1]
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"}`);
console.log("[date-calc] debug =", this.settings.debug);
},
});
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 the longer/wordier messages (your current output).")
.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`' });
}
}