CalCalist/SPECS.md
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

62 lines
5.4 KiB
Markdown

# Calcalist
## Idea
Small Rust based commandline tool with utility systemd service file that can aggregate and sync events between CalDAV/Google Calendar/iCal. Users who have a Google Calendar can create a new calendar that syncs events from several CalDAV calendars. CalDAV users can sync several Google Calendars into one calendar. This should be doable both ways, so the aggregating calendar needs to be able to distinguish where its events came from and also have a way to create new events that get synced to the correct source. In addition iCal feeds can also be aggregated but they remain one way only.
Configuration is saved in a portable toml file that can be easily migrated.
The web interface this originally called for was designed and then dropped. Setting up Google's OAuth client — a Cloud project, a consent screen, a secret in a keyring — is a far higher bar than editing thirty lines of TOML, and no interface can remove it, so a configuration UI would have served an audience that never reaches the configuration. `calcalist doctor` reports every problem in one pass, `calcalist status` names the routing markers an event can carry, and `calcalist discover` prints ready-to-paste endpoint blocks for a server's calendars — which is what the interface was actually for.
## Architecture
Every endpoint — sources and aggregate targets alike — is mirrored to a local vdir, so the aggregation engine works purely on local files.
- `pimsync` is driven as a one-shot subprocess for the CalDAV and WebCal legs. Its daemon mode is unusable here: it would race the reconciler over the same vdir files, so calcalist owns scheduling.
- The Google leg is calcalist's own, via the Calendar REST API. pimsync cannot reach Google — it has no REST storage, and its config exposes only HTTP Basic auth, which Google's CalDAV endpoint has rejected since 2025-03-14.
- Sync state (event mappings, hashes, sync tokens) lives in a JSON sidecar under `$XDG_STATE_HOME/calcalist/`, never in the portable config.
- Secrets are never stored in the config either. Credentials come from `*_command` fields that are executed to fetch them.
- A Google authorisation is filed under the account it was granted for, not the endpoint that asked for it, so every endpoint on that account shares it. An endpoint names its `account` only when calcalist is logged in to more than one.
## Sync semantics
| Question | Behaviour |
|---|---|
| Provenance | Aggregate UIDs derived as `blake3(aggregate_id, source_id, source_uid)`; the state file is a cache, not a single point of failure |
| Routing new events | A `@endpoint-id` line in the description, or a matching category, picks the source; otherwise the aggregate's `default_sink`. A hint naming an invalid sink is refused, never redirected to the default |
| Conflicts | Source wins — the origin calendar is authoritative |
| Deletion | Propagates to the source, guarded by a mass-deletion threshold |
| Attendees (mirroring) | Kept verbatim on a Google target; demoted to inert data on a CalDAV target |
| Attendees (routing) | Preserved — the sink server sends real invitations, which is intended |
| Alarms | Always preserved, with no option to strip them |
| Retargeting | `sync` refuses on target drift; `aggregate retarget` performs it deliberately |
| Recurrence | A series and its exceptions share one file. Google addresses an exception through the series, so the master is sent first and each override is matched to its instance by original start time and patched |
| Unreachable endpoints | Named in the report; only the aggregates depending on them stand down, and the run exits non-zero |
| Dry runs | Pull for real, into a throwaway copy of the local mirrors and through a read-only pimsync configuration. Nothing outside the copy is written |
The governing rule behind the attendee handling: **writes to an aggregate must never emit scheduling mail; writes to a source schedule normally.** Google can be told not to notify, so attendees survive intact there. CalDAV offers no portable way to suppress RFC 6638 scheduling, so inertness is achieved structurally by dropping the live properties instead.
Alarms are never stripped because the whole point of the tool is that a user subscribes to one calendar rather than several — so the duplicate-notification problem that stripping would guard against does not arise, while stripping would silently destroy every reminder in the one calendar the user actually watches.
## Development
Use devbox for dependency management and scripts. Use jj for version control (colocated with git).
- `devbox run fmt` — format.
- `devbox run check` — format check, clippy with warnings denied, and tests. This is the gate; jj has no commit step to hang a hook off, since the working copy is itself a commit.
- `devbox run test` — tests only.
Keep `/target` in `.gitignore`: jj snapshots the working copy on every command, with no staging step.
## Coding style
- Simple, readable, idiomatic.
- Explicit types for public APIs and important fields.
- Prefer immutable data structures.
- No global mutable state.
- No speculative features or dependencies.
- Keep functions short and single-purpose; extract helpers to avoid nesting.
- File and module naming: `snake_case.rs`. Types and traits: `PascalCase`. Functions, variables and fields: `snake_case`. Constants: `SCREAMING_SNAKE_CASE`.
- Run `devbox run check` before describing a change.
- Comment only when intent is not obvious from the code.
- Prefer composition and traits over deep type hierarchies.