readme update
This commit is contained in:
parent
22a1236f79
commit
16f5d7351f
1 changed files with 49 additions and 113 deletions
162
README.md
162
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue