# Roadmap Milestones from the implementation plan. See SPECS.md for the architecture and the sync semantics these items implement. ## M0 — skeleton (done) - [x] Initialise jj colocated with git; `.gitignore` written before the first build - [x] `devbox.json` pinning Rust 1.97.1, pimsync 0.5.11, jujutsu 0.44.0, radicale 3.7.8 - [x] Configuration model with referential validation, reporting every problem in one pass - [x] `calcalist doctor` — pimsync presence and version series, state directory, configuration - [x] Define the full CLI surface; unimplemented commands exit 2 rather than pretend - [x] 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 - [x] `state.rs` — JSON sidecar, atomic temp + fsync + rename; records each aggregate's resolved target endpoint id **and** backend type, plus per-Google-endpoint cursors - [x] `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 - [x] `ical.rs` — surgical line-level `.ics` editing (UID rewrite, property injection), respecting RFC 5545 folding; no parse-and-reserialize - [x] `provenance.rs` — deterministic `blake3(aggregate_id, source_id, source_uid)` UIDs - [x] `mirror.rs` — the to-aggregate and to-source transforms (added; not in the original plan, which folded these into `reconcile`) - [x] `reconcile.rs` — the aggregation engine; pure, no I/O - [x] `sync.rs` — one cycle: pull, reconcile, push - [x] `retarget.rs` — moving an aggregate to a different target, deliberately - [x] `pimsync.rs` — generate `pimsync.conf` (with `on_empty skip` and `on_delete skip`), drive one-shot `pimsync sync` bracketing the reconcile step - [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets - [x] `google/convert.rs` — iCalendar both ways, including recurrence, timezones and alarms - [x] `google/api.rs` — incremental pull by syncToken; push by import / update / delete with notification suppressed ### Behaviour - [x] **`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`. - [x] `sync` refuses to run on aggregate target drift, before reconciliation - [x] `aggregate retarget` — flush unrouted creations against the old target, then re-materialise; keep old orphans by default, purge bounded by the derivation - [x] Mass-deletion guard (`max_delete_fraction`), overridable with `--force`, with an absolute floor so deleting a couple of events is never refused - [x] Echo suppression: derived UIDs are never re-ingested as source events - [x] 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 - [x] Alarms map both ways, including Google's minutes-before reminder model - [x] `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 - [x] `doctor` validates the generated pimsync config and each Google authorisation ### Tests - [x] `reconcile` table-driven cases: create/update/delete each direction, both-sides-changed, routing, echo suppression, mass-delete abort - [x] `ical` round-trip fixtures: recurring with overrides, all-day, TZID, unknown `X-` props - [x] `convert` against real captured API payloads, including the case where Google's offset and its `timeZone` disagree - [x] 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 - [x] Retarget: drift makes `sync` exit non-zero having written nothing and losing no source event; an unrouted creation reaches a sink first - [x] 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`