readme update

This commit is contained in:
randogoth 2026-09-10 16:57:51 +03:00
parent 22a1236f79
commit 16f5d7351f

162
README.md
View file

@ -1,15 +1,8 @@
# calcalist # CalCalist
Aggregate several calendars into one, and keep them in step. Aggregate several calendars into one, and keep them in step.
calcalist mirrors any number of CalDAV calendars, Google calendars and iCal 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.
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.
## Install ## Install
@ -18,10 +11,7 @@ cargo build --release
# target/release/calcalist # target/release/calcalist
``` ```
One runtime dependency: **`pimsync` 0.5.x on `PATH`**, which carries the CalDAV One runtime dependency is needed for CalDAV and iCal sync: **`pimsync` 0.5.x on `PATH`**.
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.
Then check the environment, which is worth doing before writing any config: Then check the environment, which is worth doing before writing any config:
@ -31,21 +21,18 @@ calcalist doctor
## Configure ## Configure
One TOML file at `$XDG_CONFIG_HOME/calcalist/calcalist.toml`, or wherever 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
`--config` points. It holds **no secrets and no sync state**, so it is safe to copy between machines.
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 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.
`secret-tool`, `pass`, `gpg -d`, or anything else that prints the secret.
```toml ```toml
version = 1 version = 1
``` ```
### Endpoints
Each `[[endpoint]]` is one calendar, with an `id` you refer to it by elsewhere. Each `[[endpoint]]` is one calendar, with an `id` you refer to it by elsewhere.
**CalDAV** ### CalDAV Endpoint
```toml ```toml
[[endpoint]] [[endpoint]]
@ -56,17 +43,16 @@ username = "you@posteo.de"
secret_command = "secret-tool lookup service posteo user 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 `url` must name the calendar collection itself, not the server root.
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 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:
rename:
```sh ```sh
calcalist discover https://posteo.de:8443/calendars/you/ \ calcalist discover https://posteo.de:8443/calendars/you/ \
--username you@posteo.de --secret-command "secret-tool lookup service posteo" --username you@posteo.de --secret-command "secret-tool lookup service posteo"
``` ```
**Google** ## Google Calendar Endpoint
```toml ```toml
[[endpoint]] [[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 # 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" `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.
under its settings in Google Calendar. See [Authorising
Google](#authorising-google) for `client_id` and the secret.
**iCal feed** ## iCal Feed Endpoint
```toml ```toml
[[endpoint]] [[endpoint]]
@ -91,14 +75,11 @@ type = "webcal"
url = "https://example.org/holidays.ics" url = "https://example.org/holidays.ics"
``` ```
Always read-only. Give exactly one of `url` or `url_command`; the latter is for 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".
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.
### Aggregates ## Aggregates
Each `[[aggregate]]` composes endpoints: several `sources` mirrored into one Each `[[aggregate]]` composes endpoints: several `sources` mirrored into one `target`.
`target`.
```toml ```toml
[[aggregate]] [[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 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 The rules, all checked before anything is synchronised, with every problem reported in one pass:
reported in one pass:
- `target` must exist and be writable, so never an iCal feed. - `target` must exist and be writable, so never an iCal feed.
- `target` must not also be one of its own `sources`. - `target` must not also be one of its own `sources`.
- `sources` must be non-empty, and each must exist and be named once. - `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. - `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).
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. - 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: A Google calendar and a public holiday feed, aggregated into a CalDAV calendar:
@ -157,58 +135,40 @@ sources = ["gcal", "holidays"]
default_sink = "gcal" default_sink = "gcal"
``` ```
## Authorising Google # Authorising Google
Google requires an OAuth client of your own. This is the fiddliest part of the Google requires an OAuth client of your own. This is the fiddliest part of the setup, so in full:
setup, so in full:
1. At [console.cloud.google.com](https://console.cloud.google.com), create a 1. At [console.cloud.google.com](https://console.cloud.google.com), create a
project — any name. project — any name.
2. **APIs & Services → Library**, find **Google Calendar API**, enable it. 2. **APIs & Services → Library**, find **Google Calendar API**, enable it.
3. **OAuth consent screen**: choose **External**. Fill in the required fields. 3. **OAuth consent screen**: choose **External**. Fill in the required fields. Under **Test users**, add your own Google address.
Under **Test users**, add your own Google address. Without this, authorising 4. **Credentials → Create credentials → OAuth client ID**, and for **Application type** choose **Desktop app**.
fails with `403: access_denied` even though the account is your own. 5. Copy the client ID into `client_id`, and put the client secret somewhere `client_secret_command` can read it:
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:
```sh ```sh
secret-tool store --label="calcalist Google client secret" service google-calcalist secret-tool store --label="calcalist Google client secret" service google-calcalist
``` ```
6. Authorise: 6. Authorise:
```sh ```sh
calcalist google login gcal calcalist google login gcal
``` ```
A browser opens; approve the request. calcalist asks only for 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.
`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 **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.
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 **While the consent screen is in Testing, Google expires refresh tokens after seven days.** An installation that worked last week then stops with nothing
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.
having changed locally. Publish the consent screen to production to end this;
`calcalist doctor` reports it either way.
## Getting events to the right calendar ## Getting events to the right calendar
An event mirrored from a source goes back to that source when you edit it — 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
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`. 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 To send one somewhere else, put the endpoint's id after an `@` **on a line of its own** in the event's notes:
its own** in the event's notes:
``` ```
Dentist, bring the referral Dentist, bring the referral
@ -216,38 +176,23 @@ Dentist, bring the referral
@gcal @gcal
``` ```
The marker is removed before the event arrives. `calcalist status` prints the 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
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.
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 **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
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.
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 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.
event is read as a routing instruction, so the notes marker is the safer form.
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 ## What it does to your calendars
- **Deleting an event in the aggregate deletes it at its source**, unless - **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.
`propagate_deletes = false`. A cycle that would remove more than - **Edited in both places since the last sync, the source wins.** The conflict is reported.
`max_delete_fraction` of the aggregate is refused until you pass `--force` - **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.
with a floor of three, so removing one or two events is never blocked. - **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
- **Edited in both places since the last sync, the source wins.** The conflict 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.
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 ## Run it
@ -265,14 +210,8 @@ systemctl --user daemon-reload
systemctl --user enable --now calcalist.timer systemctl --user enable --now calcalist.timer
``` ```
The service expects the binary at `~/.local/bin/calcalist`; edit `ExecStart` if 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
yours is elsewhere. Only one cycle runs at a time — a timer firing while you are keyring needing an unlocked session, the timer only works while you are logged in.
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 ## 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. | | `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`. | | `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 Exit codes: `0` all well, `1` something failed or an endpoint could not be reached.
reached.
## Where things are kept ## Where things are kept
@ -304,14 +242,12 @@ vdir/<endpoint-id>/ the local mirror of each endpoint
google/accounts/<account>.json refresh tokens, 0600 google/accounts/<account>.json refresh tokens, 0600
``` ```
None of it belongs in a backup or a dotfiles repository — it is a cache, and a 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
lost state file costs a re-materialisation rather than data. Copy the
configuration between machines; leave this behind. configuration between machines; leave this behind.
## Known gaps ## Known gaps
- **A failing `pimsync sync` stops the whole CalDAV leg.** Google endpoints are - **A failing `pimsync sync` stops the whole CalDAV leg.** Google endpoints are isolated from each other, but pimsync is a single process covering every
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. CalDAV and feed pair, and a failure does not say which pair it belongs to.
## License ## License