Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 526f54a640 |
@@ -4,7 +4,6 @@ An Obsidian plugin that adds dynamic date calculations to your notes using `date
|
|||||||
|
|
||||||
|
|
||||||
## Todo
|
## Todo
|
||||||
- [ ] Make everything work not only in Live Preview, but Reading also.
|
|
||||||
- [ ] Add custom language (default to Obsidian language, overridable)
|
- [ ] Add custom language (default to Obsidian language, overridable)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,20 +23,20 @@ birthday: 1992-08-16
|
|||||||
|
|
||||||
Use inline code for quick calculations: `date-calc: birthday=1992-08-16`
|
Use inline code for quick calculations: `date-calc: birthday=1992-08-16`
|
||||||
|
|
||||||
**Note about Live Preview**: Inline code works in both Reading view and Live Preview mode. In Live Preview, the result appears after the inline code (e.g., `date-calc: birthday=1992-08-16` → Age: 32 years old...). However, inline code does **not** work for live-preview in Source mode - you'll see the literal code until you switch to Reading view or Live Preview mode.
|
**Rendering modes**: Inline code and fenced blocks both render in Reading view and Live Preview mode. In Source mode, you'll see the literal `date-calc:` code until you switch to Reading view or Live Preview.
|
||||||
|
|
||||||
## Supported Calculation Types
|
## Supported Calculation Types
|
||||||
|
|
||||||
### Birthday (`birthday`)
|
### Birthday (`birthday`)
|
||||||
|
|
||||||
Calculates age and time until next birthday with enhanced personalization features.
|
Calculates age and time until next birthday, with an optional custom label and automatic personalization from your note's frontmatter.
|
||||||
|
|
||||||
**Fields:**
|
**Fields:**
|
||||||
|
|
||||||
- `birthday` (or `birthdate`, `date`): Birth date in YYYY-MM-DD format
|
- `birthday` (or `birthdate`, `date`): Birth date in YYYY-MM-DD format
|
||||||
- `label` (optional): Custom label (defaults to "Age:")
|
- `label` (optional): Custom label (defaults to "Age:")
|
||||||
|
|
||||||
**Frontmatter Support:** If not provided in the code block, the plugin will look for `birthday`/`birthdate`, and `name` properties in the note's frontmatter.
|
**Frontmatter Support:** If not provided in the code block, the plugin reads `birthday`/`birthdate` from the note's frontmatter. If no `label` is set, a frontmatter `name` property becomes the default label automatically (e.g. `name: John` → "John's age:").
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
|
|||||||
@@ -1,611 +1,9 @@
|
|||||||
import { App, Notice, Plugin, PluginSettingTab, Setting, parseYaml, moment } from "obsidian";
|
import { Notice, Plugin } from "obsidian";
|
||||||
import { RangeSetBuilder } from "@codemirror/state";
|
import { DEFAULT_SETTINGS, DateCalcSettings } from "./src/types";
|
||||||
import { syntaxTree } from "@codemirror/language";
|
import { processFencedBlock, processInlineCode } from "./src/calculate";
|
||||||
import {
|
import { dateCalcLivePreview } from "./src/live-preview";
|
||||||
Decoration,
|
import { renderInlineResult } from "./src/widgets";
|
||||||
DecorationSet,
|
import { DateCalcSettingTab } from "./src/settings-tab";
|
||||||
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<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 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<Decoration>();
|
|
||||||
const used = new Set<string>();
|
|
||||||
|
|
||||||
// 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 {
|
export default class DateCalcPlugin extends Plugin {
|
||||||
settings: DateCalcSettings;
|
settings: DateCalcSettings;
|
||||||
@@ -615,10 +13,10 @@ export default class DateCalcPlugin extends Plugin {
|
|||||||
|
|
||||||
if (this.settings.debug) console.log("[date-calc] loaded");
|
if (this.settings.debug) console.log("[date-calc] loaded");
|
||||||
|
|
||||||
// Register Live Preview editor extension
|
// Live Preview: render inline code and fenced blocks via a CM6 decoration extension.
|
||||||
this.registerEditorExtension(dateCalcLivePreview(this.app, () => this.settings));
|
this.registerEditorExtension(dateCalcLivePreview(this.app, () => this.settings));
|
||||||
|
|
||||||
// Register Reading mode fenced code block processor
|
// Reading View: fenced ```date-calc``` blocks via Obsidian's codeblock processor.
|
||||||
this.registerMarkdownCodeBlockProcessor("date-calc", (source, el, ctx) => {
|
this.registerMarkdownCodeBlockProcessor("date-calc", (source, el, ctx) => {
|
||||||
const result = processFencedBlock(source, this.app, ctx.sourcePath, this.settings);
|
const result = processFencedBlock(source, this.app, ctx.sourcePath, this.settings);
|
||||||
el.empty();
|
el.empty();
|
||||||
@@ -628,10 +26,28 @@ export default class DateCalcPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reading View: inline `date-calc:` spans. Obsidian has no dedicated hook for
|
||||||
|
// these (unlike fenced blocks), so scan rendered <code> elements ourselves —
|
||||||
|
// same approach Dataview uses for its inline queries.
|
||||||
|
this.registerMarkdownPostProcessor((el, ctx) => {
|
||||||
|
const codeblocks = el.querySelectorAll("code");
|
||||||
|
for (let i = 0; i < codeblocks.length; i++) {
|
||||||
|
const codeblock = codeblocks.item(i);
|
||||||
|
// Skip <pre><code> (fenced blocks); those are handled above.
|
||||||
|
if (codeblock.parentElement?.nodeName.toLowerCase() === "pre") continue;
|
||||||
|
|
||||||
|
const raw = codeblock.innerText.trim();
|
||||||
|
const result = processInlineCode(raw, this.app, ctx.sourcePath, this.settings);
|
||||||
|
if (!result.text) continue;
|
||||||
|
|
||||||
|
codeblock.replaceWith(renderInlineResult(result.text, result.tooltip));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "date-calc-toggle-debug",
|
id: "date-calc-toggle-debug",
|
||||||
name: "Date Calc: Toggle debug",
|
name: "Toggle debug logging",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
this.settings.debug = !this.settings.debug;
|
this.settings.debug = !this.settings.debug;
|
||||||
await this.saveData(this.settings);
|
await this.saveData(this.settings);
|
||||||
@@ -641,7 +57,7 @@ export default class DateCalcPlugin extends Plugin {
|
|||||||
|
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "date-calc-toggle-verbose",
|
id: "date-calc-toggle-verbose",
|
||||||
name: "Date Calc: Toggle verbose output",
|
name: "Toggle verbose output",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
this.settings.verbose = !this.settings.verbose;
|
this.settings.verbose = !this.settings.verbose;
|
||||||
await this.saveData(this.settings);
|
await this.saveData(this.settings);
|
||||||
@@ -652,125 +68,3 @@ export default class DateCalcPlugin extends Plugin {
|
|||||||
this.addSettingTab(new DateCalcSettingTab(this.app, this));
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "date-calc",
|
"id": "date-calc",
|
||||||
"name": "Date calculator",
|
"name": "Date calculator",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"minAppVersion": "0.15.0",
|
"minAppVersion": "0.15.0",
|
||||||
"description": "Calculate ages, countdowns, and date differences dynamically in your notes using inline code or fenced blocks.",
|
"description": "Calculate ages, countdowns, and date differences dynamically in your notes using inline code or fenced blocks.",
|
||||||
"author": "Olivier Legendre",
|
"author": "Olivier Legendre",
|
||||||
|
|||||||
Generated
+6
-4
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-sample-plugin",
|
"name": "date-calculator",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "obsidian-sample-plugin",
|
"name": "date-calculator",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "^6.12.1",
|
||||||
|
"@codemirror/state": "^6.5.2",
|
||||||
|
"@codemirror/view": "^6.38.2",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@types/node": "^16.11.6",
|
"@types/node": "^16.11.6",
|
||||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "date-calculator",
|
"name": "date-calculator",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "Calculate ages, countdowns, and date differences dynamically in your notes using inline code or fenced blocks.",
|
"description": "Calculate ages, countdowns, and date differences dynamically in your notes using inline code or fenced blocks.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,10 +9,12 @@
|
|||||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "Olivier Legendre",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "^6.12.1",
|
||||||
|
"@codemirror/state": "^6.5.2",
|
||||||
|
"@codemirror/view": "^6.38.2",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@types/node": "^16.11.6",
|
"@types/node": "^16.11.6",
|
||||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { App, moment } from "obsidian";
|
||||||
|
import { formatSpan, parseMoment } from "./moment-utils";
|
||||||
|
import { normalizeConfig, parseConfig, parseYamlConfig } from "./config";
|
||||||
|
import type { DateCalcConfig, DateCalcResult, DateCalcSettings } from "./types";
|
||||||
|
|
||||||
|
/** Read a string or Date value out of frontmatter, ignoring any other shape. */
|
||||||
|
function frontmatterField(
|
||||||
|
fm: Record<string, unknown> | undefined,
|
||||||
|
key: string
|
||||||
|
): string | Date | undefined {
|
||||||
|
const value = fm?.[key];
|
||||||
|
return typeof value === "string" || value instanceof Date ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch to the calculator for `type`, catching and surfacing any error inline. */
|
||||||
|
export 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: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return { text: `date-calc error: ${message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateBirthday(
|
||||||
|
cfg: DateCalcConfig,
|
||||||
|
app: App,
|
||||||
|
sourcePath: string,
|
||||||
|
verbose: boolean
|
||||||
|
): DateCalcResult {
|
||||||
|
const fm = app.metadataCache.getCache(sourcePath)?.frontmatter;
|
||||||
|
|
||||||
|
const bstr =
|
||||||
|
cfg.birthday ?? cfg.birthdate ?? cfg.date ??
|
||||||
|
frontmatterField(fm, "birthday") ?? frontmatterField(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`;
|
||||||
|
// Custom label > frontmatter-name personalization > plain "Age:" default.
|
||||||
|
const name = frontmatterField(fm, "name");
|
||||||
|
const label = cfg.label ?? (typeof name === "string" ? `${name}'s age:` : "Age:");
|
||||||
|
return { text: `${label} ${ageStr}. ${msg}`, tooltip: bd.format("MMMM Do, YYYY") };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse and evaluate a `date-calc: ...` inline code span's raw text. */
|
||||||
|
export 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 = parseConfig(paramsRaw);
|
||||||
|
const norm = normalizeConfig(paramsRaw, cfg);
|
||||||
|
|
||||||
|
if (!norm.type) return { text: "" };
|
||||||
|
|
||||||
|
return calculateDateResult(norm.type, norm.cfg, app, sourcePath, settings.verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse and evaluate a fenced ```date-calc``` block's YAML body. */
|
||||||
|
export function processFencedBlock(
|
||||||
|
source: string,
|
||||||
|
app: App,
|
||||||
|
sourcePath: string,
|
||||||
|
settings: DateCalcSettings
|
||||||
|
): DateCalcResult {
|
||||||
|
const cfg = parseYamlConfig(source);
|
||||||
|
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);
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
import { parseYaml } from "obsidian";
|
||||||
|
import type { DateCalcConfig } from "./types";
|
||||||
|
|
||||||
|
/** A loosely-parsed config prior to type coercion: from inline key=value pairs or YAML. */
|
||||||
|
type RawConfig = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce a raw parsed config into `DateCalcConfig`. `verbose` needs explicit
|
||||||
|
* coercion because key=value parsing always yields the *string* "true"/"false",
|
||||||
|
* which — left alone — is truthy either way (a non-empty string), silently
|
||||||
|
* breaking `verbose=false`.
|
||||||
|
*/
|
||||||
|
function coerceConfig(raw: RawConfig): DateCalcConfig {
|
||||||
|
const { verbose, ...rest } = raw;
|
||||||
|
if (verbose === undefined) return rest as DateCalcConfig;
|
||||||
|
const verboseBool = typeof verbose === "boolean" ? verbose : String(verbose).toLowerCase() === "true";
|
||||||
|
return { ...rest, verbose: verboseBool } as DateCalcConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `key=value key2="quoted value"` pairs into a plain string map. */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a block of YAML into a `DateCalcConfig`, falling back to the trimmed
|
||||||
|
* source string when it isn't valid YAML or doesn't parse to an object. */
|
||||||
|
export function parseYamlConfig(source: string): DateCalcConfig | string {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = parseYaml(source);
|
||||||
|
if (parsed == null) return {};
|
||||||
|
if (typeof parsed === "string") return parsed;
|
||||||
|
// Any non-array object is treated as a config map; the fields we actually
|
||||||
|
// read off it (birthday/to/from/... ) are validated downstream by parseMoment.
|
||||||
|
if (typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
return coerceConfig(parsed as RawConfig);
|
||||||
|
}
|
||||||
|
return source.trim();
|
||||||
|
} catch {
|
||||||
|
return source.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse inline params as key=value pairs, YAML, or a bare type string. */
|
||||||
|
export function parseConfig(params: string): DateCalcConfig | string {
|
||||||
|
if (!params) return {};
|
||||||
|
if (params.includes("=")) return coerceConfig(parseKv(params));
|
||||||
|
return parseYamlConfig(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the calculation `type`, inferring it from config shape when omitted. */
|
||||||
|
export function normalizeConfig(
|
||||||
|
paramsRaw: string,
|
||||||
|
cfg: DateCalcConfig | string
|
||||||
|
): { type: string; cfg: DateCalcConfig } {
|
||||||
|
const params = paramsRaw.trim();
|
||||||
|
let resolved: DateCalcConfig = typeof cfg === "string" ? {} : cfg;
|
||||||
|
let type = (typeof cfg === "string" ? "" : cfg.type ?? "").toLowerCase().trim();
|
||||||
|
|
||||||
|
if (!type && typeof cfg === "string" && /^[A-Za-z][A-Za-z-]*$/.test(cfg.trim())) {
|
||||||
|
type = cfg.trim().toLowerCase();
|
||||||
|
resolved = {};
|
||||||
|
}
|
||||||
|
if (!type && /^[A-Za-z][A-Za-z-]*$/.test(params)) {
|
||||||
|
type = params.toLowerCase();
|
||||||
|
resolved = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
if (resolved.birthday || resolved.birthdate) type = "birthday";
|
||||||
|
else if ((resolved.from && resolved.to) || (resolved.start && resolved.end)) type = "diff";
|
||||||
|
else if (resolved.to || resolved.until) type = "countdown";
|
||||||
|
else if (resolved.since) type = "since";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "bday") type = "birthday";
|
||||||
|
if (type === "until") type = "countdown";
|
||||||
|
if (type === "difference") type = "diff";
|
||||||
|
|
||||||
|
return { type, cfg: resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a fenced code block's opening/closing fence from its body. */
|
||||||
|
export 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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { App } from "obsidian";
|
||||||
|
import { RangeSetBuilder } from "@codemirror/state";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import type { DateCalcSettings } from "./types";
|
||||||
|
import { processFencedBlock, processInlineCode } from "./calculate";
|
||||||
|
import { parseFencedCode } from "./config";
|
||||||
|
import { DateCalcBlockWidget, DateCalcInlineWidget } from "./widgets";
|
||||||
|
|
||||||
|
const INLINE_RE = /`date-calc:[^`]*`/g;
|
||||||
|
const SCAN_MARGIN = 2000;
|
||||||
|
|
||||||
|
/** A pending decoration, collected before being handed to RangeSetBuilder in order. */
|
||||||
|
interface PendingDeco {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
deco: Decoration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSettings) {
|
||||||
|
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 {
|
||||||
|
const container = view.dom.closest(".markdown-source-view");
|
||||||
|
if (!container?.classList.contains("is-live-preview")) return Decoration.none;
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
const sourcePath = app.workspace.getActiveFile()?.path ?? "";
|
||||||
|
const sel = view.state.selection.main;
|
||||||
|
const used = new Set<string>();
|
||||||
|
const pending: PendingDeco[] = [];
|
||||||
|
|
||||||
|
// Collect inline code decorations.
|
||||||
|
for (const r of view.visibleRanges) {
|
||||||
|
const scanFrom = Math.max(0, r.from - SCAN_MARGIN);
|
||||||
|
const scanTo = Math.min(view.state.doc.length, r.to + SCAN_MARGIN);
|
||||||
|
const slice = view.state.doc.sliceString(scanFrom, scanTo);
|
||||||
|
|
||||||
|
INLINE_RE.lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = INLINE_RE.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;
|
||||||
|
|
||||||
|
pending.push({
|
||||||
|
from: absStart,
|
||||||
|
to: absEnd,
|
||||||
|
deco: Decoration.replace({
|
||||||
|
widget: new DateCalcInlineWidget(result.text, result.tooltip, absStart, absEnd),
|
||||||
|
inclusive: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect fenced code block decorations.
|
||||||
|
for (const r of view.visibleRanges) {
|
||||||
|
const scanFrom = Math.max(0, r.from - SCAN_MARGIN);
|
||||||
|
const scanTo = Math.min(view.state.doc.length, r.to + SCAN_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;
|
||||||
|
|
||||||
|
pending.push({
|
||||||
|
from: absEnd,
|
||||||
|
to: absEnd,
|
||||||
|
deco: Decoration.widget({
|
||||||
|
widget: new DateCalcBlockWidget(result.text, result.tooltip),
|
||||||
|
side: 1,
|
||||||
|
block: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.debug) {
|
||||||
|
console.log("[date-calc] decorations built", { sourcePath, count: pending.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// RangeSetBuilder requires decorations added in ascending `from` order.
|
||||||
|
// The inline pass and the fenced-block pass above are each internally
|
||||||
|
// sorted, but not sorted *relative to each other* — a fenced block
|
||||||
|
// followed later by inline code (or vice versa) previously threw
|
||||||
|
// "Ranges must be added sorted" and broke Live Preview outright.
|
||||||
|
pending.sort((a, b) => a.from - b.from || a.to - b.to);
|
||||||
|
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
for (const { from, to, deco } of pending) builder.add(from, to, deco);
|
||||||
|
return builder.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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { moment } from "obsidian";
|
||||||
|
import type { M } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a date-ish input (frontmatter Date object, "YYYY-MM-DD" string, or any
|
||||||
|
* moment-parseable string) into a moment. Returns null if unparseable.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Break the span between two moments into years/months/days/hours/minutes. */
|
||||||
|
export 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||||
|
import type DateCalcPlugin from "../main";
|
||||||
|
|
||||||
|
export 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" });
|
||||||
|
|
||||||
|
// Documentation link
|
||||||
|
const docsDiv = containerEl.createDiv({
|
||||||
|
cls: "setting-item-description date-calc-settings-docs",
|
||||||
|
});
|
||||||
|
docsDiv.createEl("a", {
|
||||||
|
text: "📖 View Full Documentation",
|
||||||
|
href: "https://github.com/thelegend09/date-calculator",
|
||||||
|
});
|
||||||
|
|
||||||
|
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" });
|
||||||
|
|
||||||
|
// 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)" });
|
||||||
|
|
||||||
|
examplesDiv
|
||||||
|
.createEl("pre", { cls: "date-calc-example-block" })
|
||||||
|
.setText("```date-calc\ntype: birthday\nbirthday: 1992-08-16\n```");
|
||||||
|
|
||||||
|
examplesDiv
|
||||||
|
.createEl("pre", { cls: "date-calc-example-block" })
|
||||||
|
.setText(
|
||||||
|
"```date-calc\ntype: countdown\nto: 2026-12-31 23:59\nlabel: New Year\nverbose: true\n```"
|
||||||
|
);
|
||||||
|
|
||||||
|
examplesDiv
|
||||||
|
.createEl("pre", { cls: "date-calc-example-block" })
|
||||||
|
.setText("```date-calc\ntype: diff\nfrom: 2024-01-01\nto: 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { moment } from "obsidian";
|
||||||
|
|
||||||
|
/** Persisted plugin settings (Plugin#loadData / #saveData). */
|
||||||
|
export interface DateCalcSettings {
|
||||||
|
debug: boolean;
|
||||||
|
hideResultWhileCursorInside: boolean;
|
||||||
|
verbose: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: DateCalcSettings = {
|
||||||
|
debug: false,
|
||||||
|
hideResultWhileCursorInside: true,
|
||||||
|
verbose: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Result of a date calculation, ready to render. */
|
||||||
|
export interface DateCalcResult {
|
||||||
|
text: string;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized `date-calc` config, parsed from key=value pairs or YAML.
|
||||||
|
* Date-ish fields are `string | Date` because unquoted YAML scalars
|
||||||
|
* (fenced blocks, frontmatter) parse to native `Date` objects.
|
||||||
|
*/
|
||||||
|
export interface DateCalcConfig {
|
||||||
|
type?: string;
|
||||||
|
birthday?: string | Date;
|
||||||
|
birthdate?: string | Date;
|
||||||
|
date?: string | Date;
|
||||||
|
from?: string | Date;
|
||||||
|
to?: string | Date;
|
||||||
|
until?: string | Date;
|
||||||
|
since?: string | Date;
|
||||||
|
start?: string | Date;
|
||||||
|
end?: string | Date;
|
||||||
|
label?: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A moment.js instance; aliased for brevity across modules. */
|
||||||
|
export type M = moment.Moment;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { WidgetType } from "@codemirror/view";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shared `<span class="date-calc-inline">` result element used by both
|
||||||
|
* the Live Preview widget (below) and the Reading View post-processor (main.ts),
|
||||||
|
* so both rendering modes produce identical, stylable DOM.
|
||||||
|
*/
|
||||||
|
export function renderInlineResult(text: string, tooltip?: string): HTMLSpanElement {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "date-calc-inline";
|
||||||
|
span.textContent = text;
|
||||||
|
if (tooltip) {
|
||||||
|
span.className += " has-tooltip";
|
||||||
|
span.setAttribute("aria-label", tooltip);
|
||||||
|
}
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline `date-calc:` result, rendered in place of the backtick code span in Live Preview. */
|
||||||
|
export 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 = renderInlineResult(this.text, this.tooltip);
|
||||||
|
span.setAttribute("contenteditable", "false");
|
||||||
|
span.dataset.dcFrom = String(this.from);
|
||||||
|
span.dataset.dcTo = String(this.to);
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fenced ```date-calc``` block result, rendered after the block in Live Preview. */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -4,4 +4,24 @@ Date Calc Plugin Styles
|
|||||||
|
|
||||||
.date-calc-inline {
|
.date-calc-inline {
|
||||||
/* color: var(--text-accent); */
|
/* color: var(--text-accent); */
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-calc-inline.has-tooltip {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-calc-settings-docs {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-calc-examples {
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-calc-example-block {
|
||||||
|
background: var(--background-secondary);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"1.0.0": "0.15.0"
|
"1.0.0": "0.15.0",
|
||||||
|
"1.1.0": "0.15.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user