CalCalist/README.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

16 KiB

calcalist

Aggregate several calendars into one, and keep them in step.

calcalist mirrors any number of CalDAV calendars, Google calendars and iCal feeds into a single calendar you subscribe to. The point is to subscribe to one calendar instead of several: an event created or edited in the aggregate travels back to the calendar it came from, so the aggregate is somewhere to work rather than a read-only noticeboard. CalDAV and Google are read/write in both directions; iCal feeds are read-only, because the protocol is.

This is pre-1.0. The command line and the configuration format are settled; there is no web interface yet. SPECS.md covers why it is built this way.

How it works

Every endpoint — the sources and the aggregate's target alike — is mirrored to a directory of .ics files on your machine, and the aggregation engine works purely on those files. One cycle is: pull every remote into its local mirror, reconcile the mirrors against each other, push the results back out.

pimsync carries the CalDAV and iCal legs. calcalist carries the Google leg itself, over the Calendar REST API — pimsync cannot reach Google at all, having no REST storage and only HTTP Basic auth, which Google's CalDAV endpoint has rejected since March 2025.

Install

cargo build --release
# target/release/calcalist

One runtime dependency: pimsync 0.5.x on PATH, needed only if any endpoint is CalDAV or an iCal feed. A Google-only setup needs nothing else. pimsync is in nixpkgs; this repo's devbox.json pins 0.5.11 if you would rather not install it yourself.

Then check the environment:

calcalist doctor

It reports whether pimsync is present and in the supported version series, whether the state directory is writable, whether your configuration is valid, whether pimsync accepts the configuration calcalist generates for it, and whether each Google endpoint still has a usable authorisation.

Configuration

One TOML file at $XDG_CONFIG_HOME/calcalist/calcalist.toml, or wherever --config points.

It holds no secrets and no sync state, so it is safe to copy between machines. Credentials come from *_command fields instead: each is run through sh -c and the first line of its output is used. Point them at secret-tool, pass, gpg -d, or anything else that prints the secret.

version = 1

Endpoints

Each [[endpoint]] is one calendar, with an id you refer to it by elsewhere.

CalDAV

[[endpoint]]
id = "posteo"
type = "caldav"
url = "https://posteo.de:8443/calendars/you/abc123/work/"
username = "you@posteo.de"
secret_command = "secret-tool lookup service posteo user you@posteo.de"

url must name the calendar collection itself, not the server root or your principal — the last path segment is the calendar. A URL with no collection in it is rejected, and calcalist doctor says so.

Google

[[endpoint]]
id = "gcal"
type = "google"
calendar_id = "you@gmail.com"
client_id = "1234-abcd.apps.googleusercontent.com"
client_secret_command = "secret-tool lookup service google-calcalist"
# account = "you@gmail.com"   # only when logged in to more than one account

calendar_id is primary, or the calendar's own address — the "Calendar ID" under the calendar's settings in Google Calendar. See Authorising Google below for client_id and the secret.

iCal feed

[[endpoint]]
id = "holidays"
type = "webcal"
url = "https://example.org/holidays.ics"

Always read-only. Give exactly one of url or url_command — the latter for a feed whose address is itself a credential, Google's "secret address in iCal format" being the case in point, since anyone holding it can read the whole calendar:

url_command = "secret-tool lookup service google-secret-ics"

Aggregates

Each [[aggregate]] composes endpoints: several sources mirrored into one target.

[[aggregate]]
id = "unified"
target = "posteo"
sources = ["gcal", "holidays"]
default_sink = "gcal"

# Defaults, all optional:
conflict = "source_wins"      # the only policy; the origin calendar wins
propagate_deletes = true      # deleting a mirror deletes the original
max_delete_fraction = 0.2     # abort if more of the aggregate than this vanishes

max_delete_fraction guards against a cycle that would remove a large part of your calendars — a mistyped URL, a server answering with an empty collection. It has an absolute floor of three deletions, so removing one or two events is never refused. calcalist sync --force overrides it.

The rules, all checked before anything is synchronised, with every problem reported in one pass:

  • target must exist and be writable, so never an iCal feed.
  • target must not also be one of its own sources.
  • sources must be non-empty, and each must exist and be named once.
  • default_sink, if given, must be one of the sources and must be writable. Leaving it out is a deliberate mode, not an omission — see Events of the aggregate's own.
  • No endpoint may be called local, which is reserved for the routing marker.

A complete example

A Google calendar and a public holiday feed, aggregated into a CalDAV calendar:

version = 1

