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. 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 ## Features
### Fenced Code Blocks ### Fenced Code Blocks
@@ -19,25 +13,24 @@ 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`
**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`, `bday`, `age`)
Calculates age and time until next birthday with enhanced personalization features. 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 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:** **Examples:**
@@ -52,41 +45,35 @@ birthday: 2000-05-21
label: "John's age:" label: "John's age:"
``` ```
### Countdown (`countdown`, `until`, `remaining`)
### Countdown (`countdown` or `until`) Time remaining until a target date, **always measured from now**. For a comparison between two fixed dates, use `diff` instead.
Shows time remaining until a target date.
**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 ### Since (`since`, `elapsed`)
type: countdown
label: Sprint ends
to: 2025-10-15 17:00
from: 2025-10-01 09:00
```
Time elapsed since an event (or time until, if the event is in the future) — always measured against now.
### Since (`since`)
Shows time elapsed since an event (or time until if the event is in the future).
**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
@@ -94,16 +81,17 @@ type: since
since: 2024-12-20 since: 2024-12-20
``` ```
### Difference (`diff`, `difference`)
### Difference (`diff`) The time difference between two fixed dates — neither has to be "now". For a countdown relative to the present moment, use `countdown` instead.
Calculates the time difference between two specific dates.
**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
@@ -118,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
@@ -159,70 +154,44 @@ 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:
- `birthday` = `bday` - `birthday` = `bday` = `age`
- `countdown` = `until` - `countdown` = `until` = `remaining`
- `since` = `elapsed`
- `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:
@@ -242,9 +211,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
+28 -734
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{ {
"id": "date-calc", "id": "date-calc",
"name": "Date calculator", "name": "Date calculator",
"version": "1.0.0", "version": "1.2.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",
+6 -4
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "date-calculator", "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.", "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",
+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 { .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;
} }
+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"
} }