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
+38 -71
View File
@@ -2,11 +2,6 @@
An Obsidian plugin that adds dynamic date calculations to your notes using `date-calc` fenced code blocks and inline code. An Obsidian plugin that adds dynamic date calculations to your notes using `date-calc` fenced code blocks and inline code.
## Todo
- [ ] Add custom language (default to Obsidian language, overridable)
## Features ## Features
### Fenced Code Blocks ### Fenced Code Blocks
@@ -18,7 +13,6 @@ type: birthday
birthday: 1992-08-16 birthday: 1992-08-16
``` ```
### Inline Code ### Inline Code
Use inline code for quick calculations: `date-calc: birthday=1992-08-16` Use inline code for quick calculations: `date-calc: birthday=1992-08-16`
@@ -29,14 +23,14 @@ Use inline code for quick calculations: `date-calc: birthday=1992-08-16`
### Birthday (`birthday`) ### Birthday (`birthday`)
Calculates age and time until next birthday, with an optional custom label and automatic personalization from your note's frontmatter. Calculates age and time until next birthday.
**Fields:** **Fields:**
- `birthday` (or `birthdate`, `date`): Birth date in YYYY-MM-DD format - `birthday` (or `birthdate`, `date`): Birth date
- `label` (optional): Custom label (defaults to "Age:") - `label` (optional): Full prefix text, used verbatim — include your own punctuation (defaults to `Age:`)
**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:"). **Frontmatter Support:** If not provided in the code block, reads `birthday`/`birthdate` from the note's frontmatter. If no `label` is set, a frontmatter `name` property personalizes the default label automatically (e.g. `name: John` → `John's age:`).
**Examples:** **Examples:**
@@ -51,41 +45,35 @@ birthday: 2000-05-21
label: "John's age:" label: "John's age:"
``` ```
### Countdown (`countdown` or `until`) ### Countdown (`countdown` or `until`)
Shows time remaining until a target date. Time remaining until a target date, **always measured from now**. For a comparison between two fixed dates, use `diff` instead.
**Fields:** **Fields:**
- `to` (or `date`, `until`): Target date/time - `to` (or `date`, `until`): Target date/time
- `from` (optional): Starting date/time (defaults to now) - `label` (optional): Full prefix text, used verbatim (e.g. `label: "New Year:"`)
- `label` (optional): Prefix label for the output
**Examples:** **Frontmatter Support:** If `to` isn't provided in the code block, reads `to`/`deadline` from the note's frontmatter.
**Example:**
```date-calc ```date-calc
type: countdown type: countdown
label: New Year label: "New Year:"
to: 2025-12-31 23:59 to: 2025-12-31 23:59
``` ```
```date-calc
type: countdown
label: Sprint ends
to: 2025-10-15 17:00
from: 2025-10-01 09:00
```
### Since (`since`) ### Since (`since`)
Shows time elapsed since an event (or time until if the event is in the future). Time elapsed since an event (or time until, if the event is in the future) — always measured against now.
**Fields:** **Fields:**
- `since` (or `from`, `date`): Event date/time - `since` (or `from`, `date`): Event date/time
**Frontmatter Support:** If not provided in the code block, reads `since`/`created` from the note's frontmatter.
**Example:** **Example:**
```date-calc ```date-calc
@@ -93,16 +81,17 @@ type: since
since: 2024-12-20 since: 2024-12-20
``` ```
### Difference (`diff`) ### Difference (`diff`)
Calculates the time difference between two specific dates. The time difference between two fixed dates — neither has to be "now". For a countdown relative to the present moment, use `countdown` instead.
**Fields:** **Fields:**
- `from` (or `start`): Start date/time - `from` (or `start`): Start date/time
- `to` (or `end`): End date/time - `to` (or `end`): End date/time
**Frontmatter Support:** If not provided in the code block, reads `from`/`start` and `to`/`end` from the note's frontmatter.
**Examples:** **Examples:**
```date-calc ```date-calc
@@ -117,25 +106,32 @@ from: 2025-09-19 08:00
to: 2025-09-19 16:30 to: 2025-09-19 16:30
``` ```
## Inline Usage ## Inline Usage
You can use inline code for quick calculations: You can use inline code for quick calculations:
- `date-calc: birthday=1992-08-16` → Birthday summary - `date-calc: birthday=1992-08-16` → Birthday summary
- `date-calc: to=2025-12-31 label="New Year"` → Countdown - `date-calc: to=2025-12-31 label="New Year:"` → Countdown
- `date-calc: since=2024-12-20` → Time since event - `date-calc: since=2024-12-20` → Time since event
- `date-calc: from=2025-09-19 to=2025-09-20` → Date difference - `date-calc: from=2025-09-19 to=2025-09-20` → Date difference
**YAML Format:** Inline code also accepts YAML/inline-map format: **Formats:** Inline code accepts key=value pairs (`birthday=1992-08-16`) or a flow-mapping YAML object (`{type: birthday, birthday: 1992-08-16}`).
`date-calc: {type: birthday, birthday: 1992-08-16}`
**Frontmatter Integration:** For inline birthday calculations, if no date is provided, the plugin will use the note's frontmatter `birthday`/`birthdate` property. **Frontmatter Integration:** Applies to every type, not just birthday — see each type's "Frontmatter Support" above.
## Date Formats ## Date Formats
- **Timezone:** All calculations use your local timezone | Format | Example | Notes |
- **Birthday Calculations:** Normalized to local midnight to avoid timezone inconsistencies | :------------------ | :------------------------------ | :----------------------------------------- |
| Date only | `2026-12-31` | — |
| Date + time | `2026-12-31 23:59` | — |
| ISO 8601 | `2026-12-31T23:59:00` | — |
| Relative keyword | `today`, `now`, `tomorrow`, `yesterday` | Resolved at render time |
| Relative offset | `+7d`, `-3mo`, `+2w`, `-1y` | Units: `d`, `w`, `mo`, `y`, `h`, `m` (minutes) — always relative to now |
All dates use your local timezone. Birthday calculations normalize to midnight to avoid timezone edge cases; `today`/relative keywords resolve to your local time at render.
Relative dates are most useful with `diff`, since `countdown`/`since` are already anchored to now by default — e.g. `type: diff` with `from: today` and `to: +30d` gives a fixed 30-day window that doesn't need updating.
## Configuration Options ## Configuration Options
@@ -158,50 +154,35 @@ verbose: true
- `verbose: true` → "Countdown: 1 year, 2 months, 15 days" - `verbose: true` → "Countdown: 1 year, 2 months, 15 days"
- `verbose: false` → "1y 2mo 15d" - `verbose: false` → "1y 2mo 15d"
### Hide While Editing ### Hide While Editing
Settings → "Hide result while cursor inside" prevents rendered output from showing while you're editing the code, keeping the raw syntax visible. Settings → "Hide result while cursor inside" prevents rendered output from showing while you're editing the code, keeping the raw syntax visible.
## Date Formats
| Format | Example | Use Case |
| :---------- | :-------------------- | :-------------------- |
| Date only | `2026-12-31` | Birthdays, countdowns |
| Date + time | `2026-12-31 23:59` | Precise countdowns |
| ISO 8601 | `2026-12-31T23:59:00` | Also supported |
All dates use your local timezone. Birthday calculations normalize to midnight to avoid timezone edge cases.
## Field Reference ## Field Reference
### Birthday ### Birthday
- `birthday` / `birthdate` / `date` — Birth date - `birthday` / `birthdate` / `date` — Birth date (falls back to frontmatter `birthday`/`birthdate`)
- `verbose` — Override output format (optional) - `label` — Full prefix, verbatim (defaults to `Age:`, or `<frontmatter name>'s age:` if set)
- `verbose` — Override output format
### Countdown ### Countdown
- `to` / `until` / `date` — Target date/time - `to` / `until` / `date` — Target date/time (falls back to frontmatter `to`/`deadline`)
- `from` — Start date (defaults to now) - `label` — Full prefix, verbatim
- `label` — Custom prefix
- `verbose` — Override output format - `verbose` — Override output format
### Since ### Since
- `since` / `from` / `date` — Event date/time - `since` / `from` / `date` — Event date/time (falls back to frontmatter `since`/`created`)
- `verbose` — Override output format - `verbose` — Override output format
### Diff ### Diff
- `from` / `start` — Start date/time - `from` / `start` — Start date/time (falls back to frontmatter `from`/`start`)
- `to` / `end` — End date/time - `to` / `end` — End date/time (falls back to frontmatter `to`/`end`)
- `verbose` — Override output format - `verbose` — Override output format
## Type Aliases ## Type Aliases
For convenience, use these shortcuts: For convenience, use these shortcuts:
@@ -210,18 +191,6 @@ For convenience, use these shortcuts:
- `countdown` = `until` - `countdown` = `until`
- `diff` = `difference` - `diff` = `difference`
## Inline Syntax Formats
All three formats work:
**Key=value:** ```date-calc: birthday=1992-08-16```
**YAML-like:** ```date-calc: type: birthday, birthday: 1992-08-16```
**Compact:** ```date-calc: {type: birthday, birthday: 1992-08-16}```
## Custom Styling ## Custom Styling
Target these CSS classes in your snippets: Target these CSS classes in your snippets:
@@ -241,9 +210,7 @@ Target these CSS classes in your snippets:
} }
``` ```
## Commands ## Commands
- **Toggle debug** — Log decoration activity to console - **Toggle debug** — Log decoration activity to console
- **Toggle verbose** — Switch global verbose mode - **Toggle verbose** — Switch global verbose mode
+52 -13
View File
@@ -27,11 +27,11 @@ export function calculateDateResult(
case "birthday": case "birthday":
return calculateBirthday(cfg, app, sourcePath, useVerbose); return calculateBirthday(cfg, app, sourcePath, useVerbose);
case "countdown": case "countdown":
return calculateCountdown(cfg, useVerbose); return calculateCountdown(cfg, app, sourcePath, useVerbose);
case "diff": case "diff":
return calculateDiff(cfg, useVerbose); return calculateDiff(cfg, app, sourcePath, useVerbose);
case "since": case "since":
return calculateSince(cfg, useVerbose); return calculateSince(cfg, app, sourcePath, useVerbose);
default: default:
return { text: "date-calc: Unknown type. Supported: birthday, countdown, diff, since" }; 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`; const ageStr = verbose ? `${age} years old` : `${age}y`;
// Custom label > frontmatter-name personalization > plain "Age:" default. // Custom label > frontmatter-name personalization > plain "Age:" default.
// Labels are always used verbatim, including their own trailing punctuation.
const name = frontmatterField(fm, "name"); const name = frontmatterField(fm, "name");
const label = cfg.label ?? (typeof name === "string" ? `${name}'s age:` : "Age:"); const label = cfg.label ?? (typeof name === "string" ? `${name}'s age:` : "Age:");
return { text: `${label} ${ageStr}. ${msg}`, tooltip: bd.format("MMMM Do, YYYY") }; return { text: `${label} ${ageStr}. ${msg}`, tooltip: bd.format("MMMM Do, YYYY") };
} }
function calculateCountdown(cfg: DateCalcConfig, verbose: boolean): DateCalcResult { /** Always relative to now — for a fixed two-point comparison, use `diff`. */
const to = parseMoment(cfg.to ?? cfg.date ?? cfg.until); 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.` }; if (!to) return { text: `date-calc: Missing or invalid "to" date.` };
const from = parseMoment(cfg.from) ?? moment(); const from = moment();
const label = cfg.label ? `${cfg.label}: ` : ""; const label = cfg.label ? `${cfg.label} ` : "";
const span = formatSpan(from, to, verbose); const span = formatSpan(from, to, verbose);
const text = !span.isNegative const text = !span.isNegative
@@ -96,9 +108,21 @@ function calculateCountdown(cfg: DateCalcConfig, verbose: boolean): DateCalcResu
return { text }; return { text };
} }
function calculateDiff(cfg: DateCalcConfig, verbose: boolean): DateCalcResult { /** A neutral two-point comparison — for "time until X from now", use `countdown`. */
const from = parseMoment(cfg.from ?? cfg.start); function calculateDiff(
const to = parseMoment(cfg.to ?? cfg.end); 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.` }; if (!from || !to) return { text: `date-calc: Provide valid "from" and "to" dates.` };
const span = formatSpan(from, to, verbose); const span = formatSpan(from, to, verbose);
@@ -110,8 +134,18 @@ function calculateDiff(cfg: DateCalcConfig, verbose: boolean): DateCalcResult {
return { text }; return { text };
} }
function calculateSince(cfg: DateCalcConfig, verbose: boolean): DateCalcResult { function calculateSince(
const since = parseMoment(cfg.since ?? cfg.from ?? cfg.date); 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.` }; if (!since) return { text: `date-calc: Missing or invalid "since" date.` };
const now = moment(); const now = moment();
@@ -137,7 +171,12 @@ export function processInlineCode(
const cfg = parseConfig(paramsRaw); const cfg = parseConfig(paramsRaw);
const norm = normalizeConfig(paramsRaw, cfg); 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); 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"; if (resolved.birthday || resolved.birthdate) type = "birthday";
else if ((resolved.from && resolved.to) || (resolved.start && resolved.end)) type = "diff"; else if ((resolved.from && resolved.to) || (resolved.start && resolved.end)) type = "diff";
else if (resolved.to || resolved.until) type = "countdown"; 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"; if (type === "bday") type = "birthday";
+29 -2
View File
@@ -1,9 +1,22 @@
import { moment } from "obsidian"; import { moment } from "obsidian";
import type { M } from "./types"; 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 * Parse a date-ish input (frontmatter Date object, "YYYY-MM-DD" string, a
* moment-parseable string) into a moment. Returns null if unparseable. * relative keyword/offset, or any moment-parseable string) into a moment.
* Returns null if unparseable.
*/ */
export function parseMoment(input: unknown): M | null { export function parseMoment(input: unknown): M | null {
if (input == null || input === "") return null; if (input == null || input === "") return null;
@@ -29,6 +42,20 @@ export function parseMoment(input: unknown): M | null {
} }
const s = String(input).trim(); 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); const dateOnly = moment(s, "YYYY-MM-DD", true);
if (dateOnly.isValid()) return dateOnly.startOf("day"); if (dateOnly.isValid()) return dateOnly.startOf("day");