[[endpoint]]
id = "gcal"
type = "google"
calendar_id = "primary"
client_id = "1234-abcd.apps.googleusercontent.com"
client_secret_command = "secret-tool lookup service google-calcalist"

[[endpoint]]
id = "holidays"
type = "webcal"
url = "https://www.officeholidays.com/ics/germany"

[[endpoint]]
id = "posteo"
type = "caldav"
url = "https://posteo.de:8443/calendars/you/abc123/unified/"
username = "you@posteo.de"
secret_command = "secret-tool lookup service posteo user you@posteo.de"

[[aggregate]]
id = "unified"
target = "posteo"
sources = ["gcal", "holidays"]
default_sink = "gcal"

Authorising Google

Google requires an OAuth client of your own. This is the fiddliest part of the setup, so in full:

  1. At console.cloud.google.com, create a project — any name.

  2. APIs & Services → Library, find Google Calendar API, enable it.

  3. OAuth consent screen: choose External. Fill in the required fields. Under Test users, add your own Google address. Without this, authorising fails with 403: access_denied even though the account is your own.

  4. Credentials → Create credentials → OAuth client ID, and for Application type choose Desktop app.

    Desktop app matters. calcalist listens on a loopback port it picks at run time and hands Google that address as the redirect, so there is no redirect URI to register and no domain to verify. A Web application client asks for both and cannot be made to work here.

  5. Copy the client ID into client_id, and put the client secret somewhere client_secret_command can read it:

    secret-tool store --label="calcalist Google client secret" service google-calcalist
    
  6. Authorise:

    calcalist google login gcal
    

    A browser opens; approve the request. calcalist asks only for https://www.googleapis.com/auth/calendar. The refresh token is written 0600 into the state directory, never into your configuration.

One login covers every endpoint on that account. The authorisation is filed under the account it was granted for, not the endpoint that asked, so a second Google calendar on the same account needs no login of its own. Set account on an endpoint only when calcalist is logged in to more than one account and has to be told which is meant.

While the consent screen is in Testing, Google expires refresh tokens after seven days. An installation that worked last week then stops with nothing having changed locally; calcalist google login again, or publish the consent screen. calcalist doctor reports this rather than letting it surface as an opaque failure.

Routing: getting a new event to the right calendar

Every mirrored event carries where it came from — X-CALCALIST-SOURCE and X-CALCALIST-ORIGIN-UID — and a UID derived from its origin, so an edit made in the aggregate goes back to the calendar that owns the event without any guesswork.

An event you create in the aggregate belongs to nothing yet. It goes to the aggregate's default_sink. To send it somewhere else, put the endpoint's id after an @ on a line of its own in the event's notes:

Dentist, bring the referral

@gcal

The marker is removed before the event reaches the calendar. A whole line is required so that an address or a handle written in prose is not mistaken for an instruction.

calcalist status prints the markers that would actually work, so you do not have to remember what is in the configuration file:

aggregate `unified`
  published to   posteo
  sources        gcal, holidays
  mirroring      42 event(s)
  new events go to `gcal` unless told otherwise
  to choose, put one of these on a line of its own in the event's notes:
      @gcal
      @local    (keep the event here, in this calendar only)

Read-only feeds are left out, since they cannot take an event. A marker naming anything else is refused and reported — the event stays where it is rather than being quietly filed under the default, which would put it somewhere you did not ask for.

Events of the aggregate's own

An aggregate is also a calendar, and it can hold events that belong to nothing else. There are two ways to get one:

  • @local on an event, exactly like any other marker, keeps it where it was written even though a default_sink would otherwise have taken it. Unlike every other marker it is not removed from the event: the others have done their job once the event reaches its source, whereas this one has to stay legible so every later cycle reaches the same decision.
  • Leave default_sink out of the aggregate entirely. Then nothing is configured to file a new event under, so every untagged event stays put and only @-tagged ones are sent anywhere. Routing becomes opt-in rather than opt-out.

local is a reserved endpoint id for this reason, and configuring an endpoint with that name is refused.

One thing to be aware of: every other event in an aggregate is derived from a source, so the aggregate is disposable — lose the calendar and it rebuilds itself on the next sync. An event of the aggregate's own exists in exactly one place. Nothing else holds a copy, so it is only as safe as that calendar is. aggregate retarget carries these events to the new target along with everything else, since there is nothing to re-derive them from.

A category matching an endpoint id works too, that being the field iCalendar intends for this. Be aware that any category on a newly created event is read as a routing instruction, so an event carrying an unrelated category will be refused rather than sent to the default sink. The notes marker is the safer form, and the one every mobile client exposes.

