CalCalist/TODO.md
randogoth edb16204fa Pull Google calendars into their local vdirs
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>
2026-09-10 13:19:34 +03:00

70 lines
4 KiB
Markdown

# 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
Core modules:
- [x] `state.rs` — JSON sidecar, atomic temp + fsync + rename; records each aggregate's
resolved target endpoint id **and** backend type
- [x] `vdir.rs` — read and write vdir directories
- [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 over the local vdirs, applying what `reconcile` decides
- [x] `pimsync.rs` — generate `pimsync.conf` (with `on_empty skip` and `on_delete skip`),
drive one-shot `pimsync sync` bracketing the reconcile step
- [x] `doctor` asks `pimsync check` to validate the generated config, since pimsync's
parser does not always match its documentation
- [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets
- [x] `google/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
- [x] 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.
- [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
- [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
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
- [ ] Integration against Radicale plus a `file://` WebCal fixture; assert idempotence
- [x] 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
- [x] Retarget: drift makes `sync` exit 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) and `calcalist.timer`