4 Commits

Author SHA1 Message Date
olivier 3df01c6ede 1.2.0 2026-09-07 12:11:03 -04:00
olivier 537198c4f1 Add remaining type aliases, rewrite settings tab as syntax TL;DR
- Add age (birthday), remaining (countdown), elapsed (since) aliases,
  alongside the existing bday/until/difference.
- README: document new aliases in each type's header and the Type
  Aliases section.
- Settings tab rewritten: drop the stale GitHub doc link and hardcoded
  pre-redesign examples (countdown+from, auto-colon labels). Replace
  with a compact syntax TL;DR \u2014 all four types with their aliases,
  fields, and one-line descriptions, plus relative dates, frontmatter
  fallback, and inline/fenced examples \u2014 and link to the README on
  Gitea (was pointing at a stale GitHub URL).

Verified: aliases tested against the real shipped config parser; the
settings tab's actual display() (found via app.setting.pluginTabs) was
invoked live in Obsidian and rendered exactly the intended content with
no errors.
2026-09-07 12:09:55 -04:00
olivier 68e7b3ab04 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.
2026-09-07 12:04:35 -04:00
olivier 526f54a640 1.1.0: fix Live Preview crash, add Reading View inline support, birthday labels
- Fix RangeSetBuilder ordering crash: inline and fenced-block decorations
  are now merged and sorted before being added, instead of two unsorted
  passes (crashed whenever a fenced block preceded inline code in a note).
- Add explicit @codemirror/state and @codemirror/view devDependencies
  (previously only transitive via @codemirror/language).
- Add Reading View support for inline `date-calc:` spans via
  registerMarkdownPostProcessor (previously Live Preview only), mirroring
  the approach used by Dataview's inline queries.
- Fix verbose=false being silently truthy (non-empty string) in inline
  key=value config, which meant it never actually disabled verbose output.
- Fix diff type auto-inference: from/to was dead code (countdown's check
  always matched first); reordered so from+to or start+end infer diff.
- Implement documented but missing birthday label support: cfg.label,
  falling back to frontmatter name ("Jane's age:"), then "Age:".
- Split main.ts (777 lines) into src/ modules per AGENTS.md conventions.
- Fix duplicated command-palette prefixes, move inline JS styles to
  styles.css, fix package.json author, drop orphaned showArrow setting.

