CalCalist/Cargo.toml

27 lines
628 B
TOML
Raw Normal View History

[package]
name = "calcalist"
version = "0.1.0"
edition = "2024"
rust-version = "1.97"
description = "Aggregate and sync events between CalDAV, Google Calendar and iCal feeds"
license = "MIT"
publish = false
[dependencies]
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
blake3 = "1.8"
clap = { version = "4.6", features = ["derive"] }
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:13:26 +03:00
jiff = "0.2"
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:17:34 +03:00
rustix = { version = "1.1", features = ["fs"] }
serde = { version = "1.0", features = ["derive"] }
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
serde_json = "1.0"
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 12:40:47 +03:00
sha2 = "0.11"
thiserror = "2.0"
toml = "1.1"
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 12:40:47 +03:00
ureq = { version = "3.4", default-features = false, features = ["json", "rustls", "gzip"] }
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
[dev-dependencies]
tempfile = "3"
[lints.rust]
unsafe_code = "forbid"