Cut the README to a getting-started guide; retire SPECS.md and TODO.md
The README had grown to 413 lines and explained as much as it instructed: someone trying to get calcalist running read past the architecture, the scheduling-inertness rule and the reasoning behind retarget before reaching anything they needed to type. Most of the excess was duplication of SPECS.md, which is now retired along with TODO.md since the roadmap is finished. That inverted the usual answer — the rationale could not simply move to the design document, because there would not be one. Checking what would actually be orphaned: the architecture rationale is already in the module doc comments, beside the code it governs and where it cannot drift; the M0-M2 record is history and git keeps it. Two things had no home: the working agreement that has shaped every commit, and two decisions that cost real design work to decline and would otherwise be re-proposed from first principles. Both are now in CLAUDE.md, which is read at the start of every session rather than filed and forgotten. What the README keeps is the whole setup path, because none of it exists anywhere else now: every configuration field, the Google OAuth walkthrough in full, routing, and the systemd units. What it gains is a short section on what the tool does to your calendars — deletion propagating to the source is not something to discover by accident. What it loses is the design commentary. Also moves `discover` out of the command list and into the CalDAV endpoint section, since getting the url field right is its entire purpose. Verified rather than eyeballed: the worked example was extracted and run through doctor and status, the command table diffed against --help, and every internal anchor resolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ef6482ed62
commit
4854844a72
4 changed files with 187 additions and 448 deletions
324
README.md
324
README.md
|
|
@ -9,20 +9,7 @@ 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.
|
||||
Pre-1.0. The command line and the configuration format are settled.
|
||||
|
||||
## Install
|
||||
|
||||
|
|
@ -31,31 +18,24 @@ 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.
|
||||
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.
|
||||
|
||||
Then check the environment:
|
||||
Then check the environment, which is worth doing before writing any config:
|
||||
|
||||
```sh
|
||||
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
|
||||
## 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 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.
|
||||
`--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
|
||||
|
|
@ -76,9 +56,15 @@ 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.
|
||||
`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**
|
||||
|
||||
|
|
@ -93,8 +79,8 @@ client_secret_command = "secret-tool lookup service google-calcalist"
|
|||
```
|
||||
|
||||
`calendar_id` is `primary`, or the calendar's own address — the "Calendar ID"
|
||||
under the calendar's settings in Google Calendar. See
|
||||
[Authorising Google](#authorising-google) below 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**
|
||||
|
||||
|
|
@ -105,14 +91,9 @@ 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:
|
||||
|
||||
```toml
|
||||
url_command = "secret-tool lookup service google-secret-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.
|
||||
|
||||
### Aggregates
|
||||
|
||||
|
|
@ -132,11 +113,6 @@ 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:
|
||||
|
||||
|
|
@ -144,8 +120,8 @@ reported in one pass:
|
|||
- `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](#events-of-the-aggregates-own).
|
||||
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
|
||||
|
|
@ -218,25 +194,21 @@ setup, so in full:
|
|||
**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.
|
||||
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; `calcalist google login` again, or publish the consent
|
||||
screen. `calcalist doctor` reports this rather than letting it surface as an
|
||||
opaque failure.
|
||||
having changed locally. Publish the consent screen to production to end this;
|
||||
`calcalist doctor` reports it either way.
|
||||
|
||||
## Routing: getting a new event to the right calendar
|
||||
## Getting events 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 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`.
|
||||
|
||||
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:
|
||||
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
|
||||
|
|
@ -244,140 +216,47 @@ 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.
|
||||
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.
|
||||
|
||||
`calcalist status` prints the markers that would actually work, so you do not
|
||||
have to remember what is in the configuration file:
|
||||
**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.
|
||||
|
||||
```
|
||||
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)
|
||||
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.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
calcalist doctor # environment and configuration
|
||||
calcalist sync # one cycle
|
||||
```
|
||||
|
||||
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/](systemd/):
|
||||
To run it regularly, units ship in [systemd/](systemd/):
|
||||
|
||||
```sh
|
||||
install -Dm644 systemd/calcalist.service ~/.config/systemd/user/calcalist.service
|
||||
|
|
@ -387,27 +266,50 @@ 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
## Commands
|
||||
|
||||
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.
|
||||
`--help` has the flags.
|
||||
|
||||
## Not yet
|
||||
| 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 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 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
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue