Google requires OAuth for calendar access; app passwords stopped working for CalDAV, CardDAV and IMAP in March 2025, so there is no simpler path to offer. - Authorisation code flow over a loopback redirect, which is what Google supports for desktop clients now the copy-paste flow is gone, with PKCE so an intercepted code is useless without the verifier. Only the refresh token is persisted, 0600, in the state directory. - An expired grant is reported as itself: a consent screen still in Testing has its refresh tokens expired after 7 days, and "run calcalist google login" is more use than Google's bare invalid_grant. - doctor reports whether each Google endpoint is still authorised, since an installation that worked last week can stop with nothing having changed here. A webcal URL may now come from a command instead of the config. Google's secret iCal address grants read access to a whole calendar to anyone holding it, so writing it into a file described as portable and secret-free was a contradiction. Fixed a serious defect in the first draft of this module: random_token used fs::read on /dev/urandom, which reads to end of file. /dev/urandom has no end, so it allocated until the machine ran out of memory — it took the editor down with it. It now reads exactly 32 bytes, and a randomness failure is fatal rather than falling back to the clock, since a guessable state or PKCE verifier defeats the point of having them. Verified end to end against a live Posteo CalDAV calendar: pimsync validated the generated config against the real server, 58 events from a public feed were mirrored and pushed, and a second run was a no-op. 97 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3.7 KiB
3.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/api.rs,google/convert.rs- 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 (verified by hand end to end; still needs an automated test)
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