CalCalist/TODO.md
randogoth 675d1a9397 Drive pimsync for the CalDAV and WebCal legs
Generates pimsync's configuration from calcalist.toml and invokes it one-shot
either side of the reconcile step: pull first so the reconciler sees one
consistent snapshot, push afterwards to publish what it decided. Its daemon
mode stays unused because it would write the same vdirs the reconciler reads.

- Endpoints are mapped with pimsync's collection blocks, matching a local vdir
  subdirectory to an exact remote path. Matching by collection id instead would
  have tied our directory names to whatever the server's URL segments happen to
  be, and two calendars ending in the same segment would collide.
- The remote is storage_a with conflict_resolution keep a, so a concurrent
  server-side change wins over a local one calcalist has not yet reconciled.
  Nothing is lost: the next cycle sees the server's version and decides properly.
- on_empty skip and on_delete skip on every pair, so a failed pull is not
  mirrored as a mass deletion.
- Secrets stay out of the portable config: pimsync is given the same command
  calcalist would run to fetch them.
- Google endpoints are omitted entirely, since pimsync can reach neither its REST
  API nor its CalDAV endpoint.

doctor now asks pimsync to validate the generated config. pimsync is pre-1.0 and
its parser does not always match its documentation, so asking it is the only
reliable check — a parse rejection is reported as a failure, an unreachable
server only as a warning.

Found while testing against the real binary: pimsync 0.5.11 documents read_only
as applying to every storage type but rejects it on a webcal storage, with only
"Could not parse file" to go on. Dropped; it was redundant beside one_way.

Verified end to end against a live Radicale server and an HTTP-served feed: the
feed synced in, mirrored with a derived UID and provenance, and pushed to the
server; repeated runs are no-ops and adding one feed event mirrors exactly one.

87 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 10:25:38 +03:00

3.6 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

Core modules:

  • state.rs — JSON sidecar, atomic temp + fsync + rename; records each aggregate's resolved target endpoint id and backend type
  • vdir.rs — read and write vdir directories
  • 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 over the local vdirs, applying what reconcile decides
  • pimsync.rs — generate pimsync.conf (with on_empty skip and on_delete skip), drive one-shot pimsync sync bracketing the reconcile step
  • doctor asks pimsync check to validate the generated config, since pimsync's parser does not always match its documentation
  • google/auth.rs, google/api.rs, google/convert.rs
  • Reintroduce SchedulingSuppression in config.rs (removed in M0 as dead code)

Safety-critical behaviour:

  • events.import gate — 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 under sendUpdates=none. The Google attendee path depends on it. Fallback if it fails: the same demotion transform used for CalDAV.
  • 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
  • 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:

  • 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
  • Integration against Radicale plus a file:// WebCal fixture; assert idempotence
  • Safety (unit level): no live ATTENDEE/ORGANIZER on a CalDAV-targeted mirror, VALARM intact, PARTSTAT: DECLINED maps to TRANSP: 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 sync exit 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) and calcalist.timer