The Google equivalent of the pimsync pull, which pimsync cannot do: incremental fetch by syncToken, converted to iCalendar and written into the endpoint's vdir. A cursor Google no longer accepts comes back as 410, which means start again rather than something broke, so that case refetches instead of failing. The conversion was written against what the API actually returns, having probed a real calendar first. Three findings shaped it: - A recurring event's exceptions carry the same iCalUID as their master, so a series belongs in one file, which is exactly the vdir convention. - start.dateTime is an absolute instant while start.timeZone names the zone the recurrence expands in, and the two need not agree: a real event reads 2023-10-31T13:00:00+02:00 with timeZone Asia/Karachi, which is +05:00. Emitting the instant under that TZID unconverted would move it three hours, so the instant is converted into its zone. That needs a timezone database, hence jiff. - A deleted occurrence arrives as an override with status cancelled. That is an absence rather than an event, so it becomes an EXDATE on the master; moved occurrences become RECURRENCE-ID events. Timed values are written in UTC unless the event recurs. Only a recurrence needs a zone to expand in, and confining TZID to those events limits how far we depend on clients tolerating a TZID with no VTIMEZONE alongside it. Verified against a live calendar: 30 real events, including a weekly series with both a cancelled and several moved occurrences, converted and re-parsed intact. The retarget tests no longer run a full cycle. They had used Google endpoints as inert local stand-ins, which stopped being true the moment sync learned to contact Google; they now reconcile directly. 115 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 KiB
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;
.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 timezones- [~]
google/api.rs— incremental pull with syncToken done; the push direction (import / update / delete) is still outstanding, so a Google endpoint is currently read-only - Reintroduce
SchedulingSuppressioninconfig.rs(removed in M0 as dead code)
Safety-critical behaviour:
events.importgate — do this first. Import an attendee-bearing event whose guests are on a mail sink we control and confirm no mail is emitted; repeat for update and delete undersendUpdates=none. The Google attendee path depends on it. Fallback if it fails: the same demotion transform used for CalDAV.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
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