Architecture¶
calemdar is a single static Go binary. One process, one vault, one host. The internals split into a small set of cooperating packages.
The pieces¶
┌──────────────────────────────┐
│ calemdar serve │
│ (daemon; long-running) │
└─────────────┬────────────────┘
│
┌─────────────────────────────┼─────────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌──────────────┐ ┌─────────────────┐
│ watcher │ │ reactor │ │ nightly timer │
│ (fsnotify + │ │ (FC → root │ │ (reconcile all │
│ debounce) │ │ migration) │ │ + archive) │
└───────┬───────┘ └──────┬───────┘ └────────┬────────┘
│ │ │
└───────────────┬───────────┴─────────────────────────────┘
│
▼
┌───────────────┐
│ reconcile │
│ (root → flat │
│ occurrences) │
└───────┬───────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌───────┐ ┌────────┐ ┌─────────┐
│ vault │ │ writer │ │ store │
│ (fs + │ │ (atomic│ │ (sqlite │
│ paths)│ │ writes)│ │ cache) │
└───────┘ └────────┘ └─────────┘
Source-of-truth layering¶
- Markdown files on disk — the only source of truth. Recurring roots in
recurring/<slug>.md, expanded events inevents/<calendar>/<YYYY-MM-DD>-<slug>.md. - SQLite cache at
.calemdar/cache.db— a projection of the markdown, rebuildable at any time viacalemdar reindex. Fast lookups for the watcher, reactor, and CLI listers. - In-memory config populated once at startup from
config.yaml.
If you delete the cache the daemon rebuilds it on next start. If you delete a markdown file the daemon notices and reconciles accordingly.
Components¶
internal/watch — filesystem watcher¶
Wraps fsnotify with recursive-directory walking and a debounce layer.
Coalesces bursts of events on the same path within debounce_ms (default
500ms) so a single Obsidian save doesn't trigger a reconcile storm.
Also suppresses self-writes: each time the writer touches a file it records the path + mtime in the store. When a matching event comes back from fsnotify the watcher swallows it, preventing feedback loops.
internal/reactor — Full Calendar translator¶
Watches events/ for files whose frontmatter carries Full Calendar's
recurrence shape (type: recurring or type: rrule). When one appears,
reactor:
- Reads the FC frontmatter.
- Builds an equivalent calemdar root (
freq,interval,byday/bymonthday). - Writes it to
recurring/<slug>.md. - Deletes the FC-authored source file.
- Hands the new root to reconcile for immediate expansion.
End result: the user does the FC-native "new recurring event" gesture and calemdar converts it to its own shape without requiring the user to learn the CLI.
Rejects v1-unsupported rules (positional BYDAY, COUNT=, etc.) with a
clear error rather than silently losing data.
internal/reconcile — root → occurrences¶
Given a root, computes the occurrence set for the window
[today, today + horizon_months], respecting freq, interval, byday,
bymonthday, start-date, until, and exceptions. Then:
- For each target date, check if the event file exists.
- If it exists and
user-owned: true→ skip. - If it exists and
user-owned: false→ overwrite (future only). - If it does not exist → create.
- Orphan sweep: any existing event with this
series-idthat is NOT in the target set, NOTuser-owned, and hasdate >= today→ delete. - Past is immutable.
reconcilenever touches an event whosedateis earlier than today, regardless ofuser-owned.
Reconcile is idempotent; running it twice in a row does nothing the second time.
internal/autoown — user-owned flag flipper¶
Watches events/. When an event file is modified and the write was NOT
from the server (self-write suppression), autoown reads the file, sets
user-owned: true, and writes it back. After that point, reconcile leaves
it alone forever.
internal/store — SQLite cache¶
Pure-Go SQLite (modernc.org/sqlite, no CGO). Two tables, series and
occurrences — see design for the schema.
Exposes:
Open(vault)— open or create the cache.Reindex(vault)— scan disk and rebuild from scratch.- Lookup helpers for the watcher and CLI listers.
- Self-write tracking (path + mtime) for fsnotify feedback suppression.
internal/writer — atomic markdown writes¶
Writes expanded events via write-to-temp + rename. Records the mtime in
the store so the watcher's self-write suppressor can identify its own
events. Uses 0644 on files and 0755 on intermediate directories.
internal/vault — path resolution¶
Resolves the vault root (tilde expansion, absolute path normalisation) and
scaffolds the directory tree (recurring/, archive/, events/<cal>/).
internal/serve — daemon glue¶
Wires watcher → dispatcher → reactor / autoown / reconcile. Owns the
nightly timer (extend + archive at nightly_at). Stops cleanly on
SIGINT / SIGTERM.
Data flow walkthroughs¶
Create a new recurring series via the CLI¶
calemdar series new
└─▶ prompt.go gathers fields
└─▶ writer.Write(root) → recurring/<slug>.md
└─▶ watcher sees it
└─▶ dispatch to reconcile.Series
└─▶ writer.Write(...) × N → events/<cal>/<date>-<slug>.md
└─▶ store updates occurrences table
Drag an occurrence in Obsidian¶
obsidian writes events/<cal>/<date>-<slug>.md with new date / time
└─▶ watcher sees it (NOT a self-write)
└─▶ dispatch to autoown
└─▶ writer.Write(<same path>) with user-owned: true
└─▶ reconcile of the root later sees user-owned, skips this file forever
Nightly pass¶
03:00 local (timezone-aware)
└─▶ for each root: reconcile.Series (extends horizon by ~1 day)
└─▶ archive.Run (moves events past archive_cutoff_months)
Concurrency model¶
- Exactly one
calemdar serveprocess per vault. A lease file in the store directory prevents two daemons from fighting. - Watcher events are serialised through the dispatcher — reconcile, reactor, and autoown never run concurrently on the same file.
- CLI maintenance commands (
reindex,extend,archive) are safe to run while the daemon is up; they share the store and contend on SQLite transactions.
internal/notify — ntfy push¶
When notifications.enabled is true, serve spawns a goroutine that
ticks every 60 seconds, queries the store for upcoming events, and POSTs
to <ntfy_url>/<ntfy_topic> for each event crossing a configured
lead-minute window. All-day events are skipped. Optional per-calendar
filter via notifications.calendars. The calemdar notify test
subcommand sends a one-shot test push regardless of enabled.
What calemdar does NOT do¶
- No CalDAV, no ICS. If you want those, run Radicale beside it.
- No multi-device daemon. The vault is Syncthing-friendly, but exactly one
device runs
serve.