Verified: unit tests against the real bundled logic (mocked obsidian
module), a reproduction of the RangeSetBuilder crash against the real
@codemirror/state package, and live end-to-end testing in Obsidian via
CDP/Playwright (Tchernobyl vault).
2026-09-07 11:45:45 -04:00
14 changed files with 918 additions and 821 deletions
+46 -79
View File
@@ -2,12 +2,6 @@
An Obsidian plugin that adds dynamic date calculations to your notes using `date-calc` fenced code blocks and inline code.
## Todo
- [ ] Make everything work not only in Live Preview, but Reading also.
- [ ] Add custom language (default to Obsidian language, overridable)
## Features
### Fenced Code Blocks
@@ -19,25 +13,24 @@ type: birthday
birthday: 1992-08-16
```
### Inline Code
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
### Birthday (`birthday`)
### Birthday (`birthday`, `bday`, `age`)
Calculates age and time until next birthday with enhanced personalization features.
Calculates age and time until next birthday.
**Fields:**
- `birthday` (or `birthdate`, `date`): Birth date in YYYY-MM-DD format
- `label` (optional): Custom label (defaults to "Age:")
- `birthday` (or `birthdate`, `date`): Birth date
- `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 will look for `birthday`/`birthdate`, and `name` properties in the note's frontmatter.
**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:**
@@ -52,41 +45,35 @@ birthday: 2000-05-21
label: "John's age:"
```
### Countdown (`countdown`, `until`, `remaining`)
### 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:**
- `to` (or `date`, `until`): Target date/time
- `from` (optional): Starting date/time (defaults to now)
- `label` (optional): Prefix label for the output
- `label` (optional): Full prefix text, used verbatim (e.g. `label: "New Year:"`)
**Examples:**
**Frontmatter Support:** If `to` isn't provided in the code block, reads `to`/`deadline` from the note's frontmatter.
**Example:**
```date-calc
type: countdown
label: New Year
label: "New Year:"
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`, `elapsed`)
### 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:**
- `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:**
```date-calc
@@ -94,16 +81,17 @@ type: since
since: 2024-12-20
```
### Difference (`diff`, `difference`)
### 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:**
- `from` (or `start`): Start 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:**
```date-calc
@@ -118,25 +106,32 @@ from: 2025-09-19 08:00
to: 2025-09-19 16:30
```
## Inline Usage
You can use inline code for quick calculations:
- `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: from=2025-09-19 to=2025-09-20` → Date difference
**YAML Format:** Inline code also accepts YAML/inline-map format:
`date-calc: {type: birthday, birthday: 1992-08-16}`
**Formats:** Inline code accepts key=value pairs (`birthday=1992-08-16`) or a flow-mapping YAML object (`{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
- **Timezone:** All calculations use your local timezone
- **Birthday Calculations:** Normalized to local midnight to avoid timezone inconsistencies
| Format | Example | Notes |
| :------------------ | :------------------------------ | :----------------------------------------- |
| 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
@@ -159,70 +154,44 @@ verbose: true
- `verbose: true` → "Countdown: 1 year, 2 months, 15 days"
- `verbose: false` → "1y 2mo 15d"
### 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.
## 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
### Birthday
- `birthday` / `birthdate` / `date` — Birth date
- `verbose` — Override output format (optional)
- `birthday` / `birthdate` / `date` — Birth date (falls back to frontmatter `birthday`/`birthdate`)
- `label` — Full prefix, verbatim (defaults to `Age:`, or `<frontmatter name>'s age:` if set)
- `verbose` — Override output format
### Countdown
- `to` / `until` / `date` — Target date/time
- `from` — Start date (defaults to now)
- `label` — Custom prefix
- `to` / `until` / `date` — Target date/time (falls back to frontmatter `to`/`deadline`)
- `label` — Full prefix, verbatim
- `verbose` — Override output format
### Since
- `since` / `from` / `date` — Event date/time
- `since` / `from` / `date` — Event date/time (falls back to frontmatter `since`/`created`)
- `verbose` — Override output format
### Diff
- `from` / `start` — Start date/time
- `to` / `end` — End date/time
- `from` / `start` — Start date/time (falls back to frontmatter `from`/`start`)
- `to` / `end` — End date/time (falls back to frontmatter `to`/`end`)
- `verbose` — Override output format
## Type Aliases
For convenience, use these shortcuts:
- `birthday` = `bday`
- `countdown` = `until`
- `birthday` = `bday` = `age`
- `countdown` = `until` = `remaining`
- `since` = `elapsed`
- `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
Target these CSS classes in your snippets:
@@ -242,9 +211,7 @@ Target these CSS classes in your snippets:
}
```
## Commands
- **Toggle debug** — Log decoration activity to console
- **Toggle verbose** — Switch global verbose mode
+28 -734
View File
@@ -1,611 +1,9 @@
import { App, Notice, Plugin, PluginSettingTab, Setting, parseYaml, moment } from "obsidian";
import { RangeSetBuilder } from "@codemirror/state";
import { syntaxTree } from "@codemirror/language";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "@codemirror/view";
/* =========================
Settings
========================= */
interface DateCalcSettings {
debug: boolean;
hideResultWhileCursorInside: boolean;
verbose: boolean;
}
const DEFAULT_SETTINGS: DateCalcSettings = {
debug: false,
hideResultWhileCursorInside: true,
verbose: false,
};
/* =========================
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
========================= */
import { Notice, Plugin } from "obsidian";
import { DEFAULT_SETTINGS, DateCalcSettings } from "./src/types";
import { processFencedBlock, processInlineCode } from "./src/calculate";
import { dateCalcLivePreview } from "./src/live-preview";
import { renderInlineResult } from "./src/widgets";
import { DateCalcSettingTab } from "./src/settings-tab";
export default class DateCalcPlugin extends Plugin {
settings: DateCalcSettings;
@@ -615,10 +13,10 @@ export default class DateCalcPlugin extends Plugin {
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));
// 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) => {
const result = processFencedBlock(source, this.app, ctx.sourcePath, this.settings);
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
this.addCommand({
id: "date-calc-toggle-debug",
name: "Date Calc: Toggle debug",
name: "Toggle debug logging",
callback: async () => {
this.settings.debug = !this.settings.debug;
await this.saveData(this.settings);
@@ -641,7 +57,7 @@ export default class DateCalcPlugin extends Plugin {
this.addCommand({
id: "date-calc-toggle-verbose",
name: "Date Calc: Toggle verbose output",
name: "Toggle verbose output",
callback: async () => {
this.settings.verbose = !this.settings.verbose;
await this.saveData(this.settings);
@@ -652,125 +68,3 @@ export default class DateCalcPlugin extends Plugin {
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
View File
@@ -1,7 +1,7 @@
{
"id": "date-calc",
"name": "Date calculator",
"version": "1.0.0",
"version": "1.2.0",
"minAppVersion": "0.15.0",
"description": "Calculate ages, countdowns, and date differences dynamically in your notes using inline code or fenced blocks.",
"author": "Olivier Legendre",
+6 -4
View File
@@ -1,15 +1,17 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"name": "date-calculator",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"name": "date-calculator",
"version": "1.1.0",
"license": "MIT",
"devDependencies": {
"@codemirror/language": "^6.12.1",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.2",
"@lezer/common": "^1.5.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "date-calculator",
"version": "1.0.0",
"version": "1.2.0",
"description": "Calculate ages, countdowns, and date differences dynamically in your notes using inline code or fenced blocks.",
"main": "main.js",
"scripts": {
@@ -9,10 +9,12 @@
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"author": "Olivier Legendre",
"license": "MIT",
"devDependencies": {
"@codemirror/language": "^6.12.1",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.2",
"@lezer/common": "^1.5.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
+198
View File
@@ -0,0 +1,198 @@
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, app, sourcePath, useVerbose);
case "diff":
return calculateDiff(cfg, app, sourcePath, useVerbose);
case "since":
return calculateSince(cfg, app, sourcePath, 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.
// 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") };
}
/** 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 = 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 };
}
/** 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);
const text = verbose
? `Difference: ${span.text}${span.isNegative ? " (to is before from)" : ""}`
: `Diff: ${span.text}${span.isNegative ? " (reverse)" : ""}`;
return { text };
}
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();
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) {
// 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);
}
/** 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);
}
+115
View File
@@ -0,0 +1,115 @@
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 || resolved.from) type = "since";
}
if (type === "bday" || type === "age") type = "birthday";
if (type === "until" || type === "remaining") type = "countdown";
if (type === "difference") type = "diff";
if (type === "elapsed") type = "since";
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 };
}
+170
View File
@@ -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
),
}
);
}
+101
View File
@@ -0,0 +1,101 @@
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, 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;
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 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");
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 };
}
}
+118
View File
@@ -0,0 +1,118 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import type DateCalcPlugin from "../main";
const README_URL = "https://gitea.mithrilforge.dev/olivier/date-calculator";
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" });
const docsDiv = containerEl.createDiv({
cls: "setting-item-description date-calc-settings-docs",
});
docsDiv.createEl("a", { text: "📖 Full syntax reference (README)", href: README_URL });
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);
})
);
containerEl.createEl("h3", { text: "Syntax at a glance" });
const tldr = containerEl.createDiv({ cls: "date-calc-examples" });
tldr.createEl("p", {
text: "Use `date-calc: ...` inline, or a fenced ```date-calc block with type: set explicitly (or inferred from which fields you give).",
});
// Types table
const typesList = tldr.createEl("ul");
const addType = (name: string, aliases: string, desc: string, fields: string) => {
const li = typesList.createEl("li");
li.createEl("strong", { text: name });
li.appendText(` (${aliases}) — ${desc} `);
li.createEl("br");
li.createEl("code", { text: fields });
};
addType(
"birthday", "bday, age",
"age + days until next birthday.",
"birthday/birthdate/date, label"
);
addType(
"countdown", "until, remaining",
"time until a date, always from now.",
"to/until/date, label"
);
addType(
"since", "elapsed",
"time since a date, always from now.",
"since/from/date"
);
addType(
"diff", "difference",
"time between two fixed dates (neither has to be now).",
"from/start, to/end"
);
tldr.createEl("h4", { text: "Relative dates" });
tldr.createEl("p", {
text: "today, now, tomorrow, yesterday, or an offset like +7d / -3mo / +2w (always relative to now).",
});
tldr.createEl("h4", { text: "Frontmatter fallback" });
tldr.createEl("p", {
text: "Every type reads its date(s) from frontmatter if not given inline: birthday/birthdate, to/deadline, since/created, from/start + to/end.",
});
tldr.createEl("h4", { text: "Examples" });
const examplesList = tldr.createEl("ul");
examplesList.createEl("li").createEl("code", { text: "date-calc: birthday=1992-08-16" });
examplesList.createEl("li").createEl("code", { text: "date-calc: to=2026-12-31 label=\"New Year:\"" });
examplesList.createEl("li").createEl("code", { text: "date-calc: since=2024-01-01" });
examplesList.createEl("li").createEl("code", { text: "date-calc: from=2024-01-01 to=today" });
examplesList
.createEl("li")
.createEl("pre", { cls: "date-calc-example-block" })
.setText("```date-calc\ntype: countdown\nto: +30d\nlabel: \"Sprint:\"\nverbose: true\n```");
const fullDocs = tldr.createEl("p");
fullDocs.appendText("Full field reference, custom styling, and more examples: ");
fullDocs.createEl("a", { text: "README on Gitea", href: README_URL });
fullDocs.appendText(".");
}
}
+43
View File
@@ -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;
+65
View File
@@ -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
View File
@@ -4,4 +4,24 @@ Date Calc Plugin Styles
.date-calc-inline {
/* 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;
}
+3 -1
View File
@@ -1,3 +1,5 @@
{
"1.0.0": "0.15.0"
"1.0.0": "0.15.0",
"1.1.0": "0.15.0",
"1.2.0": "0.15.0"
}