# 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. ## Install ```sh cargo build --release # target/release/calcalist ``` With Nix flakes enabled: ```sh nix build # ./result/bin/calcalist # or: nix run . -- doctor # or: nix profile install . ``` Or straight from the repo, without cloning it first: ```sh nix profile install "git+https://code.randogoth.com/randogoth/CalCalist.git#calcalist" ``` One runtime dependency is needed for CalDAV endpoints that are not `native`, and for `calcalist discover`: **`pimsync`, from 0.5.11 up to but not including 0.7.0, on `PATH`**. Google calendars, iCal feeds and `native` CalDAV collections need nothing extra — calcalist syncs those itself, so a configuration whose CalDAV endpoints are all `native` never calls pimsync during a cycle. The Nix flake installation method already ensures this: it builds pimsync from source and wraps `calcalist` so that copy is the one found, whatever else is installed. To run a different pimsync — a fork, or a working tree — override the input at install time: ```sh nix profile install "/path/to/calcalist#calcalist" \ --override-input pimsync-src "git+file:///path/to/pimsync" ``` The built pimsync reports the revision it came from (`pimsync 0.6.0-a08ee3c`), so the two are told apart. Note that `nix profile upgrade` does **not** remember an override: upgrading rebuilds against the default input, and the install command has to be repeated to get the fork back. Then check the environment, which is worth doing before writing any config: ```sh calcalist doctor ``` ## Configure 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: 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. ```toml version = 1 ``` Each `[[endpoint]]` is one calendar, with an `id` you refer to it by elsewhere. ### CalDAV Endpoint ```toml [[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" native = true # optional; sync this collection with calcalist's own client ``` `url` must name the calendar collection itself, not the server root. `native = true` syncs the collection with calcalist's own CalDAV client instead of pimsync, so that endpoint needs no external binary and reports its own failures rather than taking the whole CalDAV leg down with it. It is per-endpoint so the two can run side by side, one collection each, while the native client earns the default. It is developed and tested against [Radicale](https://radicale.org/); other CalDAV servers are untested rather than unsupported, and `calcalist doctor` prints what each server advertises so an unfamiliar one fails legibly. Rather than digging it out of a provider's web interface, ask the server — this prints a ready-to-paste block per calendar, with ids taken from the URL for you to rename: ```sh calcalist discover https://posteo.de:8443/calendars/you/ \ --username you@posteo.de --secret-command "secret-tool lookup service posteo" ``` ## Google Calendar Endpoint ```toml [[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 its settings in Google Calendar. See [Authorising Google](#authorising-google) for `client_id` and the secret. ## iCal Feed Endpoint ```toml [[endpoint]] id = "holidays" type = "webcal" url = "https://example.org/holidays.ics" ``` Always read-only. Give exactly one of `url` or `url_command`; the latter is for a feed whose address is itself a credential, such as Google's "secret address in iCal format". ## Aggregates Each `[[aggregate]]` composes endpoints: several `sources` mirrored into one `target`. ```toml [[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 # skip_past = "2026-01-01" # leave finished, non-repeating events from before this out ``` 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 — see [Getting events to the right calendar](#getting-events-to-the-right-calendar). - 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: ```toml 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](https://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. 4. **Credentials → Create credentials → OAuth client ID**, and for **Application type** choose **Desktop app**. 5. Copy the client ID into `client_id`, and put the client secret somewhere `client_secret_command` can read it: ```sh secret-tool store --label="calcalist Google client secret" service google-calcalist ``` 6. Authorise: ```sh 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 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. **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. Publish the consent screen to production to end this; `calcalist doctor` reports it either way. ## Getting events to the right calendar An event mirrored from a source goes back to that source when you edit it — CalCalist tracks where each one came from. An event you *create* in the aggregate belongs to nothing yet, and goes to the aggregate's `default_sink`. To send one 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 arrives. `calcalist status` prints the markers that would actually work, so you need not remember what is in the config; a marker naming anything else is refused and reported rather than quietly filed under the default. **To keep an event in the aggregate itself**, tag it `@local` — it then stays put even though a `default_sink` would have taken it, and the marker is deliberately left in place so later cycles decide the same way. Leaving `default_sink` out of the aggregate entirely makes that the rule for every untagged event, so routing becomes opt-in. Such events live only in that calendar and cannot be rebuilt from a source, but they are ordinary events on that server, visible to every client subscribed to it. A category matching an endpoint id works too. Note that *any* category on a new event is read as a routing instruction, so the notes marker is the safer form. ## What it does to your calendars - **`skip_past` leaves history behind.** Give it a date — `skip_past = "2026-01-01"` — and an event is left out when it ended before that date *and* carries no `RRULE` or `RDATE`, so a series that began years ago still travels; its next occurrence is the point. An event whose dates cannot be read is kept rather than guessed at. This is what makes a first sync carry only what is still to come instead of publishing years of finished events into a calendar you subscribe to; on one real configuration it was 79% of them. A fixed date rather than a rolling "before now" is deliberate: a moving cutoff would retire events from the aggregate as they aged, so the published calendar would quietly delete yesterday's events on every cycle. `calcalist sync --skip-past [DATE]` applies one for a single cycle whatever the file says, defaulting to today when given bare — useful with `--dry-run` for seeing what a cutoff would leave out. Excluded events are never deleted from their source calendar; only the aggregate's copy is affected. Applying a cutoff to an aggregate that already carries history retires those mirrors, which can trip the mass-deletion guard and need `--force` the first time. - **Deleting an event in the aggregate deletes it at its source**, unless `propagate_deletes = false`. A cycle that would remove more than `max_delete_fraction` of the aggregate is refused until you pass `--force` — with a floor of three, so removing one or two events is never blocked. - **Edited in both places since the last sync, the source wins.** The conflict is reported. - **Alarms are always kept**, with no option to strip them. The tool exists so you subscribe to one calendar rather than several, so dropping reminders would disarm every one you have. - **Mirroring never mails your guests.** On a Google target attendees are kept and Google is told not to notify; on a CalDAV target, which cannot be told that, the guest list is carried as inert data instead. Routing an event *you* created is the exception: that server invites its guests for real, which is the point of creating it. ## Run it ```sh calcalist doctor # environment and configuration calcalist sync # one cycle ``` To run it regularly, units ship in [systemd/](systemd/): ```sh 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 `~/.nix-profile/bin/calcalist`, where `nix profile install` puts it; edit `ExecStart` if yours is elsewhere — a `cargo install` lands in `~/.cargo/bin`. The path is absolute because a user manager's `PATH` is not the login shell's. Only one cycle runs at a time — a timer firing while you are running `sync` by hand is refused, naming the process that holds the lock. A cycle that could not reach an endpoint exits non-zero on purpose, so a lapsed token shows up as a failed unit rather than passing unnoticed; `journalctl --user -u calcalist` has the report. If your secrets come from a keyring needing an unlocked session, the timer only works while you are logged in. ## Commands `--help` has the flags. | Command | | |---|---| | `sync` | One cycle. `--dry-run` reports against current remote state without writing; `--force` overrides the mass-deletion guard. | | `status` | Endpoints, aggregates, how much is mirrored, and the routing markers that work. | | `doctor` | Checks the environment and configuration. Run it first. | | `discover` | Lists a CalDAV server's calendars as endpoint blocks to paste. | | `google login` | Authorises an account. | | `aggregate retarget` | Moves an aggregate to a different target calendar. `sync` refuses on its own when the target changes under it, and points here. | | `prune` | Reports local mirrors of endpoints no longer configured; removes them under `--force`. | Exit codes: `0` all well, `1` something failed or an endpoint could not be reached. ## Where things are kept Everything machine-local lives under `$XDG_STATE_HOME/calcalist`: ``` state.json event mappings, content hashes, sync cursors lock held for the duration of a cycle pimsync.conf generated when pimsync is used; edits are overwritten pimsync-status/ pimsync's own record of what it has seen vdir// the local mirror of each endpoint google/accounts/.json refresh tokens, 0600 ``` None of it belongs in a backup or a dotfiles repository — it is a cache, and a lost state file costs a re-materialisation rather than data. Copy the configuration between machines; leave this behind. ## Known gaps - **A failing `pimsync sync` stops every CalDAV endpoint that is not `native`.** Google, feed and `native` CalDAV endpoints are isolated from each other and from this, but pimsync is a single process covering every pair it still owns, and a failure does not say which pair it belongs to. Setting `native = true` on those endpoints removes them from its reach, and with it the gap. ## License MIT. See [LICENSE](LICENSE).