Commit graph

6 commits

Author SHA1 Message Date
randogoth
22a1236f79 Add the MIT license
LICENSE with the standard text, `license = "MIT"` in Cargo.toml so the metadata
matches, and a line in the README pointing at it.

Copyright is attributed to `randogoth`, taken from the repository's author
setting, and dated 2026.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:42:41 +03:00
randogoth
ef6482ed62 Finish M2: run lock, systemd units, discover; drop the web UI
Designing the configuration UI in full made the case against building it. Its
audience would be people who find TOML hard, but with bring-your-own OAuth
client settled, every user must first create a Google Cloud project, configure
a consent screen and put a secret in a keyring — a far higher bar than editing
thirty lines of config. Anyone who clears it can edit the file; anyone who
cannot never reaches the file. Against that stood three dependencies, five
modules, an auth.rs refactor and a security surface guarding something that
reads the config and touches the keyring. `doctor` and `status` had already
absorbed most of what it was for. The reasoning is recorded in TODO.md and
SPECS.md rather than left as an apparent oversight.

The run lock is not a UI feature and closes a gap that already existed: nothing
stopped a timer firing into a hand-run cycle, and two cycles interleaving
writes over the same vdirs is what the design otherwise avoids. flock is used
rather than a pid file because the kernel releases it however the process ends,
so a crash cannot leave a lock to clear by hand — which also means a lock we
failed to take is held by a live process, so the pid in it is worth reporting.

The one idea worth keeping from the UI design was collection discovery, which
needed no web layer. `calcalist discover` prints a ready-to-paste endpoint
block per calendar a server offers, removing the most error-prone field in the
config. pimsync's discovery output is undocumented, so the format was
established against a real server first. Two things it teaches: everything
arrives on stdout including failures, and a pair has two storages, so pimsync
reports the scratch vdir's contents too — parsing anchors on the heading naming
the server, or a probe directory's leftovers would be offered as the user's
calendars.

Verified against Posteo as well as Radicale: all four calendars found, the
first matching the URL already configured.

Also fixes a real defect in the test harness rather than its symptom. Ports were
chosen by binding one and letting go, so two tests could pick the same number —
and the loser's readiness check then succeeded against the winner's server,
silently sharing it. Startup now confirms the child we spawned is the one alive,
retries on another port if not, and waits for a real HTTP response rather than
an open socket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:23:49 +03:00
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
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
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
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