Attendees and alarms

Writing to an aggregate must never mail anyone: the guests were already invited from the original calendar. On a Google target, attendees are kept verbatim and Google is told not to notify. A CalDAV server offers no portable way to be told that, so the guest list is instead carried as inert data — the live ATTENDEE and ORGANIZER properties are dropped, the guests appear in X-CALCALIST-ATTENDEES and in the description, and a meeting you declined is marked free.

Routing works the other way round: an event you created and sent to a source is a meeting you are deliberately organising, so its attendees are preserved and that server invites them for real.

Alarms are always kept, with no option to strip them. The whole point of the tool is that you subscribe to one calendar rather than several, so dropping reminders would silently disarm every one you have.

Commands

calcalist sync [--dry-run] [--force]
calcalist status
calcalist doctor
calcalist prune [--force]
calcalist discover <url> --username <name> [--secret-command <cmd>]
calcalist google login <endpoint>
calcalist aggregate retarget <id> --to <endpoint> [--keep-old | --purge-old]
  • sync runs one cycle. --dry-run pulls from the servers for real, into a throwaway copy of the local mirrors, so what it reports is measured against your calendars as they are now — and nothing outside that copy is written. --force overrides the mass-deletion guard.
  • status lists endpoints and aggregates, how many events each aggregate is mirroring, and the routing markers that would work.
  • doctor checks the environment and configuration. Run it first.
  • discover asks a CalDAV server which calendars it has and prints an [[endpoint]] block for each, ready to paste. Give it the account or principal URL rather than a single calendar. This is the easy way to get the url field right.
  • prune reports local mirrors belonging to endpoints your configuration no longer names. It only lists them until you pass --force, since an endpoint may just have been renamed.
  • aggregate retarget moves an aggregate to a different target calendar deliberately. sync refuses to run when an aggregate's target has changed under it and points here instead — the new, empty target would otherwise read as an aggregate whose every event had been deleted, and with delete propagation on, that would remove them from every source calendar. Events left on the old target are kept unless you pass --purge-old, which only removes events calcalist put there.

Exit codes: 0 all well, 1 something failed or an endpoint could not be reached.

If a Google endpoint cannot be reached — a lapsed token, no network — the cycle does not stop. The endpoint is named, only the aggregates that depend on it stand down, everything else is synchronised as usual, and the run exits 1 so the failure cannot pass unnoticed.

Where things are kept

Everything machine-local lives under $XDG_STATE_HOME/calcalist:

state.json                       event mappings, content hashes, sync cursors
pimsync.conf                     generated on every sync; edits are overwritten
pimsync-status/                  pimsync's own record of what it has seen
vdir/<endpoint-id>/              the local mirror of each endpoint
google/accounts/<account>.json   refresh tokens, 0600

None of this belongs in a backup or a dotfiles repository. The state file is a cache rather than a single point of failure — aggregate UIDs are derived from their origin, so a lost state file is rebuilt by re-deriving it. Copy the configuration between machines; leave this behind.

Running it regularly

Units ship in systemd/:

install -Dm644 systemd/calcalist.service ~/.config/systemd/user/calcalist.service
install -Dm644 systemd/calcalist.timer   ~/.config/systemd/user/calcalist.timer
systemctl --user daemon-reload
systemctl --user enable --now calcalist.timer

The service expects the binary at ~/.local/bin/calcalist; edit ExecStart if yours is elsewhere. The timer runs every 15 minutes with a randomised delay of up to two minutes, which spreads requests instead of every installation calling Google on the quarter hour — its quota is enforced per minute, per project.

A cycle that could not reach an endpoint exits non-zero deliberately, so a lapsed token surfaces as a failed unit in systemctl --user status calcalist rather than passing unnoticed. journalctl --user -u calcalist has the report.

Only one cycle runs at a time: calcalist takes a lock on its state directory, so a timer firing while you are running calcalist sync by hand is refused with a message naming the process that holds it, rather than the two interleaving their writes. If your secrets come from a keyring that needs an unlocked session, the timer will only work while you are logged in.

Not yet

  • A failing pimsync sync stops the whole CalDAV leg. Google endpoints are isolated from each other, but pimsync is a single process covering every CalDAV and feed pair, and a failure does not say which pair it belongs to.

There is deliberately no web interface. It was designed and then dropped: the Google OAuth setup, which no interface can remove, is a far higher bar than editing this file, so a UI would have served an audience that never gets as far as the config. doctor, status and discover cover what it was for.