CalCalist/TODO.md
randogoth 06e661b0c4 Add calcalist status, and make routing markers discoverable
The marker that steers a new event is the endpoint's id, which until now meant
remembering what was written in a TOML file while typing into a calendar app.

status lists the endpoints, what each aggregate publishes and mirrors, where
untagged events go, and the exact @markers that would work — read-only sources
omitted, since offering one as a destination would mislead.

A refused marker now names the alternatives rather than only reporting that it
failed, and matching ignores case: the name is typed by hand, and capitalisation
is not worth failing over.

133 tests.

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

7.3 KiB

Roadmap

Milestones from the implementation plan. See SPECS.md for the architecture and the sync semantics these items implement.

M0 — skeleton (done)

  • Initialise jj colocated with git; .gitignore written before the first build
  • devbox.json pinning Rust 1.97.1, pimsync 0.5.11, jujutsu 0.44.0, radicale 3.7.8
  • Configuration model with referential validation, reporting every problem in one pass
  • calcalist doctor — pimsync presence and version series, state directory, configuration
  • Define the full CLI surface; unimplemented commands exit 2 rather than pretend
  • SPECS.md: Rust naming conventions, devbox run check gate, recorded sync semantics

M1 — bidirectional sync

Functionally complete. Everything below is done except the two automated integration tests, which are listed with the remaining gaps at the end.

Core modules

  • state.rs — JSON sidecar, atomic temp + fsync + rename; records each aggregate's resolved target endpoint id and backend type, plus per-Google-endpoint cursors
  • vdir.rs — read and write vdir directories, keyed by the UID inside each file rather than by filename, since pimsync names files its own way
  • ical.rs — surgical line-level .ics editing (UID rewrite, property injection), respecting RFC 5545 folding; no parse-and-reserialize
  • provenance.rs — deterministic blake3(aggregate_id, source_id, source_uid) UIDs
  • mirror.rs — the to-aggregate and to-source transforms (added; not in the original plan, which folded these into reconcile)
  • reconcile.rs — the aggregation engine; pure, no I/O
  • sync.rs — one cycle: pull, reconcile, push
  • retarget.rs — moving an aggregate to a different target, deliberately
  • pimsync.rs — generate pimsync.conf (with on_empty skip and on_delete skip), drive one-shot pimsync sync bracketing the reconcile step
  • google/auth.rs — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets
  • google/convert.rs — iCalendar both ways, including recurrence, timezones and alarms
  • google/api.rs — incremental pull by syncToken; push by import / update / delete with notification suppressed

Behaviour

  • events.import gate — settled from Google's own API discovery document: events.import accepts no sendUpdates parameter at all, while insert, update and delete all do, and it is documented as adding "a private copy of an existing event". Confirmed live that attendees and alarms survive an import. Creation goes through import; update and delete pass sendUpdates=none.
  • sync refuses to run on aggregate target drift, before reconciliation
  • aggregate retarget — flush unrouted creations against the old target, then re-materialise; keep old orphans by default, purge bounded by the derivation
  • Mass-deletion guard (max_delete_fraction), overridable with --force, with an absolute floor so deleting a couple of events is never refused
  • Echo suppression: derived UIDs are never re-ingested as source events
  • Routing: a @endpoint-id line in the description, or a matching category, picks which source a new aggregate event is filed under; a hint naming an invalid sink is refused rather than redirected to the default
  • Alarms map both ways, including Google's minutes-before reminder model
  • webcal URLs may come from a command, for feeds whose address is itself a credential — Google's secret iCal address being the case in point
  • doctor validates the generated pimsync config and each Google authorisation
  • calcalist status — endpoints, aggregates, how many events are mirrored, and the exact @marker names an event can carry, so routing is discoverable without opening the config file

Tests

  • reconcile table-driven cases: create/update/delete each direction, both-sides-changed, routing, echo suppression, mass-delete abort
  • ical round-trip fixtures: recurring with overrides, all-day, TZID, unknown X- props
  • convert against real captured API payloads, including the case where Google's offset and its timeZone disagree
  • Topologies: 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
  • Retarget: drift makes sync exit non-zero having written nothing and losing no source event; an unrouted creation reaches a sink first
  • Safety (unit): no live ATTENDEE/ORGANIZER on a CalDAV-targeted mirror, VALARM intact, PARTSTAT: DECLINED maps to TRANSP: TRANSPARENT

Verified live, end to end

Against real accounts — two Google calendars aggregating into a Posteo CalDAV calendar:

  • Fan-in from both Google sources, each mirror tagged with its origin
  • A public iCal feed synced through pimsync and pushed to CalDAV; idempotent across repeated runs and correctly incremental
  • An edit made in the aggregate reaching the originating Google calendar, and the revert travelling the same way
  • An event created in the aggregate routed to a chosen source by its description marker, with the marker stripped before it arrived
  • A deletion in the aggregate propagating through to Google
  • The mass-deletion guard refusing a 100% removal until --force
  • pimsync check validating the generated config against a live CalDAV server

Remaining

Tests not yet automated

  • Integration against Radicale plus a WebCal fixture, asserting convergence and idempotence. Done by hand twice; not yet a test that runs in CI.
  • Safety integration: a real CalDAV server with an SMTP sink, proving no mail is emitted on mirror writes or mirror deletions.

Known gaps

  • A recurring series' exceptions are not pushed to Google. Pulling them works. Google models them as separate events against an already existing series, so pushing needs events.instances plus a patch per exception. Reported per sync rather than dropped silently.
  • Each Google endpoint needs its own google login, even for the same account and OAuth client, because tokens are keyed by endpoint id. Two endpoints on one account should share a credential.
  • A failed Google pull aborts the whole cycle, including the CalDAV side. Safe — reconciling against a stale snapshot could read as mass deletion — but a lapsed token stops everything. Skipping only the affected aggregates would be better.
  • Removing an endpoint from the config leaves its vdir behind, holding events nothing manages any more.
  • --dry-run skips the pull entirely, so it reports against whatever the last real cycle left behind rather than against current remote state.
  • A TZID is emitted without an accompanying VTIMEZONE. Tolerated by the servers tested so far, and confined to recurring events, but not strictly conformant.

M2 — interface and packaging

  • axum configuration UI, bound to 127.0.0.1
  • Trigger google login from the UI (the loopback handler itself already exists in google/auth.rs)
  • systemd user units: calcalist.service (oneshot) and calcalist.timer