Syntax redesign: sharpen countdown/diff, symmetric frontmatter, relative dates

- Sharpen countdown vs diff: countdown is now always relative to now and
  no longer accepts a 'from' field (previously identical math to diff
  with a custom from, just different message framing).
- Add symmetric frontmatter fallback to countdown (to/deadline), diff
  (from/start, to/end), and since (since/created) \u2014 previously only
  birthday read frontmatter.
- Add relative date support to parseMoment: today, now, tomorrow,
  yesterday, and +/-N offsets (d/w/mo/y/h/m), always relative to now.
  Fixes diff's main weakness of needing hardcoded, staling dates.
- Make label verbatim everywhere (was auto-appending ': ' for countdown
  only, inconsistent with birthday's verbatim convention).
- Fix silent inline failures: unresolvable type now shows an explicit
  error ('Missing or unrecognized type') instead of rendering nothing,
  matching fenced-block behavior. Also fixes 'from=X' alone (documented
  since-alias) not inferring a type.
- Remove the 'YAML-like' comma-separated inline format from docs \u2014 it
  isn't valid non-flow YAML and silently produced nothing; only
  key=value and {flow-mapping} are documented now.
- README rewritten to match: field references, frontmatter docs per
  type, and a new relative-dates section.

Verified against the real bundled logic (12 behavioral checks) and live
in Obsidian via CDP across both Live Preview and Reading View.
This commit is contained in:
2026-09-07 12:04:35 -04:00
parent 526f54a640
commit 68e7b3ab04
4 changed files with 120 additions and 87 deletions
+52 -13
View File
@@ -27,11 +27,11 @@ export function calculateDateResult(
case "birthday":
return calculateBirthday(cfg, app, sourcePath, useVerbose);
case "countdown":
return calculateCountdown(cfg, useVerbose);
return calculateCountdown(cfg, app, sourcePath, useVerbose);
case "diff":
return calculateDiff(cfg, useVerbose);
return calculateDiff(cfg, app, sourcePath, useVerbose);
case "since":
return calculateSince(cfg, useVerbose);
return calculateSince(cfg, app, sourcePath, useVerbose);
default:
return { text: "date-calc: Unknown type. Supported: birthday, countdown, diff, since" };
}
@@ -76,17 +76,29 @@ function calculateBirthday(
const ageStr = verbose ? `${age} years old` : `${age}y`;
// Custom label > frontmatter-name personalization > plain "Age:" default.
// Labels are always used verbatim, including their own trailing punctuation.
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);
/** Always relative to now — for a fixed two-point comparison, use `diff`. */
function calculateCountdown(
cfg: DateCalcConfig,
app: App,
sourcePath: string,
verbose: boolean
): DateCalcResult {
const fm = app.metadataCache.getCache(sourcePath)?.frontmatter;
const to = parseMoment(
cfg.to ?? cfg.date ?? cfg.until ??
frontmatterField(fm, "to") ?? frontmatterField(fm, "deadline")
);
if (!to) return { text: `date-calc: Missing or invalid "to" date.` };
const from = parseMoment(cfg.from) ?? moment();
const label = cfg.label ? `${cfg.label}: ` : "";
const from = moment();
const label = cfg.label ? `${cfg.label} ` : "";
const span = formatSpan(from, to, verbose);
const text = !span.isNegative
@@ -96,9 +108,21 @@ function calculateCountdown(cfg: DateCalcConfig, verbose: boolean): DateCalcResu
return { text };
}
function calculateDiff(cfg: DateCalcConfig, verbose: boolean): DateCalcResult {
const from = parseMoment(cfg.from ?? cfg.start);
const to = parseMoment(cfg.to ?? cfg.end);
/** A neutral two-point comparison — for "time until X from now", use `countdown`. */
function calculateDiff(
cfg: DateCalcConfig,
app: App,
sourcePath: string,
verbose: boolean
): DateCalcResult {
const fm = app.metadataCache.getCache(sourcePath)?.frontmatter;
const from = parseMoment(
cfg.from ?? cfg.start ?? frontmatterField(fm, "from") ?? frontmatterField(fm, "start")
);
const to = parseMoment(
cfg.to ?? cfg.end ?? frontmatterField(fm, "to") ?? frontmatterField(fm, "end")
);
if (!from || !to) return { text: `date-calc: Provide valid "from" and "to" dates.` };
const span = formatSpan(from, to, verbose);
@@ -110,8 +134,18 @@ function calculateDiff(cfg: DateCalcConfig, verbose: boolean): DateCalcResult {
return { text };
}
function calculateSince(cfg: DateCalcConfig, verbose: boolean): DateCalcResult {
const since = parseMoment(cfg.since ?? cfg.from ?? cfg.date);
function calculateSince(
cfg: DateCalcConfig,
app: App,
sourcePath: string,
verbose: boolean
): DateCalcResult {
const fm = app.metadataCache.getCache(sourcePath)?.frontmatter;
const since = parseMoment(
cfg.since ?? cfg.from ?? cfg.date ??
frontmatterField(fm, "since") ?? frontmatterField(fm, "created")
);
if (!since) return { text: `date-calc: Missing or invalid "since" date.` };
const now = moment();
@@ -137,7 +171,12 @@ export function processInlineCode(
const cfg = parseConfig(paramsRaw);
const norm = normalizeConfig(paramsRaw, cfg);
if (!norm.type) return { text: "" };
if (!norm.type) {
// Surface a visible error instead of silently rendering nothing — the
// author clearly meant to invoke date-calc (the prefix matched), so a
// vanished result is far more confusing than an explicit message.
return { text: 'date-calc: Missing or unrecognized "type" (birthday/countdown/diff/since).' };
}
return calculateDateResult(norm.type, norm.cfg, app, sourcePath, settings.verbose);
}
+1 -1
View File
@@ -84,7 +84,7 @@ export function normalizeConfig(
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";
else if (resolved.since || resolved.from) type = "since";
}
if (type === "bday") type = "birthday";
+29 -2
View File
@@ -1,9 +1,22 @@
import { moment } from "obsidian";
import type { M } from "./types";
/** Relative-offset shorthand: +7d, -3mo, +2w, etc. (always relative to now). */
const RELATIVE_RE = /^([+-]\d+)\s*(d|days?|w|weeks?|mo|months?|y|years?|h|hours?|m|min|minutes?)$/i;
const RELATIVE_UNIT: Record<string, moment.unitOfTime.DurationConstructor> = {
d: "days", day: "days", days: "days",
w: "weeks", week: "weeks", weeks: "weeks",
mo: "months", month: "months", months: "months",
y: "years", year: "years", years: "years",
h: "hours", hour: "hours", hours: "hours",
m: "minutes", min: "minutes", minute: "minutes", minutes: "minutes",
};
/**
* Parse a date-ish input (frontmatter Date object, "YYYY-MM-DD" string, or any
* moment-parseable string) into a moment. Returns null if unparseable.
* Parse a date-ish input (frontmatter Date object, "YYYY-MM-DD" string, a
* relative keyword/offset, 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;
@@ -29,6 +42,20 @@ export function parseMoment(input: unknown): M | null {
}
const s = String(input).trim();
const lower = s.toLowerCase();
if (lower === "now") return moment();
if (lower === "today") return moment().startOf("day");
if (lower === "tomorrow") return moment().add(1, "day").startOf("day");
if (lower === "yesterday") return moment().subtract(1, "day").startOf("day");
const rel = RELATIVE_RE.exec(s);
if (rel) {
const amount = parseInt(rel[1], 10);
const unit = RELATIVE_UNIT[rel[2].toLowerCase()];
return moment().add(amount, unit);
}
const dateOnly = moment(s, "YYYY-MM-DD", true);
if (dateOnly.isValid()) return dateOnly.startOf("day");