CalCalist/TODO.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

5.1 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

Core modules:

  • state.rs — JSON sidecar, atomic temp + fsync + rename; records each aggregate's resolved target endpoint id and backend type
  • vdir.rs — read and write vdir directories
  • 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 over the local vdirs, applying what reconcile decides
  • pimsync.rs — generate pimsync.conf (with on_empty skip and on_delete skip), drive one-shot pimsync sync bracketing the reconcile step
  • doctor asks pimsync check to validate the generated config, since pimsync's parser does not always match its documentation
  • google/auth.rs — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets
  • google/convert.rs — JSON to iCalendar, including recurrence and timezones
  • google/api.rs — incremental pull by syncToken, and push by import / update / delete with notification suppressed
  • Reintroduce SchedulingSuppression in config.rs (removed in M0 as dead code)

Safety-critical 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
  • 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

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
  • Integration against Radicale plus a file:// WebCal fixture; assert idempotence
  • Safety (unit level): no live ATTENDEE/ORGANIZER on a CalDAV-targeted mirror, VALARM intact, PARTSTAT: DECLINED maps to TRANSP: TRANSPARENT, bulk deletion aborts
  • Safety (integration): the same against a real Radicale instance with an SMTP sink, proving no mail is emitted
  • Retarget: drift makes sync exit non-zero having written nothing and losing no source event; purge is bounded by the derivation; an unrouted creation reaches a sink first

Verified live, end to end

Two Google calendars aggregating into a Posteo CalDAV calendar, against real accounts: fan-in from both sources with provenance intact; an edit in the aggregate reaching the originating Google calendar; an event created in the aggregate routed to a chosen source by a description marker; deletion propagating from the aggregate through to Google; and the mass-deletion guard refusing a 100% removal until --force.

Known gaps carried out of M1

  • A recurring series' exceptions are not pushed to Google. Google models them as separate events against an already existing series, so they need events.instances plus a patch per exception. Reported per sync rather than dropped silently.
  • push deleting an event remotely — verified live: deleting a mirror in the CalDAV aggregate removed the origin event from Google.
  • A failed Google pull aborts the whole cycle, including the CalDAV side. Safe — reconciling against a stale snapshot could read as mass deletion — but it means a lapsed token stops everything.

M2 — interface and packaging

  • axum configuration UI, bound to 127.0.0.1
  • OAuth loopback redirect handler
  • systemd user units: calcalist.service (oneshot) and calcalist.timer