From 16f5d7351f53c9f7057d453f0f7679bb383fa97c Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 10 Sep 2026 16:57:51 +0300 Subject: [PATCH] readme update --- README.md | 162 +++++++++++++++++------------------------------------- 1 file changed, 49 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index b2fb169..aba2467 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,8 @@ -# calcalist +# 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. - -Pre-1.0. The command line and the configuration format are settled. +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 @@ -18,10 +11,7 @@ cargo build --release # target/release/calcalist ``` -One runtime dependency: **`pimsync` 0.5.x on `PATH`**, which carries the CalDAV -and iCal legs. It is needed only if some endpoint is one of those — a -Google-only setup needs nothing else, because calcalist talks to Google itself. -pimsync is in nixpkgs, and this repo's `devbox.json` pins 0.5.11. +One runtime dependency is needed for CalDAV and iCal sync: **`pimsync` 0.5.x on `PATH`**. Then check the environment, which is worth doing before writing any config: @@ -31,21 +21,18 @@ 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. +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 ``` -### Endpoints - Each `[[endpoint]]` is one calendar, with an `id` you refer to it by elsewhere. -**CalDAV** +### CalDAV Endpoint ```toml [[endpoint]] @@ -56,17 +43,16 @@ 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. 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: +`url` must name the calendar collection itself, not the server root. + +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** +## Google Calendar Endpoint ```toml [[endpoint]] @@ -78,11 +64,9 @@ 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. +`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** +## iCal Feed Endpoint ```toml [[endpoint]] @@ -91,14 +75,11 @@ 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", which grants read access to anyone holding it. +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 +## Aggregates -Each `[[aggregate]]` composes endpoints: several `sources` mirrored into one -`target`. +Each `[[aggregate]]` composes endpoints: several `sources` mirrored into one `target`. ```toml [[aggregate]] @@ -113,18 +94,15 @@ propagate_deletes = true # deleting a mirror deletes the original max_delete_fraction = 0.2 # abort if more of the aggregate than this vanishes ``` -The rules, all checked before anything is synchronised, with every problem -reported in one pass: +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). +- `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 complete example A Google calendar and a public holiday feed, aggregated into a CalDAV calendar: @@ -157,58 +135,40 @@ sources = ["gcal", "holidays"] default_sink = "gcal" ``` -## Authorising Google +# Authorising Google -Google requires an OAuth client of your own. This is the fiddliest part of the -setup, so in full: +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. 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: +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 - `0600` into the state directory, never into your configuration. + 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. +**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. +**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 +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: +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 @@ -216,38 +176,23 @@ 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. +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. +**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. -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. +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 -- **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. +- **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 @@ -265,14 +210,8 @@ 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. 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. +The service expects the binary at `~/.local/bin/calcalist`; edit `ExecStart` if yours is elsewhere. 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 @@ -288,8 +227,7 @@ in. | `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. +Exit codes: `0` all well, `1` something failed or an endpoint could not be reached. ## Where things are kept @@ -304,14 +242,12 @@ 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 +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 the whole CalDAV leg.** Google endpoints are - isolated from each other, but pimsync is a single process covering every +- **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. ## License