Commit graph

6 commits

Author SHA1 Message Date
randogoth
dd984e150a Add aggregate retarget
Moving an aggregate to a different endpoint is the sharpest failure mode in the
design, so it is a command rather than something a config edit triggers: sync
already refuses on target drift and points here.

The move settles against the old target first. An event a user created in the
aggregate and that has not yet reached a sink exists only there, and would be
lost the moment we stop looking at that calendar. Only then is every mapped event
rebuilt on the new target — a rewrite rather than a copy, since the rendered
content differs between backends that can and cannot suppress scheduling.

Orphans on the old target are kept unless --purge-old, and a purge is bounded by
the derivation, so events the user keeps in that calendar themselves survive.

Also fixed prepare_vdirs, which created a directory per endpoint. That told
pimsync a local collection existed before its remote counterpart had been seen,
so it tried to create the counterpart: unsupported for a read-only feed, and it
would have invented calendars on a CalDAV server. Only the vdir root is created
now; collections are pimsync's to make from what it discovers.

One test premise was wrong rather than the code: a foreign event in an aggregate
that has a sink is a user-created event and gets adopted into that sink, so it
never reaches a purge as an outsider. Both behaviours are now covered.

Verified against the live Posteo calendar after the change: still a clean no-op.

106 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:08:41 +03:00
randogoth
9f14c1f651 Add Google OAuth, and let feed URLs come from a secret command
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>
2026-09-10 13:01:06 +03:00
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
randogoth
e7d36879cb Add the aggregation engine and a local sync cycle
The core of M1: everything needed to reconcile source calendars against an
aggregate, short of getting events in and out over the network.

- ical: surgical line-level editing. Each logical line keeps a byte range into
  the original, so untouched lines are emitted verbatim and only edited ones are
  rebuilt. Parsing and re-serialising would drop every property we do not model.
- ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them
  on every store, so hashing them would report a change on every cycle forever.
- provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid),
  length-prefixed so field boundaries cannot collide. Deriving rather than
  recording makes the state file a cache, and makes our own mirrors recognisable,
  which is what stops writes echoing back around.
- mirror: the transforms. An aggregate copy must be scheduling inert, so writing
  it never mails invitations for a meeting already invited from its source.
  Google can suppress notification and keeps real attendees; CalDAV cannot, so
  the guest list is demoted to inert data and a declined meeting is marked
  TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the
  demotion removed, so editing a time cannot silently drop the guests.
- reconcile: pure decision engine. Only the source changed updates the mirror,
  only the aggregate changed writes back, both changed keeps the source and logs
  a conflict.
- Mass-deletion guard takes an absolute floor as well as a fraction: a share
  alone is meaningless at small counts, where deleting the only event is 100%.
- sync refuses to run when an aggregate's configured target differs from the
  recorded one, before reconciling. Otherwise the new empty target would read as
  an aggregate whose every event was deleted, and delete propagation would then
  remove them from every source.

Found by end-to-end testing: writing an item already present under a different
filename created a duplicate rather than replacing it, because filenames are
derived from the UID while pimsync picks its own. Writes now carry the path they
supersede. Covered by a regression test.

79 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
randogoth
ef399db4d8 Add TODO.md tracking the M0-M2 roadmap
The implementation plan lives outside the repo, so it neither travels with the
project nor appears in history. Record the milestones here instead, with M0
checked off and the safety-critical M1 items called out separately from the
module work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:34:50 +03:00
randogoth
2bc93262f8 Scaffold calcalist: repo, toolchain, config model and doctor
Initialise the project per the approved implementation plan (M0).

- Pin the toolchain with devbox: Rust 1.97.1, pimsync 0.5.11, jujutsu 0.44.0,
  and radicale 3.7.8 for later integration tests.
- Add the configuration model with full referential validation, reporting
  every problem in one pass rather than short-circuiting on the first.
- Add `calcalist doctor`, verifying pimsync's presence and version series,
  the state directory, and the configuration.
- Define the whole CLI surface; only `doctor` acts, the rest exit 2 rather
  than pretending to work.
- Rewrite SPECS.md: Rust naming conventions in place of the JS/TS style
  lines, a `devbox run check` gate instead of a pre-commit hook (jj runs no
  git hooks), and the sync semantics the spec previously left unstated.

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