CalCalist/TODO.md
randogoth bc64f19211 Push local changes up to Google, completing the Google leg
Mirrors are created with events.import, updated and deleted with
sendUpdates=none, so a write to an aggregate cannot mail anyone about a meeting
that was already invited from its source.

The import question the plan flagged is settled from Google's own API discovery
document rather than by guesswork: 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". A method with notification behaviour would
need that control. Confirmed live that attendees survive an import with their
response statuses intact.

Testing against the real API caught a defect that unit tests could not: the first
push dropped VALARM entirely and Google substituted the calendar's default
reminders. Alarms are never stripped by decision, so they now map to and from
Google's reminder overrides in both directions, with relative TRIGGER durations
converted to whole minutes. A trigger Google cannot express — absolute, or after
the start — is dropped rather than guessed at.

Known gaps recorded in TODO.md rather than papered over: a series' exceptions are
not pushed, since Google models those as separate events against an existing
series; and a failed Google pull still aborts the cycle, which is safe but stops
the CalDAV side too.

121 tests.

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

4.7 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

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 is exercised only when the reconciler removes a mirror mid-cycle; it has no end-to-end test yet, because a pull legitimately resurrects anything deleted from a vdir before the cycle runs.
  • 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