No description
Find a file
randogoth 3d701361da Bundle pimsync 0.5.11 in the flake package; enforce it as the version floor
The Nix-packaged calcalist now wraps its PATH with a pinned pimsync
0.5.11 build, so discover works regardless of what else is on the
user's PATH. discover's output format is undocumented and changed
between 0.5.9 and 0.5.11 in a way the parser doesn't handle; doctor
and the NotFound error now enforce 0.5.11 as a floor instead of
accepting any 0.5.x patch. README's install instructions and pimsync
note are corrected to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 17:30:39 +03:00
src Bundle pimsync 0.5.11 in the flake package; enforce it as the version floor 2026-09-10 17:30:39 +03:00
systemd Finish M2: run lock, systemd units, discover; drop the web UI 2026-09-10 16:23:49 +03:00
tests Finish M2: run lock, systemd units, discover; drop the web UI 2026-09-10 16:23:49 +03:00
.gitignore Add Google OAuth, and let feed URLs come from a secret command 2026-09-10 13:01:06 +03:00
Cargo.lock Finish M2: run lock, systemd units, discover; drop the web UI 2026-09-10 16:23:49 +03:00
Cargo.toml Add the MIT license 2026-09-10 16:42:41 +03:00
CLAUDE.md Cut the README to a getting-started guide; retire SPECS.md and TODO.md 2026-09-10 16:41:34 +03:00
devbox.json Scaffold calcalist: repo, toolchain, config model and doctor 2026-09-10 09:31:32 +03:00
devbox.lock Scaffold calcalist: repo, toolchain, config model and doctor 2026-09-10 09:31:32 +03:00
flake.lock Add a flake.nix for Nix packaging; document nix install paths in README 2026-09-10 17:08:09 +03:00
flake.nix Bundle pimsync 0.5.11 in the flake package; enforce it as the version floor 2026-09-10 17:30:39 +03:00
LICENSE Add the MIT license 2026-09-10 16:42:41 +03:00
README.md Bundle pimsync 0.5.11 in the flake package; enforce it as the version floor 2026-09-10 17:30:39 +03:00

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

cargo build --release
# target/release/calcalist

With Nix flakes enabled:

nix build   # ./result/bin/calcalist
# or: nix run . -- doctor
# or: nix profile install .

Or straight from the repo, without cloning it first:

nix profile install "git+https://code.randogoth.com/randogoth/CalCalist.git#calcalist"

One runtime dependency is needed for CalDAV and iCal sync: pimsync 0.5.11 or newer on PATH. The Nix flake installation method already ensures this.

Then check the environment, which is worth doing before writing any config:

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.

version = 1

Each [[endpoint]] is one calendar, with an id you refer to it by elsewhere.

CalDAV Endpoint

[[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"

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:

calcalist discover https://posteo.de:8443/calendars/you/ \
  --username you@posteo.de --secret-command "secret-tool lookup service posteo"

Google Calendar Endpoint

[[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 for client_id and the secret.

iCal Feed Endpoint

[[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.

[[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

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.
  • 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:

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, 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:

    secret-tool store --label="calcalist Google client secret" service google-calcalist
    
  6. Authorise:

    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

  • 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

calcalist doctor    # environment and configuration
calcalist sync      # one cycle

To run it regularly, units ship in systemd/:

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 ~/.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

--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 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.

License

MIT. See LICENSE.