CalCalist/SPECS.md
randogoth 0c0558c24c Let an event choose which source it is filed under
An event created in an aggregate went to the configured default_sink and nowhere
else, so with several writable sources there was no way to say which calendar a
new event belonged in.

A line reading @endpoint-id in the description now picks the source, and a
matching CATEGORIES value does too. The description rather than the title because
every calendar client exposes a notes field and editing it does not disfigure the
event's name; CATEGORIES as well because that is the field iCalendar intends,
even though many mobile clients hide it. The marker is stripped before the event
reaches the calendar, being calcalist's bookkeeping rather than content.

A marker naming something that is not a writable source of that aggregate is
refused and reported, not redirected to the default: a typo should not quietly
file an event in the wrong calendar. A bare address in prose is not a marker
either, since a marker must be a line of its own.

Also covers the shapes beyond many-into-one: a source feeding several aggregates,
several aggregates sharing one target, a cycle between two aggregates, a delete
cascading across aggregates, and competing edits arriving through two aggregates
at once — the last being caught by the existing conflict detection rather than
silently overwriting.

Error display no longer repeats itself; thiserror already prints the cause chain.

Verified live against real accounts: two Google calendars aggregating into a
Posteo calendar, an edit in the aggregate reaching the originating Google
calendar, an event routed to a chosen source by its description marker, and a
deletion propagating from the aggregate through to Google.

129 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:38:30 +03:00

4.2 KiB

Calcalist

Idea

Small Rust based commandline tool with utility systemd service file that can aggregate and sync events between CalDAV/Google Calendar/iCal. Users who have a Google Calendar can create a new calendar that syncs events from several CalDAV calendars. CalDAV users can sync several Google Calendars into one calendar. This should be doable both ways, so the aggregating calendar needs to be able to distinguish where its events came from and also have a way to create new events that get synced to the correct source. In addition iCal feeds can also be aggregated but they remain one way only.

Configuration via a simple web interface. Configuration is saved in a portable toml file that can be easily migrated.

Architecture

Every endpoint — sources and aggregate targets alike — is mirrored to a local vdir, so the aggregation engine works purely on local files.

  • pimsync is driven as a one-shot subprocess for the CalDAV and WebCal legs. Its daemon mode is unusable here: it would race the reconciler over the same vdir files, so calcalist owns scheduling.
  • The Google leg is calcalist's own, via the Calendar REST API. pimsync cannot reach Google — it has no REST storage, and its config exposes only HTTP Basic auth, which Google's CalDAV endpoint has rejected since 2025-03-14.
  • Sync state (event mappings, hashes, sync tokens) lives in a JSON sidecar under $XDG_STATE_HOME/calcalist/, never in the portable config.
  • Secrets are never stored in the config either. Credentials come from *_command fields that are executed to fetch them.

Sync semantics

Question Behaviour
Provenance Aggregate UIDs derived as blake3(aggregate_id, source_id, source_uid); the state file is a cache, not a single point of failure
Routing new events A @endpoint-id line in the description, or a matching category, picks the source; otherwise the aggregate's default_sink. A hint naming an invalid sink is refused, never redirected to the default
Conflicts Source wins — the origin calendar is authoritative
Deletion Propagates to the source, guarded by a mass-deletion threshold
Attendees (mirroring) Kept verbatim on a Google target; demoted to inert data on a CalDAV target
Attendees (routing) Preserved — the sink server sends real invitations, which is intended
Alarms Always preserved, with no option to strip them
Retargeting sync refuses on target drift; aggregate retarget performs it deliberately

The governing rule behind the attendee handling: writes to an aggregate must never emit scheduling mail; writes to a source schedule normally. Google can be told not to notify, so attendees survive intact there. CalDAV offers no portable way to suppress RFC 6638 scheduling, so inertness is achieved structurally by dropping the live properties instead.

Alarms are never stripped because the whole point of the tool is that a user subscribes to one calendar rather than several — so the duplicate-notification problem that stripping would guard against does not arise, while stripping would silently destroy every reminder in the one calendar the user actually watches.

Development

Use devbox for dependency management and scripts. Use jj for version control (colocated with git).

  • devbox run fmt — format.
  • devbox run check — format check, clippy with warnings denied, and tests. This is the gate; jj has no commit step to hang a hook off, since the working copy is itself a commit.
  • devbox run test — tests only.

Keep /target in .gitignore: jj snapshots the working copy on every command, with no staging step.

Coding style

  • Simple, readable, idiomatic.
  • Explicit types for public APIs and important fields.
  • Prefer immutable data structures.
  • No global mutable state.
  • No speculative features or dependencies.
  • Keep functions short and single-purpose; extract helpers to avoid nesting.
  • File and module naming: snake_case.rs. Types and traits: PascalCase. Functions, variables and fields: snake_case. Constants: SCREAMING_SNAKE_CASE.
  • Run devbox run check before describing a change.
  • Comment only when intent is not obvious from the code.
  • Prefer composition and traits over deep type hierarchies.