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>
4.7 KiB
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;
.gitignorewritten before the first build devbox.jsonpinning 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 checkgate, 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 typevdir.rs— read and write vdir directoriesical.rs— surgical line-level.icsediting (UID rewrite, property injection), respecting RFC 5545 folding; no parse-and-reserializeprovenance.rs— deterministicblake3(aggregate_id, source_id, source_uid)UIDsmirror.rs— the to-aggregate and to-source transforms (added; not in the original plan, which folded these intoreconcile)reconcile.rs— the aggregation engine; pure, no I/Osync.rs— one cycle over the local vdirs, applying whatreconciledecidespimsync.rs— generatepimsync.conf(withon_empty skipandon_delete skip), drive one-shotpimsync syncbracketing the reconcile stepdoctoraskspimsync checkto validate the generated config, since pimsync's parser does not always match its documentationgoogle/auth.rs— OAuth loopback flow with PKCE, refresh, keyring-sourced secretsgoogle/convert.rs— JSON to iCalendar, including recurrence and timezonesgoogle/api.rs— incremental pull by syncToken, and push by import / update / delete with notification suppressed- Reintroduce
SchedulingSuppressioninconfig.rs(removed in M0 as dead code)
Safety-critical behaviour:
events.importgate — settled from Google's own API discovery document:events.importaccepts nosendUpdatesparameter at all, whileinsert,updateanddeleteall 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 throughimport; update and delete passsendUpdates=none.syncrefuses to run on aggregate target drift, before reconciliationaggregate 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:
reconciletable-driven cases: create/update/delete each direction, both-sides-changed, routing, echo suppression, mass-delete aborticalround-trip fixtures: recurring with overrides, all-day, TZID, unknownX-props- Integration against Radicale plus a
file://WebCal fixture; assert idempotence - Safety (unit level): no live
ATTENDEE/ORGANIZERon a CalDAV-targeted mirror,VALARMintact,PARTSTAT: DECLINEDmaps toTRANSP: 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
syncexit 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.instancesplus a patch per exception. Reported per sync rather than dropped silently. pushdeleting 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) andcalcalist.timer