The marker that steers a new event is the endpoint's id, which until now meant remembering what was written in a TOML file while typing into a calendar app. status lists the endpoints, what each aggregate publishes and mirrors, where untagged events go, and the exact @markers that would work — read-only sources omitted, since offering one as a destination would mislead. A refused marker now names the alternatives rather than only reporting that it failed, and matching ignores case: the name is typed by hand, and capitalisation is not worth failing over. 133 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
7.3 KiB
Markdown
129 lines
7.3 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
|
|
|
|
Functionally complete. Everything below is done except the two automated
|
|
integration tests, which are listed with the remaining gaps at the end.
|
|
|
|
### Core modules
|
|
|
|
- [x] `state.rs` — JSON sidecar, atomic temp + fsync + rename; records each aggregate's
|
|
resolved target endpoint id **and** backend type, plus per-Google-endpoint cursors
|
|
- [x] `vdir.rs` — read and write vdir directories, keyed by the UID inside each file
|
|
rather than by filename, since pimsync names files its own way
|
|
- [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: pull, reconcile, push
|
|
- [x] `retarget.rs` — moving an aggregate to a different target, deliberately
|
|
- [x] `pimsync.rs` — generate `pimsync.conf` (with `on_empty skip` and `on_delete skip`),
|
|
drive one-shot `pimsync sync` bracketing the reconcile step
|
|
- [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets
|
|
- [x] `google/convert.rs` — iCalendar both ways, including recurrence, timezones and alarms
|
|
- [x] `google/api.rs` — incremental pull by syncToken; push by import / update / delete
|
|
with notification suppressed
|
|
|
|
### Behaviour
|
|
|
|
- [x] **`events.import` gate** — settled from Google's own API discovery document:
|
|
`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". Confirmed live that attendees and alarms survive an import.
|
|
Creation goes through `import`; update and delete pass `sendUpdates=none`.
|
|
- [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, purge bounded by the derivation
|
|
- [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
|
|
- [x] Routing: a `@endpoint-id` line in the description, or a matching category, picks
|
|
which source a new aggregate event is filed under; a hint naming an invalid sink
|
|
is refused rather than redirected to the default
|
|
- [x] Alarms map both ways, including Google's minutes-before reminder model
|
|
- [x] `webcal` URLs may come from a command, for feeds whose address is itself a
|
|
credential — Google's secret iCal address being the case in point
|
|
- [x] `doctor` validates the generated pimsync config and each Google authorisation
|
|
- [x] `calcalist status` — endpoints, aggregates, how many events are mirrored, and the
|
|
exact `@marker` names an event can carry, so routing is discoverable without
|
|
opening the config file
|
|
|
|
### 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
|
|
- [x] `convert` against real captured API payloads, including the case where Google's
|
|
offset and its `timeZone` disagree
|
|
- [x] Topologies: a source feeding several aggregates, several aggregates sharing one
|
|
target, a cycle between two aggregates, a delete cascading across aggregates,
|
|
and competing edits arriving through two aggregates at once
|
|
- [x] Retarget: drift makes `sync` exit non-zero having written nothing and losing no
|
|
source event; an unrouted creation reaches a sink first
|
|
- [x] Safety (unit): no live `ATTENDEE`/`ORGANIZER` on a CalDAV-targeted mirror,
|
|
`VALARM` intact, `PARTSTAT: DECLINED` maps to `TRANSP: TRANSPARENT`
|
|
|
|
### Verified live, end to end
|
|
|
|
Against real accounts — two Google calendars aggregating into a Posteo CalDAV
|
|
calendar:
|
|
|
|
- Fan-in from both Google sources, each mirror tagged with its origin
|
|
- A public iCal feed synced through pimsync and pushed to CalDAV; idempotent across
|
|
repeated runs and correctly incremental
|
|
- An edit made in the aggregate reaching the originating Google calendar, and the
|
|
revert travelling the same way
|
|
- An event created in the aggregate routed to a chosen source by its description
|
|
marker, with the marker stripped before it arrived
|
|
- A deletion in the aggregate propagating through to Google
|
|
- The mass-deletion guard refusing a 100% removal until `--force`
|
|
- `pimsync check` validating the generated config against a live CalDAV server
|
|
|
|
## Remaining
|
|
|
|
### Tests not yet automated
|
|
|
|
- [ ] Integration against Radicale plus a WebCal fixture, asserting convergence and
|
|
idempotence. Done by hand twice; not yet a test that runs in CI.
|
|
- [ ] Safety integration: a real CalDAV server with an SMTP sink, proving no mail is
|
|
emitted on mirror writes or mirror deletions.
|
|
|
|
### Known gaps
|
|
|
|
- [ ] A recurring series' *exceptions* are not pushed to Google. Pulling them works.
|
|
Google models them as separate events against an already existing series, so
|
|
pushing needs `events.instances` plus a patch per exception. Reported per sync
|
|
rather than dropped silently.
|
|
- [ ] Each Google endpoint needs its own `google login`, even for the same account and
|
|
OAuth client, because tokens are keyed by endpoint id. Two endpoints on one
|
|
account should share a credential.
|
|
- [ ] A failed Google pull aborts the whole cycle, including the CalDAV side. Safe —
|
|
reconciling against a stale snapshot could read as mass deletion — but a lapsed
|
|
token stops everything. Skipping only the affected aggregates would be better.
|
|
- [ ] Removing an endpoint from the config leaves its vdir behind, holding events
|
|
nothing manages any more.
|
|
- [ ] `--dry-run` skips the pull entirely, so it reports against whatever the last
|
|
real cycle left behind rather than against current remote state.
|
|
- [ ] A `TZID` is emitted without an accompanying `VTIMEZONE`. Tolerated by the servers
|
|
tested so far, and confined to recurring events, but not strictly conformant.
|
|
|
|
## M2 — interface and packaging
|
|
|
|
- [ ] axum configuration UI, bound to 127.0.0.1
|
|
- [ ] Trigger `google login` from the UI (the loopback handler itself already exists
|
|
in `google/auth.rs`)
|
|
- [ ] systemd user units: `calcalist.service` (oneshot) and `calcalist.timer`
|