CalCalist/TODO.md
randogoth 623505b9c8 Close the remaining M1 gaps
Eight items were still open at the end of M1: two integration tests that had
only been run by hand, and six known gaps.

Recurrence overrides now reach Google. Google addresses an exception through
the series rather than as an event of its own, so the master is sent first and
each override is then matched to its instance by original start time and
patched. Matching needs the two sides' spellings reduced to one key: iCalendar
writes a zoned local time, Google an absolute offset. An override matching no
occurrence is counted rather than forced — that means a stale RECURRENCE-ID
left behind by an edited RRULE, and inventing an event for it would put
something in the calendar the series does not contain.

Reading one component apart from another needed a view `properties` cannot
give: it flattens every VEVENT together, which is right for the UID a series
shares and wrong for an override, whose SUMMARY and the master's are then
indistinguishable. `Calendar::events` splits them.

A TZID now travels with the VTIMEZONE that defines it, derived from the zone's
own transition table as the yearly rule it implies. This changes the content
hash of every zoned recurring event, so the first cycle after this re-pushes
them.

A Google authorisation is filed under the account it was granted for rather
than the endpoint that asked for it, so two endpoints on one account no longer
need a login each. The account is read from the primary calendar's id, which
needs no scope beyond the calendar one already granted. Authorisations written
by the previous scheme are still honoured, and move across at the next login.

An unreachable Google endpoint no longer ends the cycle — one lapsed token used
to stop the CalDAV side too. It is named, only the aggregates depending on it
stand down, and the run exits non-zero so a partial cycle cannot pass for
success. The CalDAV leg cannot be narrowed the same way: pimsync is one process
covering every pair, so a failure does not say which pair it belongs to.

`--dry-run` now pulls for real, into a throwaway copy of the local mirrors and
through a pimsync configuration that only ever reads from a server. What it
reports is measured against the calendars as they are now rather than against
whatever the last real cycle left behind.

`calcalist prune` reports local mirrors of endpoints the configuration no
longer names, and removes them under --force.

The integration tests run against a real Radicale server and a real iCal feed:
convergence and idempotence, the dry run, prune, and the scheduling rule
asserted on the bytes that actually reached the server. The plan asked for an
SMTP sink for that last one; Radicale implements no RFC 6638 scheduling, so a
quiet SMTP port would have proved nothing about the transform.

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

8.4 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

Complete. Every item from the plan is built, tested and verified live.

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
  • Recurrence overrides are pushed to Google. Google addresses an exception through the series rather than as an event of its own, so the master goes up first and each override is then matched to its instance by original start time and patched. An override matching no occurrence is counted, not forced.
  • A TZID now travels with the VTIMEZONE that defines it, derived from the zone's own transition table as the yearly rule it implies. A zone that does not shift gets one fixed observance.
  • One Google authorisation covers every endpoint on that account. Tokens are keyed by the account, discovered at login from the primary calendar's id — which needs no scope beyond the calendar one already granted. An endpoint names its account only when calcalist is logged in to more than one.
  • A Google endpoint that cannot be reached no longer ends the cycle. It is named, only the aggregates depending on it stand down, and the run exits non-zero so a lapsed token cannot pass for success.
  • calcalist prune — reports local mirrors of endpoints the configuration no longer names, and removes them under --force
  • --dry-run pulls for real, into a throwaway copy of the local mirrors and through a pimsync configuration that only ever reads from a server. What it reports is measured against the calendars as they are now, and nothing outside the copy is written.

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

Integration

  • Against a real Radicale server and a real iCal feed, over pimsync: two sources converge on a CalDAV target, and the next cycle is a no-op.

  • Safety, against the same server: no live ATTENDEE or ORGANIZER reaches the aggregate, the guest list survives as inert data, the alarm survives intact, and the source keeps its scheduling properties.

    The plan asked for an SMTP sink here. Radicale implements no RFC 6638
    scheduling, so a quiet SMTP port would have proved nothing about the
    transform — no server in reach of a test sends calendar mail at all. What
    is asserted instead is the bytes that reached the server, which is the
    thing the transform is actually responsible for. Proving the Google half
    was settled separately, from the API discovery document.
    
  • --dry-run reaches the servers, reports what it found, and leaves both the target and the local mirrors untouched.

  • A retired endpoint's mirror is reported by prune and removed under --force.

Known gaps

  • A pimsync sync that fails takes the whole CalDAV leg with it. Unlike the Google side this cannot be narrowed: pimsync is one process covering every pair, so a failure does not say which pair it belongs to.

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