Capycal
Capycal is calendar sync for the Mac, Linux and servers. It copies events from one calendar to another and removes everything you don’t want the other side to see. It works with any CalDAV server and, on macOS, with every calendar in Calendar.app through EventKit.
A meeting called “Board Review: Q3 Financials” in your work calendar becomes “Busy (work)” in your personal calendar. The slot is blocked, the content stays private.
Why
Work and personal calendars usually live with different providers: Google Workspace, Exchange, iCloud, Nextcloud or a self-hosted server. Colleagues need to see when you are busy privately, and your family needs to see when you are in meetings, without either side seeing the details. Most providers can’t share free/busy information with each other, so the usual workaround is copying events by hand.
Capycal does this copying for you:
- It syncs one way, from a source calendar to a target calendar. Two syncs in opposite directions give you a two-way setup.
- It copies when an event takes place and whether it blocks time. Titles, descriptions, attendees and locations stay behind unless you list them in
fields. - Transforms rewrite what is left: set the title to “Busy”, add travel buffers or reminders, drop weekend or all-day events.
- Created events carry a marker, so Capycal never copies its own blockers back and can remove them again.
On macOS, Capycal can read from and write to Calendar.app directly. Calendar.app already aggregates Google, Exchange, iCloud and local calendars, so you get all of them without dealing with OAuth or Exchange authentication. On any platform, Capycal can also read a calendar from its private iCal address, such as Google’s secret address, as a read-only source.
Two ways to run it
| Desktop app | capycal CLI | |
|---|---|---|
| Platforms | macOS 14 or later, Linux (GTK) | Linux, macOS |
| Runs as | menu bar or tray app, optional Start at Login | daemon, one-shot (--once), systemd / launchd / Docker service |
| Setup | Settings window with an assistant | TOML file |
| Status | status per sync in the menu and in Settings, live log window | logs (pretty or JSON), --status |
Both use the same sync engine and the same config file: the app writes the TOML for you, and you can edit it by hand next to it. Use the app on a computer you are logged in to, and the CLI on a server or for scripting.
This book follows that split. After Installation, Desktop app and CLI and server each start with a quick start. How syncing works explains calendars, syncs, transforms and full syncs for both: every option names its place in Settings and its key in the config. The Cookbook shows common setups both ways, the Reference lists every config option.
Terms used in this book
- Calendar: a named entry under
[calendars.<name>]: a CalDAV collection, a Calendar.app calendar or a read-only feed (an iCal address). A calendar can be the source of one sync and the target of another. - Sync: one
[syncs.<name>]table that copies events from a source to a target everyinterval. - Blocker: a stripped-down copy of an event that only blocks the time slot.
- Marker: the
X-CAPYCAL-SOURCE/X-CAPYCAL-SYNCproperties (or a[capycal:...]line in the notes for Calendar.app) on every event Capycal creates. It names the sync and the source event, so Capycal can find its events again without its state files.
Support
Capycal is free and open source. If it is useful to you, you can support its development through GitHub Sponsors or Ko-fi.
Installation
Prebuilt downloads are on the releases page:
| Platform | File |
|---|---|
| macOS menu bar app (Apple silicon, macOS 14+) | capycal-v<version>-macos.dmg (includes the CLI) |
| macOS CLI (Apple silicon) | capycal-v<version>-aarch64-apple-darwin.tar.gz |
| Linux x86_64 (static) | capycal-v<version>-x86_64-unknown-linux-musl.tar.gz, capycal_<version>-1_amd64.deb, capycal-<version>-1.x86_64.rpm |
| Linux arm64 (static) | capycal-v<version>-aarch64-unknown-linux-musl.tar.gz, capycal_<version>-1_arm64.deb, capycal-<version>-1.aarch64.rpm |
| Arch Linux | PKGBUILD of the AUR package capycal-bin |
| Linux desktop app (x86_64, arm64; GTK 4.14+) | capycal-gtk_<version>-1_<arch>.deb, capycal-gtk-<version>-1.<arch>.rpm, see Desktop app (Linux) |
On macOS and Linux, Homebrew installs the CLI and the macOS app from the tap twiebe/capycal.
Each file has a .sha256 file next to it:
shasum -a 256 -c capycal-v<version>-<target>.tar.gz.sha256
Homebrew
brew install twiebe/capycal/capycal # CLI (macOS on Apple silicon, Linux)
brew install --cask twiebe/capycal/capycal-app # menu bar app (macOS)
The formula installs capycal with the man page and shell completions; brew services start capycal runs it in the background with your default config. The cask installs Capycal.app without linking its bundled CLI, so both can be installed side by side. The app is not notarized either: before its first start, remove the quarantine flag as described under macOS app.
macOS app
-
Open
capycal-v<version>-macos.dmgand dragCapycal.appto Applications. -
The app is neither signed with an Apple Developer ID nor notarized, so macOS refuses the first start. Right-click
Capycal.appin Finder and choose Open, then confirm. Alternatively remove the quarantine flag once:xattr -dr com.apple.quarantine /Applications/Capycal.app -
When Capycal first reads Calendar.app, macOS asks for calendar access for “Capycal”. Allow it. You can change this later in System Settings > Privacy & Security > Calendars.
The bundle also contains the capycal CLI. To use it from a terminal:
sudo ln -s /Applications/Capycal.app/Contents/MacOS/capycal /usr/local/bin/capycal
Continue with Quick start: desktop app.
Linux packages
sudo apt install ./capycal_<version>-1_amd64.deb # Debian, Ubuntu
sudo dnf install ./capycal-<version>-1.x86_64.rpm # Fedora, RHEL, openSUSE (zypper)
On Arch Linux, install capycal-bin from the AUR, or build the PKGBUILD from the release with makepkg -si.
The packages install /usr/bin/capycal, the man page (man capycal), completions for bash, zsh and fish, and two systemd units that are not enabled: the user service capycal.service (runs your default config, see User service) and the system service capycal.service (see System service). Then:
capycal --generate-config # ~/.config/capycal/capycal.toml
capycal --discover https://caldav.example.com/
capycal --once # try it
systemctl --user enable --now capycal
CLI binaries
tar -xzf capycal-v<version>-<target>.tar.gz
sudo install capycal-v<version>-<target>/capycal /usr/local/bin/
The archive also contains README.md, LICENSE, CONTRIBUTORS.md, THIRD-PARTY-NOTICES.txt, capycal.example.toml, the man page capycal.1, shell completions in completions/ and the systemd units in systemd/. Completions and the man page can also be generated by the binary itself:
capycal --completions bash > ~/.local/share/bash-completion/completions/capycal
capycal --completions zsh > ~/.zfunc/_capycal # a directory in your fpath
capycal --completions fish > ~/.config/fish/completions/capycal.fish
capycal --man > ~/.local/share/man/man1/capycal.1
macOS: the binary is not notarized. If macOS refuses to start it, remove the quarantine flag once:
xattr -d com.apple.quarantine /usr/local/bin/capycal
Linux: the binaries are static musl builds and do not use NSS for name resolution. Regular DNS and /etc/hosts work, but hostnames that only resolve through NSS modules do not, for example mDNS *.local names via nss-mdns, or LDAP. If your CalDAV server has such a name, use its IP address or build from source, which links against glibc and uses the system’s NSS configuration.
Linux builds support CalDAV only. Calendar.app (EventKit) calendars need macOS.
From source
Requires a recent stable Rust toolchain (edition 2024).
cargo install --git https://github.com/twiebe/capycal
EventKit support is compiled in automatically on macOS. The menu bar app is behind the ui feature; see Building and testing for building Capycal.app.
Docker
The repository contains a Dockerfile and a docker-compose.yml that build the CLI from source:
git clone https://github.com/twiebe/capycal && cd capycal
cp capycal.example.toml capycal.toml # edit it
cp .env.example .env # put your passwords here
docker compose up -d
See Running on a server for details.
Quick start: desktop app
The menu bar app runs the syncs of one config file in the background. This example copies a work calendar (for example an Exchange account added to Calendar.app) as blockers into a calendar of your iCloud account.
1. Prepare the calendars
In Calendar.app, add the accounts you want to sync (System Settings > Internet Accounts) and create an empty calendar for the blockers, here “Work Blockers” in iCloud.
2. Start the app
Start Capycal.app from Applications (see Installation for the first start). A calendar icon appears in the menu bar; the app has no Dock icon. On the first start the app writes a commented-out starter config to ~/Library/Application Support/capycal/capycal.toml and opens Settings.
3. Add the sync
Settings starts an assistant. Once you choose Calendar.app, it says why and macOS asks whether “Capycal” may access your calendars; allow it, so the lists can show them.
- Welcome to Capycal: choose the card “Calendars in Calendar.app”, then Continue.
- Copy events from: the work calendar, for example “Calendar” under “Exchange”. The calendars are listed under the account they belong to.
- Into: “Work Blockers” under “iCloud”. Calendars you may not write to are marked “read-only” and cannot be chosen.
- What should the copies show?: the card “Busy blocker” (copies titled “Busy (Work)”, after the source calendar). Each card shows how a copied event will look.
- Name the sync: change the proposed name (made from the two calendars) to
work-to-blockers, then Create.
For a CalDAV calendar (Nextcloud, Fastmail, …) choose the card “A CalDAV account” in the first step (or go Back to it), or Add Account… in Settings later: server URL, user name and password (the password goes into the Keychain), then its calendars appear in the lists.
Settings writes the config for you, keeping it readable:
[calendars.exchange_calendar]
eventkit_calendar = "A1B2C3D4-..."
[calendars.icloud_work_blockers]
eventkit_calendar = "E5F6..."
[syncs.work-to-blockers]
source = "exchange_calendar"
target = "icloud_work_blockers"
interval = "5m"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Busy" },
]
You can still edit it by hand with Open in Editor… under Settings > General; the app reloads when the file changes.
4. Allow calendar access
If you have not allowed calendar access yet, macOS asks on the first sync. Allow it. If you dismissed the prompt, enable Capycal in System Settings > Privacy & Security > Calendars and choose Sync Now.
5. Check the status
The menu shows one line per sync, for example “work-to-blockers: synced just now”. Show Log under Settings > General opens the live log if something goes wrong. Turn on Start Capycal at login in Settings > General to keep the syncs running after a restart. To take the blockers out of the target calendar again, use Remove Synced Events.
See Desktop app for every menu item and macOS and Calendar.app for what is synced from Calendar.app.
Menu and status
Capycal.app runs the syncs of one config without a terminal or LaunchAgent. It needs macOS 14 or later on Apple silicon. It lives in the menu bar: everything is in the calendar icon there. While one of its windows is open (Settings, Log, Problems, About), it also shows in the Dock and in Cmd+Tab, with a menu bar of its own: Settings… (Cmd+,), Quit Capycal (Cmd+Q), Edit for the text fields and Window (Cmd+W, Cmd+M). Closing the last window takes it out of the Dock again; a click on its Dock icon with no window open shows Settings. Quitting with unsaved changes in Settings asks first.
Menu bar icon
The icon shows the overall state; hover over it for a one-line summary.
| Icon | State |
|---|---|
| Calendar | All syncs ran without errors |
| Calendar with a dot | A sync is running, or events are being removed |
| Calendar with a red dot | A sync failed or had failed events, or the config has an error. The menu shows which |
| Dimmed calendar | Starting, not configured yet, or all syncs disabled |
Menu
| Item | What it does |
|---|---|
| Status lines | One line per sync, or the reason nothing runs (see below) |
| Sync Now | Start a cycle of every sync without waiting for its interval. Enabled while the config is loaded and at least one of its syncs runs (is not disabled) |
| Settings… | Everything you can set: syncs, accounts and, under General, starting at login, the log level and the config file (see Settings) |
| About Capycal | App icon, version and a random tagline, with buttons for the website, reporting an issue and sponsoring, and Acknowledgements…: Capycal’s license, its contributors and the licenses of the software it is built from |
| Quit Capycal | Remove the menu bar icon and close the windows at once, stop the syncs (waiting up to 35 seconds for a running one to finish its current step) and quit |
Repairs live in Settings: Full Sync Now, Remove Synced Events… and Remove All Events… on a sync’s page under Advanced settings > Troubleshooting, the config file and Show Log on General, which also shows what keeps the config from running. A sync is switched off and on in Settings, with the switch next to its name. While events are counted or removed, Sync Now is disabled.
Status lines
| Line | Meaning |
|---|---|
work-to-home: synced 2 min ago | The last cycle succeeded |
work-to-home: synced 2 min ago, 3 failed | The last cycle ran, but 3 events could not be written; they are retried every cycle |
work-to-home: syncing | A cycle is running |
work-to-home: error: ... | The last cycle failed, for example because the server was unreachable |
work-to-home: waiting | Started, no cycle finished yet |
work-to-home: disabled | enabled = false in the config, for example after Remove Synced Events; switching it on in Settings starts it again |
Not set up yet: open Settings… | The config is valid but has no syncs, for example the starter config |
Counting synced events, Removing the events of work-to-home | Remove Synced Events is running |
No config: open Settings… to set one up | The config file doesn’t exist (it was deleted after the start, or could not be written) |
Config error: ... | The config is invalid. If syncs were running, they continue with the previous config |
Another Capycal runs this config (pid N) | Another Capycal process runs this config (see config lock) |
Removing events failed: ... | A target calendar could not be read (the sync keeps running), some events could not be deleted (the sync is disabled), or the config could not be written to disable the sync (it is stopped until Capycal restarts; restart it once the config is writable) |
Lines that report a problem (a sync error, failed events, a config error, the lock, a failed removal, enable or disable) can be clicked and open the Problems window.
Problems window
The Problems window shows every current problem with its full message, for example a config error with the line, column and the offending part of the file, followed by the most recent warnings and errors of the log (for failed events, these name the events). It updates while it is open and says “No problems” once everything runs again.
- Edit Config opens the config in your editor
- Reload Config loads it again
- Show Log opens the log window
When loading the config fails, at the start or after you edited the file, Settings opens on General and shows the mistake, so a typo in the config shows up right away. Sync errors don’t open it, since a server that is briefly unreachable would otherwise open it again and again; the red menu bar icon and the status line show them.
Log window
The log window shows the recent log lines and new ones as they arrive, colored by level.
- Search: show only lines containing the text (case-insensitive).
- Level: show only lines of this level and more severe ones (Error, Warn, Info, Debug; Debug includes trace lines).
- Follow: keep the newest line in view. Scrolling up turns it off, scrolling back to the end turns it on again.
- Clear View: empty the window. The log itself is not affected.
- Export: save the complete log of the current app session as a text file: all levels, ignoring search and level filter.
The window keeps the last 5000 lines. Which lines are logged at all depends on log_level in [daemon]; set log_level = "debug" and save the config for more detail. The app applies the new level when it reloads the config. RUST_LOG, if set when the app starts, overrides log_level and is not changed by a reload.
Log files
The app writes its log to ~/Library/Logs/capycal/capycal-ui.log (also visible in Console.app):
- Every app start begins a new file; the previous one becomes
capycal-ui.log.1. - A file is also rotated when it would exceed 10 MB.
- Rotation shifts
.1to.2and so on. The last 5 rotated files are kept, older ones are deleted.
Export covers only the current session. If a very long session was rotated more than 5 times, the export starts with a note that older parts are gone.
Settings
First start
If the config file doesn’t exist when the app starts, or holds nothing but blank lines and comments, the app writes the starter config there (creating the directory, keeping existing comments below it) and logs “starter config written”. Every setting in it is commented out, so the app greets you with a small “Welcome to Capycal” window (once per start): it asks whether your calendars are in Calendar.app or in a CalDAV account (and adds the account), then for the source, the target (in another account added right there, if need be) and what to copy, one question at a time, with dots showing the step and Cancel, Back and Continue at the bottom (Return continues, Escape cancels). At the end it says the sync runs; Open Settings shows the full editor, Done closes the window. Sync Now stays disabled until there is one.
The window
Settings… opens a window (as large as you left it, on the entry you looked at last) with your syncs and accounts on the left (a disabled sync has a grey icon, one whose last sync failed an orange one) and the selected one on the right. Add Sync… below the syncs and Add Account… below the accounts add one. At the bottom of the sidebar is the version of Capycal. Under a sync’s name, a line says how it is doing: “Synced 2 min ago: 3 new, 1 removed”, “Syncing now…”, or why the last sync failed. The bar at the bottom says when there are unsaved changes and holds Revert and Save; a dot after the entry’s name in the list marks them too. Cmd+S saves, Cmd+N adds a sync and Cmd+Delete deletes the entry shown (on Linux with Ctrl).
Add Sync… starts the same assistant in a small window over Settings (a sheet on macOS), with one question per step: the source calendar, the target calendar (the source and calendars you may only read are greyed out) what the copies show: Busy blocker (only the time, titled “Busy (Work)” after the source calendar), Title and time, Everything (title, notes, location and reminders), each card with a preview of a copied event, or Custom, and last its name, proposed as work-to-blockers. Create saves the sync and shows it in the editor below, where you can change everything. A Custom sync starts switched off (enabled = false), so it copies nothing until you set it up and switch it on. Cancel (or closing it) returns to Settings as it was. Set Up in Editor leaves the assistant for the editor with what you picked so far, for those who rather fill in the form. A right click on a sync in the list offers Duplicate: a new, unsaved copy named <sync>-copy. Calendar.app’s calendars are listed under their account (iCloud, Exchange, …), accounts and calendars in alphabetical order. A calendar in a CalDAV account that is not set up yet: Add Another Account… (Add CalDAV Account… while there is none) below the calendars of the source or target step adds the account in the assistant and comes back to that step with its calendars, for example a second server for the target of a CalDAV to CalDAV sync, or a CalDAV target for a Calendar.app source. Add Account… in the sidebar adds one outside the assistant. Add Calendar Feed… below the calendars of the first step shows a Feed address field: paste a calendar’s iCal address there to copy from it.
A sync shows its name at the top (click it to rename; Esc or a click beside it ends editing) with its on/off switch at the right (off is enabled = false: configured but stopped), and below a sentence saying what it does, such as “Copies events from Calendar into Family every 5 min.” The step you work in is outlined in the accent color. Below, the sync is drawn as the steps an event takes, from top to bottom:
- From: the source calendar and how often it is checked (Check every: 1, 5 or 15 minutes, or Custom for any other, such as
10mor2h). The calendar menus list the calendars of every account, found on the server when the window opens, and the calendars of Calendar.app. Without a connection they show the error and the calendars already in the config; Refresh next to them asks the servers again. Add Calendar Feed… at the end of the menu reads a calendar from its iCal address instead, such as Google’s secret address: paste it into Feed address below, and the feed is named after the site (“google”). Feeds you added are listed under Feeds, and Feed address shows and changes the address of the one chosen; an address set in the config file throughenv,file,commandorkeychainis not shown. See Calendar feeds. - Keep: the content copied on top of times, recurrence, status and free/busy: Only the time, Title, Title, notes, location, reminders, or Custom, which shows the fields as checkboxes and others (such as
X-CUSTOM) as text. - One step per transform, in the order they run, each numbered and as a sentence (“Set SUMMARY to “Busy”“). Click one to edit its fields in it (Field suggests common iCal fields such as
LOCATIONand takes any other name too, always in upper case) and its conditions: Add Condition adds one, which reads Only if (If for Skip events, which starts with one and keeps at least one) and then if, with what it looks at (Title, Location, Notes, Other field, Start with one checkbox per weekday and From / Until, Event for all-day), its test and its text, arrows to move it up or down, Remove, and a handle (≡) to drag it to another place; the and / or switch between two conditions says whether both have to match or one is enough, and conditions joined by or share a box, One of these (a condition dropped inside it joins it, one dropped right above or below it stays out) (see Conditions); drag it by its handle (≡) to another place, the bin removes it. The + between two steps adds a transform right there, each named with a line saying what it does; below the transforms its menu offers Common steps that come filled in: hide the details (title “Busy”), travel time (30 min before and after), travel time only for events with a location, skip weekends, skip outside working hours (Mon-Fri before 8:00 and from 18:00) and a reminder 10 min before. Cmd+Z undoes adding, moving or removing a step. Without a mouse, a right click on a step (VoiceOver: its context menu) offers Move Up, Move Down and Remove, and Cmd+Option+Up / Down moves the open step (on Linux Ctrl+Alt). A field with a mistake (an invalid regex, a time like 25:00) shows it below the field once you leave it, not while you type. While something keeps Save disabled, the bar at the bottom says what, with a warning sign, for example “Set field (step 1): fill in Field and Value”. - Into: the target calendar. A calendar the account may only read, and a feed, is greyed out.
- Advanced settings below: how often a full sync runs (Full sync every), the window of days read from a Calendar.app source, Parallel writes of a CalDAV target (
concurrencyof the target calendar, default 2, shared by every sync writing into it), and, for a saved sync, Troubleshooting: Full Sync Now compares all copies with the source and fixes the ones that differ (the status line says “Full sync running…” meanwhile, and the bar at the bottom shows how far it got, as it does for the full sync that follows a save), Remove Synced Events… removes this sync’s copies from the target, after asking, and switches the sync off, Remove All Events… empties the target calendar (see below).
Right of the steps, One event on its way shows a sample meeting (Tuesday 9:00 to 10:00, with a place, notes and a reminder) and the copy this sync would make of it, through the same fields and transforms the sync uses; it follows every change before you save. Next event above it reads the source’s next event and shows that one instead of the sample (again each time you choose it). Picking a calendar that is not in the config yet adds a [calendars.<name>] entry for it when you save; a calendar no sync uses any more is removed after asking. Advanced settings below the steps hold the full sync interval and, for a Calendar.app source, Calendar.app events: the days back and ahead the sync reads; with All events these do not apply and are greyed out.
Save writes the config, keeping your comments and the order of everything else, and the syncs restart with it. Renaming a sync or changing its target first removes the events it copied into the old target, after asking, and then copies the source again; otherwise the old copies would stay behind without a sync that owns them. Changing what a sync copies (source, fields, transforms) makes its next cycle a full sync, so the copies already made get the new content. Delete (at the bottom left) removes a sync together with its copies, or with Keep Copies leaves them in the target. While copies are removed, the bar at the bottom counts them (“Removing the synced events of "work-to-home"… 120 of 340”) next to a progress bar.
An account has a name, the server URL, the user name and the password. A password you type is stored in the Keychain (service app.capycal, the account’s name) and the config only says password = { keychain = "<name>" }. A password from an environment variable, a file or a command stays as it is until you type a new one. When macOS would ask before Capycal reads a Keychain password (after an update to a build with a new signature), the app first says why and which accounts it reads, and starts the syncs once you close that note; choose Always Allow in the macOS prompt so it does not come back. Test Connection looks up the account’s calendars with what you entered. Below the box, the page lists the syncs that use the account; Delete removes it once none does.
If the config file changes while you have unsaved changes to the same sync or account, a yellow Changed on disk banner offers Reload (drop your changes) or Overwrite (keep them; Save writes them). Without unsaved changes the window just shows the new state. Closing the window or choosing another entry with unsaved changes asks first.
What each setting does, with its key in the config: Calendars and accounts, Syncs, Transforms, Full sync.
General
General at the top of the list holds the app’s own settings: Start Capycal at login, the Log level (written as [daemon] log_level; a filter written by hand shows as “Custom”) with Show Log, and the Config file with Open in Editor… and Show in Finder. When something keeps the config from running, General turns orange and starts with Needs attention: the mistake in the config file (where it is and what is wrong) with Open in Editor…, another Capycal process holding the config with Reload Config, or a failed removal of synced events with Show Log. Settings also opens when the config file cannot be read at all, then with only General, and shows everything once the file is fixed. Open in Editor… writes the starter config if the file is missing and opens it in the app for .toml files, or in the default text editor. Saving it reloads the syncs. Settings and your own edits can be mixed: Settings keeps your comments and only replaces the entries it changes. To find calendar URLs and identifiers for hand-written entries, use capycal --discover and capycal --discover-eventkit (see CLI reference).
Removing events
Remove Synced Events
Remove Synced Events… under Advanced settings > Troubleshooting on a sync’s page removes what Capycal created for that sync, and keeps it from creating the events again:
- A dialog opens at once and the app counts the sync’s events, in the background (“Counting synced events” with a spinner in the dialog, also in the menu). Cancel or closing the dialog stops here. On a CalDAV target these are the events carrying the marker of exactly this sync; on a Calendar.app target the events whose notes marker names this sync (for events of older versions, which only name the source calendar: those of the sync’s source).
- The dialog then asks “Remove N events?” and names the sync, the number of events and the target calendar. The source calendars are never touched. If the target cannot be read, it says so and only offers Done. Remove Events is red and not the default button: Return and Esc pick Cancel.
- On Remove Events, the app stops the sync, deletes its events (with the target calendar’s
concurrency), forgets its sync state and disables it (enabled = falsein the config): the menu shows “work-to-home: disabled”. Meanwhile the dialog shows “Removing synced events” with a progress bar (“264 of 1501 events removed”), Show Log and Close; closing it does not stop the removal. - When done, the dialog says how many events were removed, and any that could not be. Failures also show up in the menu and the Problems window.
The sync stays disabled, also after restarting the app, until you switch it on in Settings; it then copies its source again. Renaming or deleting a sync in Settings removes its events by itself. If the target calendar cannot be read, nothing is deleted and the sync keeps running.
This is the same as capycal --purge <sync> (see CLI reference), without stopping the app first.
Remove All Events
Remove All Events… under Advanced settings > Troubleshooting on a sync’s page deletes every event in the sync’s target calendar, also those Capycal did not create. Use it only on a calendar that holds nothing but Capycal’s copies, for example after copies of an old version or a broken config piled up there.
- A dialog opens and counts the events in the target. An empty calendar or one that cannot be read ends here.
- A warning in an orange box says how many events are deleted and lists the syncs writing into the calendar, which are switched off afterwards. Continue… goes on, Cancel stops.
- A second warning asks you to type the calendar’s name as Settings shows it. Remove All Events is enabled only once the name matches.
- The app stops the syncs writing into the calendar, deletes the events with a progress bar, forgets their sync state and switches them off (
enabled = false). Closing the dialog does not stop the removal. If the calendar cannot be read, nothing is deleted and the syncs keep running.
Switch the syncs on again in Settings to copy their sources from scratch. This is the same as capycal --purge-all <calendar> (see CLI reference), except that the CLI does not switch the syncs off.
Login, files and the CLI
Start at Login
Start Capycal at login (Settings > General) registers the app as a login item; it appears in System Settings > General > Login Items. If the app does not run from an app bundle, or macOS refuses, it writes a LaunchAgent to ~/Library/LaunchAgents/app.capycal-ui.plist instead. Turning it off removes the registration.
Sponsoring
30 days after the first successful sync, the app asks whether you’d like to sponsor Capycal, and after that about once a year. It only asks at a quiet moment: no sync running, no problem shown. The dialog has a button per way to sponsor, Sponsor on GitHub (GitHub Sponsors) and Support on Ko-fi (Ko-fi). Choosing one opens its page.
- Sponsor on GitHub, Support on Ko-fi and Already Sponsoring ask again a year later, with a thank-you, in case the sponsorship was a one-time one or has ended.
- Not Now (also Return, Esc or closing the dialog) asks again half a year later the first time, then a year later.
- I’d Rather Not ends the asking for good.
The About dialog and Support Capycal at the end of Settings > General always list the same links; opening one there does not change when the app asks.
A line below the buttons says when the app asks again.
The app keeps this in its user defaults (SponsorFirstSeen, SponsorNext, SponsorSponsoring, SponsorNever in app.capycal). A capycal-ui binary started outside the app bundle, for example with cargo run, keeps them in the domain capycal-ui instead; use that name in the commands below. To see the dialog without waiting, start the app with CAPYCAL_ASK_SPONSOR=1; it then asks at the next quiet moment, whatever the defaults say:
CAPYCAL_ASK_SPONSOR=1 /Applications/Capycal.app/Contents/MacOS/capycal-ui
To see the thank-you a sponsor gets a year later, set SponsorSponsoring first:
defaults write app.capycal SponsorSponsoring -bool true
defaults delete app.capycal resets it all.
Another config file
The app uses the default config path, ~/Library/Application Support/capycal/capycal.toml. To run another config, start it with:
open -a Capycal --args -c /path/to/capycal.toml
Running alongside the CLI
The app takes the same config lock as the CLI. Don’t run the same config in the app and in a capycal daemon (for example the LaunchAgent from contrib/) at the same time: the second one shows or logs the first one’s pid and does not sync. Using the CLI for one-off commands such as --validate or --discover-eventkit while the app runs is fine.
A second copy of the app (for example Capycal.app/Contents/MacOS/capycal-ui started from a terminal while the app runs) says “Capycal is already running” and quits; Finder and open bring the running app forward anyway.
The CLI inside the bundle is at /Applications/Capycal.app/Contents/MacOS/capycal.
Where files live
| What | Path |
|---|---|
| Config | ~/Library/Application Support/capycal/capycal.toml |
| State | ~/Library/Application Support/capycal/state/ (or state_dir) |
| Config locks | <state dir>/locks/ |
| Log files | ~/Library/Logs/capycal/capycal-ui.log* |
| Login item fallback | ~/Library/LaunchAgents/app.capycal-ui.plist |
| Sponsor dialog answers | user defaults app.capycal |
Calendar access is granted to “Capycal” in System Settings > Privacy & Security > Calendars.
Desktop app (Linux)
capycal-gtk runs the syncs of one config in the background, with an icon in the panel’s tray. Its windows are GTK 4 and libadwaita. It works like the macOS app: the same menu, status lines, Problems window, log window, Remove Synced Events and Start at Login. This page lists only what is different on Linux.
Requirements
- GTK 4.14 and libadwaita 1.5 or later (Ubuntu 24.04, Debian 13, Fedora 40 and newer).
- A tray that shows StatusNotifierItem icons. KDE Plasma, Cinnamon, XFCE, MATE, Budgie and the Ubuntu desktop do. GNOME needs the extension AppIndicator and KStatusNotifierItem Support (Fedora:
gnome-shell-extension-appindicator). Without a tray, the app shows its menu as a window after ten seconds, with a note about the missing tray; it keeps syncing, and the icon appears as soon as a tray starts.
Install and start
sudo apt install ./capycal-gtk_<version>-1_amd64.deb # Debian, Ubuntu
sudo dnf install ./capycal-gtk-<version>-1.x86_64.rpm # Fedora
The package adds Capycal to the application menu. Starting it again while it runs opens the menu as a window, with the same status lines and items as the tray menu (without a D-Bus session, the second start says “Capycal is already running” and quits). It uses the default config, ~/.config/capycal/capycal.toml; for another one, start it with capycal-gtk -c /path/to/capycal.toml. On the first start it writes the commented starter config and opens a small “Welcome to Capycal” window: after a short welcome it adds your CalDAV account (server URL, user name, password), then asks for the source and target calendar and what to copy.
Don’t run the app and the systemd user service with the same config: they share a config lock, and the second one does not sync.
Differences from the macOS app
| Linux | |
|---|---|
| Tray icon | A calendar symbol in the panel’s own color, like the other tray icons: with a red dot when something needs attention, a dot while syncing, faded when inactive. A left click opens the menu |
| Settings… | The same Settings window, in GTK. Without Calendar.app, the source and target lists only show the calendars of your accounts. A calendar you may not write to is marked “(read-only)” in the target list and cannot be chosen |
| General > Open in Editor… | Opens the config with the default application for its file type |
| General > Start Capycal at login | Writes ~/.config/autostart/app.capycal.desktop (with the config path), which every desktop following the XDG autostart spec runs at login. Show in Files opens the config’s folder in the file manager |
| Log file | ~/.local/share/capycal/capycal-gtk.log |
| Sponsor dialog answers | ~/.config/capycal/sponsor. CAPYCAL_ASK_SPONSOR=1 asks at the next quiet moment, a line sponsoring=1 shows the thank-you, deleting the file resets it |
A password typed in Settings is stored in your keyring through the Secret Service (GNOME Keyring, KWallet), as { keychain = "<account>" } in the config. Passwords can also come from your keyring with a command credential, for example { command = "secret-tool lookup service capycal account work" }.
In the Settings window, Ctrl+W closes it and Ctrl+Q quits the app (both ask first about unsaved changes); Ctrl+S, Ctrl+N, Ctrl+Delete and Ctrl+Z work as Cmd on macOS.
Quick start: CLI
This walks through a first sync from one CalDAV calendar to another. For Calendar.app calendars, see the desktop app quick start or Calendar.app to Nextcloud.
1. Create a config
capycal --generate-config
This writes a commented starter config to the default location and refuses to overwrite an existing file:
| OS | Config | State |
|---|---|---|
| Linux | ~/.config/capycal/capycal.toml | ~/.local/state/capycal/ |
| macOS | ~/Library/Application Support/capycal/capycal.toml | ~/Library/Application Support/capycal/state/ |
Use -c <path> with any command for a different config file.
2. Find your calendars
capycal --discover https://caldav.example.com
Capycal asks for username and password, looks up the CalDAV root (RFC 6764, /.well-known/caldav) and prints an [accounts.<name>] snippet for the server and one [calendars.<name>] snippet per calendar:
[accounts.work]
url = "https://caldav.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
[calendars.work]
account = "work"
url = "https://caldav.example.com/calendars/alice/work/"
On macOS, capycal --discover-eventkit does the same for Calendar.app.
3. Wire a sync
Copy the source calendar and the calendar that should receive the blockers into the config, then edit the sync:
[daemon]
log_level = "info"
[accounts.work]
url = "https://caldav.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_HOME_PASSWORD" }
[calendars.work]
account = "work"
url = "https://caldav.example.com/calendars/alice/work/"
[calendars.blockers]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"
[syncs.work-to-blockers]
source = "work"
target = "blockers"
interval = "5m"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]
Use a dedicated, empty calendar as the target. Capycal only touches events it created, but a separate calendar is easier to hide, share or clean up.
4. Check and run once
export CAPYCAL_WORK_PASSWORD=... CAPYCAL_HOME_PASSWORD=...
capycal --validate
capycal --once -v
--validate parses the config and checks references, transforms and placeholders without contacting a server. --once runs every sync one time and exits with a non-zero code if any sync failed; -v adds debug output.
5. Run as a daemon
capycal
Without --once, Capycal keeps running and syncs every interval until it receives SIGINT or SIGTERM. For a permanent setup, see Running on a server.
Next: Config file and the Cookbook.
Config file
Capycal reads one TOML file. The desktop app writes it for you; this page is about the file itself. The CLI looks at the platform default path unless you pass -c <path>; the desktop app uses the same default (see Another config file for another file).
| OS | Default config path |
|---|---|
| Linux | ~/.config/capycal/capycal.toml |
| macOS | ~/Library/Application Support/capycal/capycal.toml |
A config has these parts:
- Accounts (
[accounts.<name>]): a CalDAV server and the credentials for it. Every CalDAV calendar names its account. - Calendars (
[calendars.<name>]): where events live. Each is a CalDAV collection or a Calendar.app calendar. The<name>is your own key; syncs refer to it and it appears in markers and the{{source_name}}placeholder. - Syncs (
[syncs.<name>]): which calendar is copied to which, how often, and which iCal properties are kept. - Transforms (the
transformslist of a sync): what happens to each event on the way, in order.
[daemon]
log_level = "info"
[accounts.work]
url = "https://caldav.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
[calendars.work]
account = "work"
url = "https://caldav.example.com/calendars/alice/work/"
[calendars.blockers]
account = "work"
url = "https://caldav.example.com/calendars/alice/blockers/"
[syncs.work-to-blockers]
source = "work"
target = "blockers"
interval = "5m"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]
What each part means, with its place in the desktop app: Calendars and accounts, Syncs, Transforms. Every key: Config options.
Starter config
capycal --generate-config and the desktop app (on its first start) write a starter config. Every setting in it is commented out, so it loads as a valid config without syncs. It explains and shows, ready to uncomment:
- every
[daemon]option with its default, - a CalDAV calendar with the three credential forms and a Calendar.app calendar with the accepted naming forms,
- a complete sync, including what
fieldskeeps and the Calendar.app query window, - one example of every transform action.
Lines starting with ## explain, lines starting with # are settings: remove the # to use one. The CLI refuses to run the starter config as it is, since it has no syncs.
Required parts
- The
[daemon]table is optional; without it every daemon setting has its default. - Every sync needs a name (its table key),
source,targetandinterval. Withoutfieldsit copies busy blockers. - The CLI needs at least one
[syncs.<name>]table to run (see CLI reference). A config without syncs is still valid, so the desktop app can show a config that is not set up yet.
Unknown keys are errors, at every level (top level, [daemon], calendars, credentials, syncs, transforms). The message names the key, its line and the keys allowed there:
error: configuration error: failed to parse /etc/capycal/capycal.toml: TOML parse error at line 16, column 1
|
16 | intervl = "5m"
| ^^^^^^^
unknown field `intervl`, expected one of `source`, `target`, `interval`, `fields`, ...
Validating
capycal --validate # default config
capycal --validate -c my.toml
This checks the TOML, unknown keys, required options, calendar references, durations, regexes, days, times, placeholders and the Calendar.app query window. A config without syncs is reported as valid, but --validate exits with code 1, since the CLI could not run it. It does not resolve credentials or contact a server; use capycal --once -v for that.
Daemon settings
The [daemon] table holds process-wide settings. The table and every option in it are optional.
[daemon]
state_dir = "/var/lib/capycal"
log_level = "info"
log_format = "pretty"
watch_config = false
state_dir
Directory for the per-sync state files (<sync name>.json) and the config locks (locks/). Precedence: state_dir in the config, then the CAPYCAL_STATE_DIR environment variable (if not empty), then the OS default:
| OS | Default state directory |
|---|---|
| Linux | $XDG_STATE_HOME/capycal, usually ~/.local/state/capycal/ |
| macOS | ~/Library/Application Support/capycal/state/ |
Services that run without a home directory need one of the first two. The systemd system unit and the Docker image set CAPYCAL_STATE_DIR=/var/lib/capycal, so their configs need no state_dir. A relative path is resolved against the working directory of the process, not the config file, so use an absolute path.
The state remembers which source event belongs to which target event. It is a cache: if it is lost, the next cycle is a full sync that rebuilds it from the markers Capycal puts on every event it creates, on CalDAV and Calendar.app targets alike, without creating events twice. Only Calendar.app events written by old versions, whose marker names no sync, are deleted and created again. See Troubleshooting.
log_level
Default "info". One of trace, debug, info, warn, error, or a tracing filter directive such as "info,capycal=debug".
Precedence: the RUST_LOG environment variable wins over everything, then -v / -vv on the command line (CLI only), then log_level.
A changed log_level takes effect on a config reload, in the CLI daemon and in the desktop app, and is logged as “log level changed”. If RUST_LOG or -v set the level at start, a reload doesn’t change it.
log_format
"pretty" (default) or "json". Applies to the CLI’s output on stdout. JSON gives one object per line for log collectors. The desktop app always writes plain text to its log file.
watch_config
Default false. When true, the CLI daemon reloads the config by itself when the file changes, the same way as on SIGHUP. See Reloading the config. The option is read at start and ignored for --once. The desktop app always watches its config, regardless of this option.
Reloading the config
A running Capycal applies config changes without a restart:
| How | CLI daemon | Desktop app |
|---|---|---|
SIGHUP (kill -HUP <pid>, systemctl reload capycal, docker compose kill -s HUP capycal) | yes | - |
File change with watch_config = true in [daemon] | yes | always on |
| Reload Config (Problems window, or Settings > General while another process holds the config) | - | yes |
On reload Capycal parses and validates the new file first. If it is invalid, the error is logged (and shown in the app’s menu) and the running syncs continue with the previous config. If it is valid, all syncs are stopped and started again with the new config; each starts with a cycle right away. State files are kept, so nothing is synced twice.
The file watcher notices saves, replacements by rename (how most editors save) and deletion followed by re-creation, about half a second after the last write. The CLI logs “config changed, reloading”.
A changed log_level applies after a successful reload, unless RUST_LOG or -v fixed the level at start. Not affected by a reload: log_format (set at start) and watch_config itself. The config lock follows a reload: the new sync names are recorded, and a changed state_dir takes the lock in the new directory. A reload whose sync names another config already runs in the same state directory, or whose new state directory is locked by another process, is rejected like an invalid config.
A CLI daemon started with --only applies the same selection to the reloaded config; a reload that no longer contains a selected sync is rejected like an invalid config.
Removing a sync from the config stops it but leaves its events in the target and its state file in the state directory. Run capycal --purge <sync> before removing it if the events should go too.
Config lock
One config runs in one process at a time. Two processes with the same config would write the same state files and create duplicates. The daemon, --once, --full-sync, --purge, --purge-all and the desktop app take an exclusive lock for the config file; a second process with the same config stops with an error naming the config and the pid that holds it. The app shows “Another Capycal runs this config (pid N)” instead and keeps checking on reload.
Different configs can run in parallel. If they share a state directory and both have a sync with the same name, both would write <name>.json, so the second one fails to start with an error naming the conflicting config.
The lock files are in <state dir>/locks/, one per config (named after a hash of the config’s absolute path). They record the pid and program of the holder, the config path and its sync names; capycal --status shows the pid, and a second desktop app quits when another one holds the lock. The lock is released when the process exits, even after a crash; the files themselves stay and are harmless.
--validate, --generate-config and the discovery commands don’t take the lock. To purge a config that a daemon or the desktop app runs, stop it first; otherwise the purge fails with the same “already in use” error.
Running on a server
For a permanent setup, run the capycal daemon under a service manager. Ready-made service definitions are in contrib/; they expect the binary at /usr/local/bin/capycal. Install steps are in the comment at the top of each file.
Before enabling a service, check the config with capycal --validate -c <config> and one manual capycal --once -v -c <config>.
systemd
System service
contrib/systemd/capycal.service runs Capycal as a dynamic, unprivileged user with a hardened sandbox:
- Config:
/etc/capycal/capycal.toml. - Passwords:
VAR=valuelines in/etc/capycal/capycal.env(mode0600), referenced aspassword = { env = "VAR" }. - State:
/var/lib/capycal, created by systemd (StateDirectory=) and passed to Capycal asCAPYCAL_STATE_DIR, since the dynamic user has no home directory. Don’t set a differentstate_dirin the config.
sudo install -m 0755 capycal /usr/local/bin/capycal
sudo install -D -m 0644 capycal.toml /etc/capycal/capycal.toml
sudo install -D -m 0600 capycal.env /etc/capycal/capycal.env
sudo cp contrib/systemd/capycal.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now capycal
User service
The quickest way to run Capycal as your user is:
capycal --install-service # or: capycal -c /path/to/capycal.toml --install-service
It writes ~/.config/systemd/user/capycal.service with the absolute paths of the Capycal binary and the config, reloads systemd, enables and starts the service, and tells you if lingering is off (without it the service stops when you log out; loginctl enable-linger $USER changes that). Run it again after moving the binary or the config. It refuses while another Capycal process runs the config, and it never overwrites a capycal.service it didn’t write. capycal --uninstall-service stops and removes it again. Install the binary to its final place first (for example /usr/local/bin), since the unit points to the binary you run it with.
Passwords go into ~/.config/capycal/capycal.env, files referenced with { file = "..." } or a password manager command.
To set it up by hand instead, contrib/systemd/capycal-user.service is the same unit with the default config and state paths:
mkdir -p ~/.config/systemd/user
cp contrib/systemd/capycal-user.service ~/.config/systemd/user/capycal.service
systemctl --user daemon-reload && systemctl --user enable --now capycal
loginctl enable-linger $USER # keep it running while you are logged out
Logs and reload
journalctl -u capycal -f # system service
journalctl --user -u capycal -f # user service
sudo systemctl reload capycal # re-read the config (SIGHUP)
Docker
The repository’s Dockerfile builds the CLI from source into a Debian slim image that runs as an unprivileged user. docker-compose.yml mounts the config read-only and keeps the state in a volume:
git clone https://github.com/twiebe/capycal && cd capycal
cp capycal.example.toml capycal.toml
cp .env.example .env
docker compose up -d
- The image sets
CAPYCAL_STATE_DIR=/var/lib/capycal, where the compose file mounts the state volume, so the state survives a recreated container. Leavestate_dirunset incapycal.toml, or set it to a path inside that volume. - Put the passwords into
.envand reference them aspassword = { env = "..." }.RUST_LOGin.envoverrideslog_level, also on reload. - Time conditions of transforms use the container’s timezone, UTC by default. Add
- /etc/localtime:/etc/localtime:roto the volumes to use the host’s.
docker compose logs -f capycal # logs
docker compose kill -s HUP capycal # reload the config
docker compose exec capycal capycal --validate --config /etc/capycal/capycal.toml
The Makefile has shortcuts: make up, make down, make logs, make reload.
launchd (headless macOS)
On a Mac that should sync without the desktop app, run the CLI as a LaunchAgent with contrib/launchd/app.capycal.plist. It runs in your login session, which Calendar.app (EventKit) access requires; a system LaunchDaemon can’t read calendars.
-
Replace
USERNAMEin the plist with your user name. -
Install and start it:
cp contrib/launchd/app.capycal.plist ~/Library/LaunchAgents/ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.capycal.plist -
Under launchd the calendar permission belongs to the
capycalbinary itself. Runlaunchctl kickstart gui/$(id -u)/app.capycalonce while logged in and confirm the prompt.
tail -f ~/Library/Logs/capycal.log # logs
launchctl kill HUP gui/$(id -u)/app.capycal # reload the config
launchctl bootout gui/$(id -u)/app.capycal # stop and remove
Use passwords from files ({ file = "..." }) rather than environment variables, so they don’t end up in the plist.
Don’t run the LaunchAgent and the desktop app with the same config; they share a config lock and the second one does not sync.
Logs
The CLI logs to stdout. With log_format = "json" in [daemon], each line is a JSON object with timestamp, level, message and fields (sync, error, …), ready for Loki, Elasticsearch or jq:
journalctl -u capycal -o cat | jq 'select(.level == "ERROR")'
Useful messages at info level: capycal started, sync started, sync cycle done, deleted synced event, config reloaded successfully, sync cycle failed (error). A block of work starts with <name> started and ends with <name> done (with took, the duration), <name> done with errors (warning), <name> cancelled or <name> failed, for example full sync started and full sync done. Use -v or log_level = "debug" for per-event details.
Reload
A running daemon re-reads its config on SIGHUP (all service managers above forward their reload command as SIGHUP), or by itself with watch_config = true. An invalid new config is rejected and the syncs continue unchanged. See Reloading the config.
CLI reference
capycal [OPTIONS]
| Option | Description |
|---|---|
-c, --config <PATH> | Config file. Default: ~/.config/capycal/capycal.toml (Linux), ~/Library/Application Support/capycal/capycal.toml (macOS) |
-v, --verbose | More log output: -v debug, -vv trace. Overrides log_level; RUST_LOG overrides both |
--once | Run every enabled sync once and exit |
--only <NAMES> | Run only these syncs (comma-separated), with the daemon, --once or --full-sync |
--full-sync | Start every sync with a full sync, then continue as daemon (or exit with --once) |
--validate | Check the config and exit. Exits with 1 if it has no syncs |
--status | Show whether a Capycal process runs the config and what each sync’s state says, then exit |
--install-service | Install, enable and start Capycal with this config as a systemd user service (Linux), see User service |
--uninstall-service | Stop and remove that service (Linux) |
--generate-config | Write a starter config to the config path and exit. Refuses to overwrite |
--discover <URL> | List the calendars of a CalDAV account and print config snippets |
--discover-eventkit | List Calendar.app calendars and print config snippets (macOS) |
--purge <SYNC> | Delete the events a sync created from its target and forget its state |
--purge-all <CALENDAR> | Delete all events from a calendar and forget the state of the syncs writing to it |
--yes | With --purge-all: don’t ask for confirmation |
--completions <SHELL> | Print shell completions (bash, zsh, fish, elvish, powershell) |
--man | Print the man page |
-V, --version | Print the version |
-h, --help | Show help |
Without --once, --validate, --generate-config, discovery or purge options, Capycal runs as a daemon: it syncs every interval until SIGINT (Ctrl-C) or SIGTERM, and reloads the config on SIGHUP.
Exit codes
| Code | Meaning |
|---|---|
0 | Success. For --once: every sync completed |
1 | Any error: invalid config, a config without syncs (also for --validate), lock held by another process, a failed sync in --once, a purge or discovery that could not list the calendar. Single failed deletes during a purge are logged and counted but do not change the exit code |
Errors are printed to stderr or logged; with log_format = "json", errors after config loading are JSON log lines.
Running
capycal # daemon with the default config
capycal -c /etc/capycal/capycal.toml
capycal --once -v # one cycle with debug output, e.g. from cron
capycal --once --only work-to-home
--once and the daemon take the config lock; a second process with the same config exits with code 1.
A daemon started with --only keeps the selection on every reload (SIGHUP or watch_config). If a selected sync is no longer in the reloaded config, the reload fails with sync '<name>' selected with --only is no longer in the config and the daemon continues with the previous config.
The daemon, --once and --full-sync need at least one [syncs.<name>] table. Without one they exit with code 1 and no syncs defined in <path> - add a [syncs.<name>] table. A reload to a config without syncs is rejected and the daemon keeps the previous config.
Validate and generate
capycal --generate-config # writes the starter config to the default path
capycal --generate-config -c ./test.toml # or elsewhere
capycal --validate # prints "configuration is valid" with the state directory
The starter config is fully commented out: uncomment a calendar pair and a sync before running it.
--validate exits with code 0 only for a config the CLI can run. A config without syncs is valid (the desktop app accepts it), so it prints “configuration is valid” and then the “no syncs defined” error, and exits with code 1. This keeps capycal --validate && systemctl restart capycal from starting a daemon that would exit at once.
When no -c is given and the default config is missing or broken, the error includes the path that was looked at and a hint to run --generate-config.
Status
capycal --status
config: /home/alice/.config/capycal/capycal.toml
state: /home/alice/.local/state/capycal
daemon: running (pid 4242)
SYNC FROM -> TO EVENTS LAST CHANGE LAST FULL SYNC
work-to-personal work -> personal_blockers 128 2026-10-14 09:12 2026-10-01 08:00
personal-to-work personal -> work_blockers - - - no state yet, starts with a full sync
--status reads the state files and the config lock without changing anything or contacting a server, so it works while a daemon or the desktop app runs. Per sync it shows the number of synced events, when the state last changed (a cycle saves only when something changed), when the last full sync completed and notes such as an interrupted full sync or deletes waiting for a retry. Errors of the last cycles are in the log (journalctl --user -u capycal for the user service).
Discover
capycal --discover https://cloud.example.org
capycal --discover-eventkit
--discover asks for username and password on the terminal, finds the account’s calendars (via /.well-known/caldav and the principal’s calendar home) and prints each calendar’s name and URL to stderr, and to stdout an [accounts.<name>] snippet for the server (named after its host, with password = { env = "CAPYCAL_<NAME>_PASSWORD" }) plus a [calendars.<name>] snippet per calendar that refers to it. Task lists and other collections without events are left out. Redirect stdout to collect them: capycal --discover https://... >> snippets.toml.
--discover-eventkit lists every Calendar.app calendar with its identifier, grouped by account, and prints snippets in the same groups, named <account>_<calendar> and using the identifier, with the Account/Name form as a comment.
Neither needs a config file.
Full sync
capycal --full-sync --once
capycal --full-sync --once --only work-to-home
Compares the whole source of each sync with its target: creates missing events, updates differing ones and removes Capycal’s own duplicates, events whose source event is gone and events the current fields or transforms exclude. Nothing else in the target is touched, and the source never is. Use it after changing fields or transforms. Without --once the daemon starts with a full sync and then continues as usual.
The log line full sync done reports took (the duration), created, updated, unchanged, deleted and duplicates and legacy_markers (both included in deleted) per sync; with failed events it is a warning full sync done with errors that adds errors. Full syncs also run on their own, see Full sync.
Purge
capycal --purge work-to-home
capycal --purge-all blockers
--purge <SYNC> deletes the events this sync created from its target calendar, then removes the sync’s state. Other events in the calendar are not touched:
- CalDAV target: Capycal fetches every event in the calendar and deletes those carrying the marker of exactly this sync (
X-CAPYCAL-SYNC:<sync>). - Calendar.app target (macOS): Capycal deletes the events whose notes marker names this sync. Events created by older Capycal versions carry only the source calendar (
[capycal:<source>]); for these the source decides, so if two syncs read the same source and write to the same Calendar.app calendar, such old events of both are deleted. On other platforms the command fails with “EventKit calendars are only supported on macOS”.
The source calendar is never touched. The sync must still be in the config, so purge before removing or renaming a sync. A daemon running the same config holds its lock, so stop it first; afterwards remove the sync from the config, or the next start creates the events again.
--purge-all <CALENDAR> deletes every event in the calendar, including ones Capycal didn’t create, then removes the state of all syncs that write to it. It works for CalDAV and Calendar.app calendars. Only use it on calendars dedicated to Capycal. It asks you to type the calendar name first; in a script or without a terminal, add --yes instead (capycal --purge-all blockers --yes), otherwise it refuses.
Both use the calendar’s concurrency for parallel deletes and report the number of deleted events and errors. Both take the config lock, so they exit with code 1 while a daemon or the desktop app runs the same config. In the desktop app, use Remove Synced Events… and Remove All Events… on the sync’s page in Settings instead.
Calendars
Each [calendars.<name>] table defines one calendar. The type follows from the keys: a table with eventkit_calendar is a Calendar.app calendar, one with ics_url a calendar feed, any other table is a CalDAV calendar. A calendar can be the source of some syncs and the target of others; a feed can only be a source.
The <name> is yours to choose. It is used in source / target of syncs, in the {{source_name}} placeholder and in the markers on created events.
In the app: you don’t write calendar entries yourself. Picking a calendar in a sync’s From or Into adds its
[calendars.<name>]entry when you save, and a calendar no sync uses any more is removed after asking.
Accounts
In the app: Add Account… in Settings asks for a name, the server URL, the user name and the password; Test Connection looks up the calendars with them.
A CalDAV calendar belongs to an [accounts.<name>] table, which holds the server and the credentials. Several calendars on one server share one account, so the password is set once.
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_NEXTCLOUD_PASSWORD" }
[calendars.personal]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/personal/"
[calendars.blockers]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"
| Key | Required | Description |
|---|---|---|
url | yes | Server address, the one you would give a calendar app; capycal --discover starts from it |
username | yes | User name for HTTP Basic authentication |
password | yes | A credential |
CalDAV
In the app: the calendar menus of a sync list the calendars every account finds on the server.
concurrencyis Parallel writes in the Advanced settings of a sync that writes into the calendar.
[accounts.work]
url = "https://caldav.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
[calendars.work]
account = "work"
url = "https://caldav.example.com/calendars/alice/work/"
concurrency = 4
| Key | Required | Description |
|---|---|---|
account | yes | Name of the account whose credentials the calendar uses |
url | yes | URL of the calendar collection (not the server root) |
concurrency | no, default 2 | Maximum events written (PUT) to this calendar at the same time as a target, and parallel deletes of a purge |
capycal --discover <server> lists the collection URLs of an account. Some examples of collection URLs:
| Server | Collection URL |
|---|---|
| Nextcloud | https://cloud.example.org/remote.php/dav/calendars/<user>/<calendar>/ |
| Radicale | https://radicale.example.org/<user>/<calendar-id>/ |
| Fastmail | https://caldav.fastmail.com/dav/calendars/user/<email>/<calendar-id>/ |
| iCloud | found with --discover https://caldav.icloud.com and an app-specific password |
Only HTTP Basic authentication is supported. Providers that require OAuth (Google, Microsoft 365) can’t be used over CalDAV directly. To copy from them, use their calendar feed, or on macOS add the account to Calendar.app and use it through EventKit.
concurrency is shared by every sync that writes to the calendar and also sets the parallel deletes of --purge / --purge-all. Raise it for large initial syncs to a server that handles parallel requests well, set it to 1 for one that answers parallel writes with errors. The desktop app sets it as Parallel writes in a sync’s Advanced settings when the target is a CalDAV calendar. Reads are not limited by it, and the deletes of a sync cycle run one at a time per sync. Capycal retries on HTTP 408, 429 and 5xx with exponential backoff and honours Retry-After.
Calendar.app (EventKit, macOS only)
In the app: the calendar menus list Calendar.app’s calendars under their account; Settings writes the identifier.
[calendars.work_mac]
eventkit_calendar = "Exchange/Calendar"
eventkit_calendar accepts three forms, tried in this order:
- The calendar identifier, for example
"A1B2C3D4-...". Stable across renames. "Account/Name", for example"iCloud/Family"or"Exchange/Calendar". Use this when several accounts have a calendar with the same name.- The calendar name alone, for example
"Work". The first calendar with that name wins.
capycal --discover-eventkit prints every calendar with its account and identifier, and a snippet with both the identifier and the Account/Name form:
[calendars.work]
eventkit_calendar = "A1B2C3D4-0000-0000-0000-000000000000"
# eventkit_calendar = "Exchange/Work"
A config with an eventkit_calendar fails validation on Linux. How long the source is queried is set per sync with the EventKit window options. See macOS and Calendar.app for what is read and written.
Calendar feeds (iCal address)
In the app: Add Calendar Feed… at the end of a sync’s From menu, or below the calendars of the assistant’s first step, asks for the address. Feeds you added are listed under Feeds; as a target they are greyed out.
Many calendar services publish a calendar as one iCalendar file at a private address. Capycal reads such a feed as a source: no account, no password, and it works with Google Calendar and Outlook.com, which don’t offer CalDAV with a password.
[calendars.google]
ics_url = "https://calendar.google.com/calendar/ical/alice%40gmail.com/private-0123456789abcdef/basic.ics"
| Key | Required | Description |
|---|---|---|
ics_url | yes | The feed’s address, https://, http:// or webcal://. A credential like a password: a plain string or { env = ... }, { file = ... }, { command = ... }, { keychain = ... } |
Where to find the address:
| Service | Address |
|---|---|
| Google Calendar | Settings, Settings for my calendars, the calendar, Integrate calendar: Secret address in iCal format |
| Outlook.com, Microsoft 365 | Settings, Calendar, Shared calendars, Publish a calendar: the calendar with Can view all details, then the ICS link |
| Others | Look for “publish”, “share link”, “subscribe” or “iCal”. Capycal reads any address that returns an .ics file |
A feed is read-only: a sync with a feed as target fails validation, and --purge-all refuses it. Every check downloads the whole calendar, unless the server answers that it did not change (ETag / Last-Modified). Capycal finds the changed events by their content, so a feed that sets every event’s DTSTAMP to the time of the request doesn’t rewrite all copies. Some feeds (waste collection calendars, for example) are generated on each request and give every event a new UID: Capycal recognizes the events by their content and keeps their copies. For such a feed, an event whose time or title changes gets a new copy in place of the old one, and a full sync replaces all its copies once.
The service decides how fresh its feed is. Google updates the secret address only every few hours, so a change in Google Calendar can take that long to reach the target, whatever interval says. An interval of "30m" or "1h" is plenty.
Treat the address like a password: anyone who has it can read the calendar. It never appears in Capycal’s logs, and {{source_url}} gives ics://feed for a feed. To keep it out of the config file, use another credential form, for example ics_url = { keychain = "google-feed" }; the desktop app then shows the address as set in the config file. A feed behind HTTP Basic authentication takes the user name and password in the address: https://user:password@example.com/calendar.ics. If the address leaks, reset it with the service (Google: Reset next to the secret address) and enter the new one.
Passwords
In the app: a password typed into an account is stored in the Keychain (macOS) or the Secret Service (Linux) and written as
password = { keychain = "<account>" }. A password fromenv,fileorcommandstays as it is until you type a new one.
The password of a CalDAV account ([accounts.<name>]) can come from five places:
password = { env = "CAPYCAL_WORK_PASSWORD" } # environment variable
password = { file = "secrets/work.txt" } # file content
password = { command = "pass show capycal/work" } # output of a command
password = { keychain = "work" } # macOS Keychain or Secret Service
password = "literal" # plain text in the config
env: the value of the environment variable. Works well with systemdEnvironmentFile=, Dockerenv_fileand.envfiles.file: the content of the file, with leading and trailing whitespace (including the final newline) removed. A relative path is resolved against the directory of the config file. Works well with Docker or systemd secrets (/run/secrets/...).command: the output of a shell command (sh -c, run in the config file’s directory), with leading and trailing whitespace removed. Use it for password managers and keyrings:secret-tool lookup service capycal account work(GNOME Keyring, KWallet and other Secret Service keyrings),pass show capycal/work | head -n1,op read op://Private/work/password(1Password) orsecurity find-generic-password -s capycal-work -w(macOS Keychain). If the command fails or prints nothing, the sync does not start; its error output goes to Capycal’s standard error (the journal under systemd), its regular output (the password) is never shown. A keyring that needs an unlocked desktop session doesn’t work in a system service.keychain: an entry in the macOS Keychain or, on Linux, the Secret Service (GNOME Keyring, KWallet). The entry has the serviceapp.capycaland the name given here as its account (usernameattribute). To set one by hand:security add-generic-password -s app.capycal -a work -won macOS,secret-tool store --label "capycal work" service app.capycal username workon Linux. A server or a system service without a desktop session has no Secret Service; useenvorfilethere.- Plain string: kept in the config file. Not recommended; if you use it, make the config readable only by you (
chmod 600).
Credentials are resolved when the syncs start and on every config reload, so a missing variable or file shows up on capycal --once, not on --validate. Capycal never logs passwords.
Use an app-specific password where the provider offers one (iCloud, Fastmail, Nextcloud).
Syncs
Each [syncs.<name>] table copies events from one calendar to another. The key after syncs. is the name of the sync.
In the app: Add Sync… asks for the source, the target and what to copy, and writes the table. The sync’s page in Settings shows each key below as a step or under Advanced settings.
[syncs.work-to-blockers]
source = "work"
target = "blockers"
interval = "5m"
| Key | Required | Description |
|---|---|---|
| name (table key) | yes | Unique name. Used for the state file, markers, --only, --purge and the {{sync_name}} placeholder |
source | yes | Calendar key to read from |
target | yes | Calendar key to write to, different from source |
interval | yes | Time between cycles, e.g. "30s", "5m", "1h" |
fields | no | Content properties to copy on top of time, recurrence and availability (allow-list), default none |
transforms | no | List of transforms, applied in order |
full_sync_interval | no | Time between automatic full syncs, default "7d" |
eventkit_days_past | no | See Calendar.app query window |
eventkit_days_future | no | See below |
eventkit_all_events | no | See below |
Name
In the app: click the name at the top of the sync’s page to rename it. Settings removes the copies in the target first, after asking, and copies the source again.
The name is the key of the sync’s table: [syncs.work-to-blockers] names the sync work-to-blockers. It must not be empty, contain / or \, or start with a dot. Names with spaces, dots or other characters outside letters, digits, _ and - need quotes: [syncs."work to blockers"]. TOML rejects two syncs with the same name. Syncs keep their order from the file. The name is part of every target event’s UID and marker, so renaming a sync makes Capycal create its events again while the old ones stay. Run capycal --purge <old name> (or Remove Synced Events in the desktop app) before renaming.
interval
In the app: Check every in the From step: 1, 5 or 15 minutes, or Custom.
A humantime duration such as "90s", "5m", "1h 30m". A cycle is cheap when nothing changed: CalDAV sources use RFC 6578 sync tokens or a ctag check, Calendar.app sources compare a local query. "5m" is a good default; the desktop app’s Sync Now and a reload start a cycle immediately. After a cycle that failed or could not write every event, Capycal tries again sooner: after 1, 2, 5 and then every 15 minutes, never later than interval, until a cycle succeeds.
fields
In the app: the Keep step: Only the time (no
fields), Title, Title, notes, location, reminders, or Custom with a checkbox per field.
Every copied event keeps what defines when it takes place and how it blocks time: DTSTART, DTEND, DURATION, RRULE, RDATE, EXDATE, STATUS and TRANSP, plus UID (replaced by Capycal), DTSTAMP, RECURRENCE-ID and the VTIMEZONE definitions the kept properties refer to. Dropping any of them would put the copy at the wrong time or turn a series into a single event, so they are not configurable.
fields is an allow-list of the content properties to copy on top (uppercase, as in the iCal data). Everything else is dropped after the transforms have run, except properties a set_field transform sets. Without fields, a sync creates busy blockers: the times, recurrence, status and free/busy flag of the original and nothing else. "VALARM" keeps the event’s alarm components as a whole. Listing a structural property such as "DTSTART" is allowed and changes nothing.
Typical choices:
| Goal | fields |
|---|---|
| Busy blocker | leave it out |
| Keep the source’s reminders | ["VALARM"] |
| Copy the visible details | ["SUMMARY", "LOCATION", "DESCRIPTION"] |
| Keep categories and privacy class | add "CATEGORIES", "CLASS" |
Notes:
TRANSPkeeps events marked “free” as free in the target. To show every blocker as busy, remove it with aremove_fieldtransform.- Recurrence: a recurring CalDAV event is copied as one series. Modified single occurrences are stored as extra
VEVENTs with theirRECURRENCE-ID. Calendar.app sources deliver every occurrence as its own event. - Only events are copied. Tasks and journal entries (
VTODO,VJOURNAL), which some servers such as Nextcloud keep in the same calendar, are skipped. - Cancelled events (
STATUS:CANCELLEDon the event or the whole series) are not copied, and a copy Capycal made earlier is removed. A cancelled single occurrence of a series is copied as cancelled, so the series stays intact. - Privacy: you don’t need
SUMMARYinfieldsto set a title. Aset_fieldtransform inserts it. LeavingSUMMARY,DESCRIPTION,LOCATION,ATTENDEE,ORGANIZERandURLout is the safe default for blockers. - Calendar.app sources only provide
DTSTART,DTEND,SUMMARY,LOCATION,DESCRIPTION,TRANSPandVALARM. See macOS and Calendar.app for what Calendar.app targets write.
How an event is processed
For each new or changed source event, every cycle:
- Events that carry a Capycal marker are skipped, so blockers are never copied back. Cancelled events are skipped too, and an earlier copy is removed. Tasks and journal entries in the same calendar are never copied.
- The transforms run in order on the whole source event, so they can look at properties the sync does not copy. A
skipcan leave the event out here. - Time, recurrence, status and availability (
DTSTART,DTEND,DURATION,RRULE,RDATE,EXDATE,STATUS,TRANSP),UID,DTSTAMP,RECURRENCE-ID, the properties infieldsand those aset_fieldset in this event are kept. Timezone definitions referenced by the kept properties are carried over. Alarms ofadd_alarmare added after this step. - Capycal adds its markers, replaces the UID with one derived from the sync name and the source UID, and writes the event to the target.
When a source event is deleted (or, for Calendar.app sources, leaves the query window), its copy in the target is deleted too.
Changing fields or transforms only affects events that change afterwards, until the next automatic full sync. To apply the new rules to all events now, run a full sync.
Calendar.app query window
In the app: Calendar.app events under Advanced settings: the days back and ahead, or All events.
A Calendar.app source has no change log, so Capycal queries a time window every cycle:
| Key | Default | Description |
|---|---|---|
eventkit_days_past | 30 | Days before now to include |
eventkit_days_future | 365 | Days after now to include |
eventkit_all_events | false | Query 20 years back and 4 years ahead in chunks, ignoring the two options above |
The options are set on the sync and only apply when its source is a Calendar.app calendar. They are accepted on Linux too, so a config stays portable.
Events that leave the window (older than eventkit_days_past) count as removed and their blockers are deleted from the target.
eventkit_days_past + eventkit_days_future must be at most 1460 days: Calendar.app returns nothing for a single query over about four years, so a larger window is rejected as a config error. Use smaller values, or eventkit_all_events = true, which splits its range into several queries and is not limited by this.
Disabling a sync
In the app: the switch next to the sync’s name.
enabled = false keeps a sync in the config but stops it:
[syncs.work-to-home]
source = "work"
target = "home"
interval = "5m"
enabled = false
A disabled sync is still validated, so its calendars and transforms must be correct. The daemon starts no runner for it, --once skips it and --status shows it as disabled. --purge and “Remove Synced Events” in the app still remove its events. The first cycle after enabling it again is a full sync, so whatever changed in the meantime is picked up.
Two syncs between the same calendars
Two syncs in opposite directions (A to B and B to A) are safe: events created by Capycal carry a marker and are never synced back. See Two-way blockers.
Transforms
Transforms change each event before it is written to the target, or leave it out. They are the transforms list of a sync and run in the order they appear, on the whole source event; fields decides afterwards which properties are copied. Every transform can have conditions, so it only changes the events they match:
In the app: each transform is a step between Keep and Into. The + between two steps adds one there, its menu also offers common steps that come filled in. Each action below names its step.
[syncs.work-to-home]
source = "work"
target = "home"
interval = "5m"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Busy" },
{ action = "skip", when = [{ all_day = true }] },
]
Each transform is one inline table. The list may span several lines and contain comments (TOML 1.1). The option reference also shows the [[syncs.<name>.transforms]] table form.
| Action | Effect | Parameters |
|---|---|---|
set_field | Set a property to a fixed value | field, value |
replace_field | Replace text in a property | field, search, replacement, count |
replace_field_regex | Find and replace in a property with a regex | field, pattern, replacement |
remove_field | Remove a property | field |
add_buffer | Start earlier and/or end later | pre_event, post_event |
add_alarm | Add a reminder | trigger |
skip | Leave out the events its conditions match | when (required) |
Every action takes when, a list of conditions.
The examples on this page show only the transforms list; it goes into the table of a sync.
set_field
In the app: Set field.
Sets field to value. An existing property line is replaced as a whole (including parameters such as LANGUAGE=); if the event has no such property, it is added. The property doesn’t need to be in fields.
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]
value is written as is. For text properties such as SUMMARY, escape , ; and \ as \, \; \\ (see Text escaping).
replace_field
In the app: Replace text.
Replaces search with replacement in the value of field: every occurrence, or only the first count ones. Both are plain text, without regex; replacement may contain placeholders. Like replace_field_regex, it needs the property in fields, and it sees the escaped value (see Text escaping).
transforms = [
# "Zoom: Weekly" -> "Call: Weekly"
{ field = "SUMMARY", action = "replace_field", search = "Zoom", replacement = "Call" },
# Only the first "-": "2024-01-15 - Review" -> "2024 01-15 - Review"
{ field = "SUMMARY", action = "replace_field", search = "-", replacement = " ", count = 1 },
]
replace_field_regex
In the app: Replace regex.
Replaces every match of pattern in the value of field with replacement. Only the value is searched, not the property name or parameters. Events without the property are left alone.
The property must be copied, so it has to be in fields (or be always kept, or be added by an earlier set_field); otherwise the config is rejected, since the transform would do nothing. Keep in mind that events the pattern does not match keep the original value. To hide a property, use set_field instead, which replaces it in every event.
transforms = [
# "Call with ACME" -> "Call", "Lunch with Bob" -> "Lunch"
{ field = "SUMMARY", action = "replace_field_regex", pattern = ' with .*$', replacement = "" },
]
pattern uses Rust regex syntax; (?i) makes it case-insensitive. replacement can refer to groups as $1 or ${name}; write $$ for a literal $. Use TOML literal strings (single quotes) for patterns, so backslashes need no doubling.
transforms = [
# Keep only a ticket number: "Review [OPS-123] rollout" -> "OPS-123"
{ field = "SUMMARY", action = "replace_field_regex", pattern = '^.*\[(?P<ticket>[A-Z]+-\d+)\].*$', replacement = '${ticket}' },
]
remove_field
In the app: Remove field.
Removes every line of property field.
transforms = [
{ field = "LOCATION", action = "remove_field" },
]
Usually it is simpler to leave a property out of fields. remove_field is useful after a broad fields list, to drop something a previous transform added, or to drop an always-kept property such as TRANSP (every blocker then shows as busy).
add_buffer
In the app: Add buffer.
Moves DTSTART earlier by pre_event and DTEND later by post_event. At least one of them is required. All-day events are not changed.
transforms = [
{ action = "add_buffer", pre_event = "30m", post_event = "15m" },
]
For events stored with DURATION instead of DTEND, the duration grows by pre_event plus post_event. In a recurring series, the dates that name single occurrences (EXDATE, RDATE, RECURRENCE-ID) move with DTSTART, so excluded and changed occurrences still match. Buffers are real time: a 30 minute buffer before an event at 03:00 on the night clocks spring forward starts at 01:30, not at the skipped 02:30, as long as Capycal knows the event’s timezone: from its definition in the event (VTIMEZONE, which CalDAV servers include) or, for IANA names such as Europe/Berlin (Calendar.app sources), from the built-in timezone database.
add_alarm
In the app: Add alarm.
Adds a display alarm (“Reminder”) trigger before the start. Repeat the transform for several alarms.
transforms = [
{ action = "add_alarm", trigger = "15m" },
{ action = "add_alarm", trigger = "1m" },
]
Alarms from the source are only copied with "VALARM" in fields; add_alarm adds to those. See Travel buffers and reminders.
skip
In the app: Skip events. It starts with one condition and keeps at least one.
Leaves out the events that match all its conditions. Later transforms don’t run for them, and a copy synced earlier is deleted from the target. skip without when is a config error, since it would skip every event.
transforms = [
# Leave out weekend events
{ action = "skip", when = [{ days = ["sat", "sun"] }] },
# Leave out all-day events
{ action = "skip", when = [{ all_day = true }] },
# Leave out events with "private" in the title, in any case
{ action = "skip", when = [{ field = "SUMMARY", matches = "(?i)private" }] },
# Leave out events without a location
{ action = "skip", when = [{ field = "LOCATION", empty = true }] },
]
Since transforms see the whole event, a condition can look at a property the sync does not copy: a busy blocker without fields can still leave out events by their title.
Recipes: Weekends, nights and business hours.
Conditions
In the app: open a step and press Add Condition. The first condition reads Only if (If for Skip events), the others if, after a switch between and and or that joins them to the one above. Each has a popup for what it looks at (Title, Location, Notes, Other field, Start, Event), one for the test, and its text, days or times; drag it by its handle (≡) to another place, or use the arrows (Move Up, Move Down) to swap it with its neighbour; Remove takes it away.
when is a list of conditions. A transform with conditions only changes an event when all of them match; without when it changes every event.
transforms = [
# 45 minutes of travel time, only for events at a place that is not a video call
{ action = "add_buffer", pre_event = "45m", post_event = "30m", when = [
{ field = "LOCATION", empty = false },
{ field = "LOCATION", contains = "zoom.us", not = true },
] },
]
One of several
In the app: switch the and between two conditions to or. Conditions joined by or share a box, One of these; switching back to and splits it. To join conditions that are not next to each other, drag one into the box: dropped inside it, at its top, between two of its conditions or at its bottom, it joins them with or. Dropped outside a box, right above or below one included, it comes with and.
{ any = [...] } in when is a group of conditions of which one is enough. It stands for one condition among the others, which still all have to match:
transforms = [
# An earlier alarm for events at the airport or at the station
{ action = "add_alarm", trigger = "2h", when = [{ any = [
{ field = "LOCATION", contains = "Airport" },
{ field = "LOCATION", contains = "Station" },
] }] },
# Leave out weekday events that are all-day or marked as out of office
{ action = "skip", when = [
{ any = [{ all_day = true }, { field = "SUMMARY", contains = "OOO" }] },
{ days = ["mon", "tue", "wed", "thu", "fri"] },
] },
]
anyholds at least two conditions, takes no other keys and cannot hold anotherany.- For “this and that, or something else”, use two transforms: two
skips leave out what either matches. Steps that would add twice, such asadd_alarmoradd_buffer, needanyinstead.
Tests
Each condition has exactly one test:
| Test | Keys | Matches when | In the app |
|---|---|---|---|
| contains | field, contains | the value contains the text, case matters | contains, does not contain |
| matches | field, matches | the value has a match of the regex | matches regex, does not match regex |
| empty | field, empty = true | the property is missing or empty; empty = false: it has a value | is empty, is not empty |
| time | days, optional start_time, end_time | the event starts on one of the days, within the times | Start with is on, is not on, the days, From and Until |
| all-day | all_day = true | the event is all-day (DTSTART with VALUE=DATE); all_day = false: it is not | Event with is all-day, is not all-day |
not = trueturns contains, matches and time around.emptyandall_daysay it with their value, sonotis an error there.fieldis an iCal property name in upper case such as"SUMMARY"(Title),"LOCATION"(Location) or"DESCRIPTION"(Notes); any other is Other field. Only contains, matches and empty take afield.- A property that appears several times, such as
ATTENDEEorCATEGORIES, matches when any of its lines does; “does not contain” means none of them does, and “is empty” that all are empty. - A missing property counts as an empty value:
containsdoesn’t match it,matches = "^$"does. - The value is unescaped first (see Text escaping), so
Lunch, Room 5matches as written. matchesuses Rust regex syntax and looks for a match anywhere in the value; use^and$for the whole value and(?i)to ignore case.- Conditions look at the series itself (the
VEVENTwithoutRECURRENCE-ID). A transform whose conditions match changes, or skips, the whole resource, so a single occurrence moved into or out of a time range follows its series. The same holds for its text: a single occurrence renamed to “Doctor” is not checked against a condition onSUMMARY, only the series title is.
Time conditions
days are "mon", "tue", "wed", "thu", "fri", "sat", "sun" (lowercase). start_time and end_time are HH:MM and both inclusive at minute precision; they default to "00:00" and "23:59", so without times the whole day matches.
transforms = [
# Leave out events starting after 18:00 on weekdays
{ action = "skip", when = [{ days = ["mon", "tue", "wed", "thu", "fri"], start_time = "18:00" }] },
# Leave out events outside working hours: before 08:00 or from 18:00 on weekdays
{ action = "skip", when = [{ days = ["mon", "tue", "wed", "thu", "fri"], start_time = "18:00", end_time = "07:59" }] },
]
- Times are in the timezone of the machine running Capycal. The event’s start is converted from UTC or its
TZIDfirst; floating times are taken as local. - Only the start counts. An event from 17:00 to 19:00 does not match
start_time = "18:00". - A
start_timeafter theend_timewraps around midnight:start_time = "22:00",end_time = "05:59"matches starts from 22:00 to midnight and from midnight to 05:59 on each ofdays. Both parts belong to the day the event starts on: withdays = ["fri"], Friday 23:00 matches, Saturday 01:00 doesn’t. - All-day events have no start time: they match a time condition without times on their weekday, and never one with
start_timeorend_time, not even withnot = true. To match them, add a condition withall_day. - The desktop app’s common step Skip outside working hours fills in Mon-Fri,
18:00to07:59; Skip weekends fills in Saturday and Sunday without times.
Placeholders
value (of set_field) and replacement (of replace_field and replace_field_regex) may contain:
| Placeholder | Value |
|---|---|
{{source_name}} | Key of the source calendar, e.g. work for [calendars.work] |
{{source_url}} | URL of a CalDAV source, eventkit://local for Calendar.app, ics://feed for a feed (its address is a secret) |
{{sync_name}} | Name of the sync (its table key) |
An unknown placeholder is a config error. Placeholder values are inserted without escaping.
Order
Transforms run top to bottom on the whole source event, each on the result of the previous one. fields is applied after the last one:
- A
skipleaves the event out as soon as its conditions match; later transforms don’t run. - Conditions see the event as the transforms above them left it: a time condition sees the start moved by an earlier
add_buffer, a condition onSUMMARYthe title set by an earlierset_field. Put a transform above the others to look at the original values. A condition on a property that an earlierset_fieldorremove_fieldwithout conditions already fixed would always give the same answer, so it is a config error that names the transform to move. - A property a
set_fieldsets is copied, even if it is not infields, but only in the events its conditions matched. In the others the property stays out unless it is infields, so a title that should have become “Busy” is not copied from the source either. replace_fieldandreplace_field_regexchange only properties that are copied, so their property has to be infields(or be set by an earlierset_field).add_alarmchecks its conditions at its place but adds its alarm afterfields, so the source event’s own alarms are only copied with"VALARM"infields.
Scope
set_field,replace_field,replace_field_regex,remove_fieldandadd_bufferchange the event’s own properties in everyVEVENTof the resource. A recurring CalDAV series stores modified occurrences as extraVEVENTs, so aset_fieldofSUMMARYcovers them too. Alarms (VALARM) and timezone definitions (VTIMEZONE) are never touched;add_alarmadds an alarm to everyVEVENT.- Conditions look at the series itself (the
VEVENTwithoutRECURRENCE-ID); askipkeeps or leaves out the whole resource. - An event that was synced earlier and is skipped later (for example, moved to a weekend) is deleted from the target, the same way as an event deleted in the source.
Text escaping
Transforms work on the raw iCal values, where text is escaped as in RFC 5545 (except conditions, which match the unescaped value). This also applies to Calendar.app sources: their SUMMARY, LOCATION and DESCRIPTION are escaped before transforms run. A regex or a search of replace_field therefore has to match \,, \; and \\ instead of ,, ; and \, and a value or replacement should be escaped the same way. A line break in a description is \n.
To rewrite Lunch, Room 5 (stored as Lunch\, Room 5) to Lunch:
transforms = [
# pattern is a TOML literal string; as a basic string: "\\\\, Room \\d+"
{ field = "SUMMARY", action = "replace_field_regex", pattern = '\\, Room \d+', replacement = "" },
]
Full sync
A normal cycle only processes what changed in the source since the last one. A full sync compares the whole source with the target:
In the app: Full Sync Now under Advanced settings > Troubleshooting starts one, Full sync every sets
full_sync_interval.
- Read every current event of the source (for a Calendar.app source: its query window).
- Find the events this sync created in the target by their marker. The state is rebuilt from these markers, so the state files are a cache that can be lost.
- Create missing events and update events whose content differs. Remove Capycal’s own events that are duplicates, whose source event is gone, or that the current
fieldsand transforms exclude. Events Capycal did not create are never touched, and neither is the source.
When events disappear from the source
When events are gone from the source and it now has less than half as many events as were synced (from 5 synced events on), Capycal waits one cycle before it removes their copies. A source can be briefly empty or incomplete without an error, for example Calendar.app still loading its accounts right after login. If the events are back in the next cycle, nothing was removed; if they are still gone, for example because you cleared the calendar, the next cycle removes them. A source that replaced its events with as many new ones is not held back. The log says “most synced events are gone from the source, removing them in the next cycle”, and the desktop app’s menu shows “synced …, N to remove next cycle”. Failed or incomplete answers of the source (errors, cut-off listings) never remove anything.
Unchanged events
An event whose stored content is unchanged is not written again. On a CalDAV target the comparison ignores the DTSTAMP, LAST-MODIFIED, CREATED and SEQUENCE lines, line folding and line endings. On a Calendar.app target Capycal compares the fields it writes (title, location, notes with the marker, start, end, all-day flag, alarms and availability; times as instants, alarms in any order), so unchanged events keep their modification date and are not uploaded to the account again. An event with an old-style marker is written again to update the marker.
When it runs
A full sync runs:
- when a sync has no usable state: its first start, after the state directory was lost, or when the state file is corrupt (it is moved aside, see Troubleshooting);
- every
full_sync_interval, counted from the last full sync. State files from versions before full syncs count from their first cycle, so an upgrade doesn’t run every full sync at once; - after a change of the sync’s source or target calendar,
fields, transforms or Calendar.app query window, so existing copies get the new content and a new target gets every event. A change ofintervalorfull_sync_intervalalone does not trigger one. Copies in an old target stay there: runcapycal --purge <sync>before you change the target or rename the sync; - on the first cycle after a disabled sync is enabled again;
- when this sync’s events in a CalDAV target were changed or removed by someone else: another computer running the same sync, a purge there, or a hand edit (see One sync on several computers);
- on request:
capycal --full-sync(see CLI reference) or Full Sync Now in the desktop app (Settings, the sync’s Advanced settings > Troubleshooting).
full_sync_interval is a humantime duration greater than zero, default "7d". A full sync of a CalDAV target downloads every event of the target calendar, so on large calendars keep it long. A full sync stopped by a shutdown or reload, or one that failed, keeps its progress and is not recorded; the next cycle is a full sync again, also when it was only requested.
Calendar.app targets: Capycal marks its events with a notes line naming the sync, the source calendar and the source event. Events written by older versions carry only [capycal:<source>]. A full sync rewrites the marker of such events when the state knows them; without state it deletes them and creates them again with the full marker. If two syncs read the same source into the same Calendar.app calendar, an old-style event of one sync is not in the state of the other, so the first full sync of the other may delete it; its own sync creates it again at its next full sync.
One sync on several computers
You can run the same syncs on two or more computers, for example a Mac and a Linux laptop you switch between, so the copies stay current whichever one is on. Give every computer the same config, for example through Syncthing, a cloud folder or Git:
- Keep the sync names the same. A copy’s address and marker are derived from the sync name and the source event, so every computer writes the same copy instead of a second one.
- Keep the state local. Leave
state_dirout (each computer uses its OS default) or setCAPYCAL_STATE_DIRper computer; never share the state directory. - On a Mac the default state directory is inside the config’s folder (
~/Library/Application Support/capycal/state/), so share only the file: keep it in the shared folder and link it, for exampleln -s ~/Sync/capycal.toml ~/Library/Application\ Support/capycal/capycal.toml, or start the app with-c(see Another config file). Settings writes through the link. - Credentials from the Keychain or the Secret Service are stored per computer, so set the password on each one.
{ env = "..." }and{ command = "..." }work if each computer provides them.
Each computer keeps its own state, so it does not see what another one wrote. Writes of the same source event go to the same address, so normal cycles converge on their own. What a computer has to notice is a change in the target without a change in the source: the other computer removed the copies (--purge, Remove Synced Events), wrote them with other settings, or you edited or deleted a copy by hand. Capycal asks a CalDAV target at the start of each cycle what changed since its own last write (RFC 6578 sync-collection, without downloading events). If one of this sync’s own events changed, the cycle is a full sync, which restores the copies; the log says “synced events changed in the target, running a full sync”. Changes to other events in the calendar, including the copies of other syncs, do not count.
Limits:
- Only CalDAV targets. A Calendar.app target is not checked, a full sync there runs only on the other triggers above.
- The target server must support sync-collection; Nextcloud, Radicale, Baikal, SabreDAV, Fastmail, iCloud and most others do. Without it Capycal logs this at debug level and relies on
full_sync_interval. - A change made elsewhere while a cycle writes to the target counts as the cycle’s own and is picked up by the next full sync.
- Switching a sync off (
enabled = false) or changing its transforms reaches the other computer with the config. A purge with the desktop app writesenabled = false, so the other computer stops too; a purge with--purgedoes not, and the other computer restores the copies at its next cycle.
macOS and Calendar.app
On macOS, Capycal reads and writes Calendar.app calendars through Apple’s EventKit framework. Every account Calendar.app knows works: iCloud, Exchange, Google, CalDAV and local calendars. Capycal does not talk to Google or Exchange itself; Calendar.app does the syncing with the provider.
Permissions
macOS asks for calendar access the first time Capycal reads Calendar.app. Who gets the permission depends on how Capycal runs:
| Started from | Permission belongs to |
|---|---|
| Desktop app | “Capycal” |
| Terminal, iTerm, … | the terminal app |
| LaunchAgent | the capycal binary |
Change it in System Settings > Privacy & Security > Calendars. Capycal needs full access, since it writes events too. If the prompt is not answered within two minutes, the sync fails and is retried on the next cycle.
What is read
A Calendar.app source provides these properties per event:
| iCal | From Calendar.app |
|---|---|
DTSTART, DTEND | start and end, with the event’s timezone (TZID); all-day events as dates |
SUMMARY | title |
LOCATION | location |
DESCRIPTION | notes (without Capycal’s marker line) |
TRANSP | availability: “free” becomes TRANSPARENT, busy, tentative and unavailable become OPAQUE |
VALARM | alarms, relative or absolute |
Attendees, organizer, URL, status and recurrence rules are not available. Cancelled events are skipped.
Recurring events
A Calendar.app source delivers recurring events as individual occurrences, without RRULE. Each occurrence becomes its own event in the target. The reason: when you delete a single occurrence of a series, EventKit simply stops returning it. A copied RRULE would bring it back in the target; single occurrences mirror exactly what Calendar.app shows.
Query window
There is no change log in EventKit, so every cycle queries a time window around now: by default 30 days back and 365 days ahead (eventkit_days_past, eventkit_days_future on the sync). Events leaving the window are removed from the target.
EventKit returns nothing for a single query spanning more than about four years. Keep the sum of both options at or below 1460 days. eventkit_all_events = true instead queries 20 years back and 4 years ahead in four-year chunks. See Syncs.
Timezones and all-day events
Timed events keep their timezone (DTSTART;TZID=Europe/Berlin:...), so local times survive daylight saving changes. All-day events use local dates; their iCal end is the day after the last day, which Capycal converts from and to Calendar.app, where an all-day event ends at the last second of its last day. Time conditions of transforms compare in the Mac’s timezone.
Events Capycal writes to Calendar.app carry the timezone of their source event (UTC for UTC times), so they stay at the right moment when you travel; all-day events and times without a timezone stay floating, like in the source. A full sync adds the timezone to timed events written by earlier versions.
When writing to Calendar.app, Capycal translates timezone names macOS doesn’t know: Windows names such as W. Europe Standard Time (from Outlook and Exchange) become their IANA zone, and prefixed names such as /freeassociation.sourceforge.net/Europe/Berlin lose the prefix. Another unknown name is converted to UTC with the VTIMEZONE the event carries. Times without a timezone (floating) are local times; a name that still can’t be resolved falls back to local time with a warning in the log.
What is written
A Calendar.app target sets title, location, notes, start, end, all-day flag, availability, alarms and recurrence from the synced event:
DTEND, orDURATIONif there is noDTEND, sets the end.TRANSP:TRANSPARENTsets availability “free”, anything else “busy” (if the calendar supports availability).- Alarms mirror the synced
VALARMs: alarms of the target event are replaced on every update. LeaveVALARMout offieldsand don’t useadd_alarmfor blockers without reminders. STATUScan’t be set; EventKit treats it as read-only. Cancelled events are not copied anyway, and cancelled single occurrences of a series are removed from it.- A recurring event (
RRULE, from a CalDAV source) becomes a series in Calendar.app with the same rule.EXDATEs remove single occurrences, moved or changed occurrences (RECURRENCE-ID) are changed in the series. When the series changes, Capycal deletes it and writes it again as a whole, and a full sync always rewrites it. - Calendar.app can’t represent every
RRULE: hourly or more frequent rules,BYHOUR/BYMINUTE/BYSECONDand several rules on one event are logged as a warning, and only the first occurrence is written. Extra dates (RDATE) are left out with a warning. - Removing a series (deleted in the source,
--purge, Remove Synced Events) removes all its occurrences.
Marker
EventKit doesn’t keep custom iCal properties, so Capycal marks the events it creates with a line [capycal:sync=<sync>;source=<source calendar>;uid=<source event UID>] at the end of the notes (values containing %, ;, =, ] or line breaks are percent-encoded). The marker prevents sync loops when the calendar is also a source, and lets a full sync rebuild the state and purge find Capycal’s events. Keep it when you edit such an event. Events written by older versions carry [capycal:<source calendar>]; Capycal still reads it and replaces it with the full marker the next time it writes the event.
Calendar.app as source and target
Every combination works: Calendar.app to CalDAV, CalDAV to Calendar.app and Calendar.app to Calendar.app, for example from an Exchange calendar to an iCloud calendar in the same Calendar.app. See the desktop app quick start and the Cookbook.
Recipes
Common setups, each first as the steps in the desktop app’s Settings, then as a complete config. Each config passes capycal --validate; replace URLs, user names and calendar names with yours. Recipes that use Calendar.app calendars only validate on macOS.
- Work calendar as private blockers: your family sees “Busy” when you are in meetings.
- Two-way blockers: work and personal calendars block each other, without loops.
- Weekends, nights and business hours: copy only the events that matter on the other side.
- Travel buffers and reminders: block travel time, add or keep alarms.
- Calendar.app to Nextcloud: an Exchange or Google calendar through Calendar.app to a CalDAV server.
- Calendar.app as target: private CalDAV events as blockers in an Exchange calendar.
- Google Calendar as blockers: a Google or Outlook.com calendar through its secret iCal address, without OAuth.
All recipes use a dedicated, empty calendar or the main calendar of the other side as the target. Capycal only changes and deletes events it created itself, recognised by their marker.
Work calendar as private blockers
Your work meetings appear as “Busy (work)” in a calendar your family can see. No titles, locations, attendees or descriptions leave the work calendar.
In the app: Add Sync…, the work calendar as the source, the calendar your family sees as the target, and Busy blocker. The copies are titled “Busy (Work)” after the source calendar.
In the config:
[daemon]
log_level = "info"
[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { file = "nextcloud.secret" }
[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"
# An empty calendar on the family Nextcloud, shared with the family
[calendars.work_blockers]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"
[syncs.work-to-family]
source = "work"
target = "work_blockers"
interval = "5m"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]
What happens:
- Without
fields, only the times, the recurrence rules, the status and the free/busy flag are copied.SUMMARYis not copied; theset_fieldtransform adds a new one. - Events you marked as “free” stay free, so they don’t block the family calendar. Cancelled meetings are not copied.
- Recurring meetings stay a single series in the target.
nextcloud.secretis read from the config’s directory.
If the work calendar is in Calendar.app (Exchange, Google), use Calendar.app to Nextcloud instead.
Variations:
- Add
fields = ["LOCATION"]to see where a meeting takes place. - Add
{ action = "skip", when = [{ all_day = true }] }(in the app Skip events with Event set to is all-day) if all-day events (holidays, out-of-office) shouldn’t block a whole day; seeskip.
Two-way blockers
Work and personal calendars block each other: private appointments appear as “Private” in the work calendar, work meetings as “Work” in the personal calendar. Both syncs write straight into the main calendars, so colleagues and family see the blockers without subscribing to anything.
In the app: Add Sync… twice, work to personal and personal to work, each with Busy blocker. Change the title in each sync’s Set field step to “Work” and “Private”, and add Skip events with Event set to is all-day with + to the second.
In the config:
[daemon]
log_level = "info"
[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_PERSONAL_PASSWORD" }
[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"
[calendars.personal]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/personal/"
[syncs.work-to-personal]
source = "work"
target = "personal"
interval = "5m"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Work" },
]
[syncs.personal-to-work]
source = "personal"
target = "work"
interval = "5m"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Private" },
{ action = "skip", when = [{ all_day = true }] },
]
Why this doesn’t loop
Every event Capycal writes carries two extra properties:
X-CAPYCAL-SOURCE:work/<uid of the original event>
X-CAPYCAL-SYNC:work-to-personal
When personal-to-work reads the personal calendar, it skips every event with an X-CAPYCAL-SOURCE property. The “Work” blockers are therefore never copied back into the work calendar, and the same holds the other way round. In Calendar.app, which can’t store custom properties, the marker is a [capycal:sync=...;source=...;uid=...] line at the end of the event notes.
Consequences:
- Edit or delete the original, not the blocker. A blocker is overwritten on the next change of its original. A blocker deleted or changed by hand in a CalDAV target is restored by the next cycle when the server supports sync-collection (see One sync on several computers), otherwise by the next full sync.
- Chains don’t work: with A to B and B to C, the blockers from A in B are skipped and never reach C. Sync A to C directly.
- The CalDAV server must keep unknown
X-properties. Common servers (Nextcloud, Radicale, Baikal, iCloud, Fastmail) do. If a server drops them, two-way syncs loop; use separate blocker calendars there, so no sync reads a calendar another sync writes to.
Weekends, nights and business hours
A skip with a time condition leaves out events by weekday and start time in the local timezone of the machine running Capycal. These recipes block only the work hours of your private calendar in the work calendar: private appointments on weekends, at night or in the evening are nobody’s business at work.
Only business hours
Mon-Fri, 08:00-18:00. Everything that starts outside this window is left out:
In the app: add the sync with Busy blocker, then add the common steps Skip weekends and Skip outside working hours with +, and Skip events with Event set to is all-day.
In the config:
[daemon]
log_level = "info"
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_PERSONAL_PASSWORD" }
[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
[calendars.personal]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/personal/"
[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"
[syncs.personal-to-work]
source = "personal"
target = "work"
interval = "10m"
transforms = [
# Weekends
{ action = "skip", when = [{ days = ["sat", "sun"] }] },
# Weekdays before 08:00 and from 18:00: the window wraps around midnight
{ action = "skip", when = [{ days = ["mon", "tue", "wed", "thu", "fri"], start_time = "18:00", end_time = "07:59" }] },
# Holidays, birthdays and other all-day entries
{ action = "skip", when = [{ all_day = true }] },
{ field = "SUMMARY", action = "set_field", value = "Private appointment" },
]
Notes:
- Only the start counts. A private appointment from 17:00 to 19:00 is copied and blocks until 19:00.
end_timeis inclusive, so"07:59"keeps an 08:00 start.- A time condition with times never matches all-day events, not even with
not = true, so holidays need their ownskipwithall_day = true. - A recurring CalDAV series is checked by its first start: the whole series is kept or left out together. Calendar.app sources deliver every occurrence as its own event, so each occurrence is checked on its own.
Nights
A window whose start is after its end wraps around midnight, so one condition covers the night:
In the app: one Skip events step with Start, every day ticked, From 22:00 and Until 05:59.
In the config:
[daemon]
[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_PERSONAL_PASSWORD" }
[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"
[calendars.family]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"
[syncs.work-to-family]
source = "work"
target = "family"
interval = "5m"
transforms = [
# 22:00 - 05:59
{ action = "skip", when = [{ days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], start_time = "22:00", end_time = "05:59" }] },
{ field = "SUMMARY", action = "set_field", value = "Busy" },
]
Timezone
The times are compared in the system timezone of the machine running Capycal. On a server in UTC, start_time = "18:00" means 18:00 UTC. Set the server’s timezone, or for Docker mount the host’s zone into the container (/etc/localtime:/etc/localtime:ro).
Travel buffers and reminders
Travel buffers
On-site appointments in the personal calendar need travel time. add_buffer blocks 30 minutes before and 15 minutes after each event in the work calendar:
In the app: + > Add buffer with 30 minutes before and 15 after; Travel time among the common steps fills in 30 minutes on both sides. Skip events with Event set to is all-day goes before it.
In the config:
[daemon]
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_PERSONAL_PASSWORD" }
[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
[calendars.personal]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/personal/"
[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"
[syncs.personal-to-work]
source = "personal"
target = "work"
interval = "10m"
transforms = [
{ action = "skip", when = [{ all_day = true }] },
{ action = "add_buffer", pre_event = "30m", post_event = "15m" },
{ field = "SUMMARY", action = "set_field", value = "Private (incl. travel)" },
]
- All-day events are never buffered. They are dropped here, because a blocked whole day in the work calendar is rarely wanted.
post_eventextendsDTEND. Events stored withDURATIONinstead ofDTENDget a longerDURATION. Recurring events stay consistent: excluded and moved occurrences move with the series.- Put a
skipwith a time condition beforeadd_buffer, so it looks at the original times.
Only events with a place
Travel time only makes sense for appointments somewhere. With conditions, the buffer goes only to events that have a location which is not a video call link; the others are still copied, without a buffer:
In the app: Travel time for events at a place among the common steps adds the buffer with Location is not empty. Open it, press Add Condition, choose Location, does not contain and type https://.
In the config, instead of the add_buffer above:
transforms = [
{ action = "skip", when = [{ all_day = true }] },
{ action = "add_buffer", pre_event = "30m", post_event = "15m", when = [
{ field = "LOCATION", empty = false },
{ field = "LOCATION", contains = "https://", not = true },
] },
{ field = "SUMMARY", action = "set_field", value = "Private (incl. travel)" },
]
An event without a location counts as empty. To leave out events without a place altogether, use action = "skip" with { field = "LOCATION", empty = true } instead.
Reminders on blockers
There are two ways to get alarms on the blockers:
Keep the source’s alarms: add "VALARM" to fields. Every alarm of the original is copied, so the blocker reminds you exactly like the original.
Add your own: add_alarm adds a display alarm a fixed time before the (buffered) start. Repeat it for several alarms.
In the app: to keep the source’s alarms, tick Reminders of the source event under Keep > Custom (Title, notes, location, reminders keeps them too). For your own, + > Add alarm, or Reminder 10 min before among the common steps.
In the config:
[daemon]
[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { env = "CAPYCAL_PERSONAL_PASSWORD" }
[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"
[calendars.phone]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"
[syncs.work-to-phone]
source = "work"
target = "phone"
interval = "5m"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Work meeting" },
{ action = "add_alarm", trigger = "10m" },
{ action = "add_alarm", trigger = "1m" },
]
With both "VALARM" in fields and add_alarm, the blocker gets the source’s alarms plus yours. Without either, blockers have no alarms, which is usually what you want for blockers in a calendar other people see.
For Calendar.app targets, the target event’s alarms are replaced by the alarms of the synced data on every update.
Calendar.app to Nextcloud
Your employer’s Exchange (or Google Workspace) calendar is in Calendar.app on your Mac, but not reachable over CalDAV. Capycal reads it through EventKit and writes blockers to a Nextcloud calendar. Run it with the desktop app or a LaunchAgent.
In the app: Add Sync…, pick the Exchange calendar under its account in the source list (the app asks for calendar access first) and the Nextcloud calendar as the target. The window of days is Calendar.app events under Advanced settings.
In the config:
[daemon]
log_level = "info"
# The Exchange account's calendar as shown in Calendar.app
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { file = "nextcloud.secret" }
[calendars.work]
eventkit_calendar = "Exchange/Calendar"
[calendars.nextcloud]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/work-blockers/"
[syncs.work-to-nextcloud]
source = "work"
target = "nextcloud"
interval = "5m"
eventkit_days_past = 7
eventkit_days_future = 180
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]
- Find the exact
eventkit_calendarvalue withcapycal --discover-eventkit. The account name (“Exchange” here) is the one in Calendar.app’s sidebar; use the identifier if names are ambiguous. - Calendar.app delivers recurring events as single occurrences, so the target gets one event per occurrence within the window.
- The window here is one week back and six months ahead, which keeps each cycle fast. Blockers older than a week are removed from Nextcloud.
nextcloud.secretis read from the config’s directory (~/Library/Application Support/capycal/by default). Make it readable only by you.
This config only validates on macOS. On Linux, --validate reports eventkit_calendar is only supported on macOS.
Calendar.app as target
Calendar.app can also receive events. This is the way to write blockers into a calendar that Capycal can’t reach over CalDAV, such as an Exchange calendar your colleagues see. Here private appointments from Nextcloud appear as “Private” in the work calendar:
In the app: Add Sync… with the Nextcloud calendar as the source and the Exchange calendar under its account as the target, then set the title in the Set field step to “Private”.
In the config:
[daemon]
log_level = "info"
[accounts.nextcloud]
url = "https://cloud.example.org/remote.php/dav/"
username = "alice"
password = { file = "nextcloud.secret" }
[calendars.personal]
account = "nextcloud"
url = "https://cloud.example.org/remote.php/dav/calendars/alice/personal/"
# Your Exchange calendar in Calendar.app
[calendars.work]
eventkit_calendar = "Exchange/Calendar"
[syncs.personal-to-work]
source = "personal"
target = "work"
interval = "10m"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Private" },
]
What to know about Calendar.app targets:
- Title, location, notes, start, end, all-day, availability, alarms and recurrence are written. A recurring CalDAV event becomes a series in Calendar.app, with its excluded and moved occurrences. Rules Calendar.app can’t represent (hourly,
BYHOUR, several rules) are written as their first occurrence only, with a warning in the log. See What is written. - Capycal marks its events with a
[capycal:sync=...;source=personal;uid=...]line at the end of the notes. Don’t remove it; it prevents loops and lets a full sync and purge find the events. - The target event’s alarms are replaced with the alarms of the synced data on every update. With neither
"VALARM"infieldsnoradd_alarm, blockers have no alarms. TRANSP:TRANSPARENTbecomes availability “free”; everything else is “busy”.--purge <sync>(or Remove Synced Events in the desktop app) deletes the events of a sync by this marker.--purge-allempties a dedicated calendar.- Calendar.app sends the events to Exchange, Google or iCloud as it normally would. Your Mac has to be running for the blockers to update.
This config only validates on macOS.
Google Calendar as blockers
Your Google Calendar appears as “Busy (google)” in your work calendar, without a Google password or OAuth: Capycal reads the calendar’s secret iCal address. The same works with Outlook.com and any other service that publishes a calendar as an .ics address. See Calendar feeds for where to find the address.
In the app: in Google Calendar, copy Settings for my calendars, the calendar, Integrate calendar, Secret address in iCal format. Then in Capycal Add Sync…, Add Calendar Feed… below the calendars, paste the address into Feed address and Continue; pick the work calendar as the target and Busy blocker. In the sync’s From, set Check every to Custom and 30m.
In the config:
[daemon]
log_level = "info"
[accounts.work]
url = "https://caldav.work.example.com/"
username = "alice"
password = { env = "CAPYCAL_WORK_PASSWORD" }
# The secret address, kept out of the config file
[calendars.google]
ics_url = { env = "CAPYCAL_GOOGLE_FEED" }
[calendars.work]
account = "work"
url = "https://caldav.work.example.com/calendars/alice/default/"
[syncs.google-to-work]
source = "google"
target = "work"
# Google refreshes the feed only every few hours
interval = "30m"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Busy ({{source_name}})" },
]
What happens:
- Every 30 minutes Capycal downloads the feed and copies new, changed and removed events. Google itself updates the feed with a delay of several hours, so a new private appointment can take that long to block your work calendar. For changes within minutes, read Google through Calendar.app on a Mac instead (Calendar.app to Nextcloud).
- Without
fields, only times, recurrence, status and free/busy are copied; an event you marked as “free” in Google stays free. - The feed is read-only: Capycal never writes to Google. A feed cannot be the target of a sync.
- Anyone with the secret address can read the calendar. If it leaks, Reset it in Google Calendar and enter the new one.
Option reference
Every option the config parser accepts. Durations use humantime syntax ("30s", "15m", "1h 30m"). Unknown keys are errors.
[daemon]
The table and all its options are optional.
| Option | Type | Default | Description |
|---|---|---|---|
state_dir | path | $CAPYCAL_STATE_DIR, else Linux ~/.local/state/capycal, macOS ~/Library/Application Support/capycal/state | State files and config locks. Wins over CAPYCAL_STATE_DIR |
log_level | string | "info" | trace, debug, info, warn, error or a tracing filter directive. RUST_LOG and -v take precedence. Applied again on reload |
log_format | "pretty" | "json" | "pretty" | CLI log output format |
watch_config | bool | false | CLI daemon: reload when the config file changes. The desktop app always does |
Details: Daemon settings.
[accounts.<name>]
| Option | Type | Default | Description |
|---|---|---|---|
url | string | required | Server address to discover the account’s calendars on |
username | string | required | HTTP Basic user name |
password | credential | required | "plain", { env = "VAR" }, { file = "path" }, { command = "..." } or { keychain = "name" }, see Credentials |
Details: Calendars.
[calendars.<name>]
| Option | Type | Default | Description |
|---|---|---|---|
account | string | required for CalDAV | Name of the [accounts.<name>] whose credentials the calendar uses |
url | string | required for CalDAV | CalDAV calendar collection URL |
eventkit_calendar | string | - | macOS only. Calendar identifier, "Account/Name" or name. Makes this a Calendar.app calendar |
ics_url | credential | - | Address of a read-only iCalendar feed (https://, http:// or webcal://), as a string or any credential form. Makes this a feed, usable only as a source; takes no account or url. Details: Calendar feeds |
concurrency | integer | 2 | Maximum events written to this calendar at the same time as a target, shared by all syncs writing to it; also the parallel deletes of --purge / --purge-all. 0 counts as 1 |
Details: Calendars, Credentials.
[syncs.<name>]
Each sync is a table named by its key, for example [syncs.work-to-home]. The name is non-empty, has no / or \ and no leading .. Names with spaces, dots or other characters outside A-Z a-z 0-9 _ - need quotes: [syncs."work to home"]. Two syncs with the same name are a TOML error (“duplicate key”). Syncs run, log and show up in the menu bar app in file order.
| Option | Type | Default | Description |
|---|---|---|---|
source | string | required | Calendar key to read from |
target | string | required | Calendar key to write to; must differ from source |
interval | duration | required | Time between sync cycles |
fields | list of strings | [] | Content properties to copy, such as "SUMMARY", "LOCATION", "DESCRIPTION"; "VALARM" keeps alarm components. DTSTART, DTEND, DURATION, RRULE, RDATE, EXDATE, STATUS, TRANSP, UID, DTSTAMP and RECURRENCE-ID are always kept. See fields |
enabled | bool | true | false keeps the sync configured but stops it. See Disabling a sync |
full_sync_interval | duration | "7d" | Time between automatic full syncs; must be greater than zero. See Full sync |
eventkit_days_past | integer | 30 | Calendar.app source: days before now to query. Together with eventkit_days_future at most 1460 |
eventkit_days_future | integer | 365 | Calendar.app source: days after now to query |
eventkit_all_events | bool | false | Calendar.app source: query 20 years back and 4 years ahead in chunks |
transforms | list of tables | [] | Transforms, run in order. See Transforms below |
Details: Syncs.
Transforms
A sync’s transforms are the transforms list in its table, one inline table per transform:
[syncs.work-to-home]
# source, target, ...
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Busy" },
# comments are allowed between entries
{ action = "add_alarm", trigger = "10m" },
]
Multi-line lists of inline tables need TOML 1.1, which Capycal reads. Some editors and linters still only know TOML 1.0 and flag them. The TOML 1.0 form, one [[syncs.<name>.transforms]] table per transform, works too:
[[syncs.work-to-home.transforms]]
field = "SUMMARY"
action = "set_field"
value = "Busy"
A sync uses one form or the other; mixing both for the same sync is a TOML error.
| Option | Type | Used by | Description |
|---|---|---|---|
action | string | all, required | set_field, replace_field, replace_field_regex, remove_field, add_buffer, add_alarm, skip |
field | string | set_field, replace_field, replace_field_regex, remove_field (required) | iCal property name, e.g. "SUMMARY" |
value | string | set_field (required) | New value, may contain placeholders |
pattern | string | replace_field_regex (required) | Regular expression (Rust regex syntax) |
search | string | replace_field (required) | Text to find, not empty |
replacement | string | replace_field, replace_field_regex (required) | Replacement, may contain placeholders; for replace_field_regex also $1 / ${name} |
count | integer | replace_field | Replace only the first count occurrences, default all |
pre_event | duration | add_buffer | Move the start earlier |
post_event | duration | add_buffer | Move the end later. At least one of the two is required |
trigger | duration | add_alarm (required) | Alarm time before the start |
when | list of conditions | all; skip (required) | The transform only changes events that match all of them; { any = [...] } is one of them that matches when any of its conditions does |
Placeholders in value and replacement: {{source_name}}, {{source_url}}, {{sync_name}}. Details: Transforms.
Conditions
when is a list of inline tables. In the table form, each condition is a [[syncs.<name>.transforms.when]] table after its transform. A condition has exactly one test: contains, matches or empty (with field), days (with optional start_time and end_time), or all_day. Or it is { any = [...] }, at least two such conditions of which one has to match, with no other keys.
| Option | Type | Used by | Description |
|---|---|---|---|
any | list of conditions | At least two conditions, one of them has to match; nothing else in the same table, no any inside | |
field | string | contains, matches, empty (required) | iCal property name in upper case, e.g. "LOCATION" |
contains | string | The unescaped value contains this text, not empty | |
matches | string | The unescaped value has a match of this regex, not empty | |
empty | boolean | true: the property is missing or empty; false: it has a value | |
days | list of strings | The event starts on one of "mon" … "sun", at least one | |
start_time | "HH:MM" | days | Window start, local time, default "00:00"; after end_time the window wraps around midnight |
end_time | "HH:MM" | days | Window end (inclusive), local time, default "23:59" |
all_day | boolean | true: the event is all-day; false: it is not | |
not | boolean | contains, matches, days | Turn the test around, default false |
Details: Conditions.
Troubleshooting and FAQ
Getting debug logs
- CLI:
capycal --once -v(debug) or-vv(trace) runs one cycle with details per event.RUST_LOG=capycal=debugworks too and overrides both-vandlog_level. - Daemon: set
log_level = "debug"in[daemon]and reload (SIGHUP, or save withwatch_config = true). This has no effect ifRUST_LOGor-vset the level at start. - Desktop app: a click on a red status line in the menu opens the Problems window, which shows the full error message and the recent warnings and errors. For more, set
log_level = "debug"and save the config (the app reloads it), reproduce the problem, then Show Log (Settings > General) > Export.
Logs never contain passwords. They do contain calendar URLs and event UIDs, so check before sharing them.
Sync
Duplicate events
Capycal recognises its own events by the marker of the sync and the source event, so a full sync removes duplicates of one sync: run capycal --full-sync --once or Full Sync Now on the sync’s page in Settings (Advanced settings > Troubleshooting). Duplicates that come back keep a cause:
- Copied config on a second machine, or the same config in the desktop app and a LaunchAgent on different state directories. Both write the same events; run each sync in one place only.
- Renamed sync: the marker and the target UIDs depend on the sync name, so the old events are no longer this sync’s. Purge before renaming (see Syncs).
To remove everything a sync created, run capycal --purge <sync> (on the machine whose config has this sync), or use Remove Synced Events… (Settings, the sync’s Advanced settings > Troubleshooting) in the desktop app. --purge-all (or Remove All Events… next to it in the app) empties a calendar used only by Capycal.
A deleted copy comes back
A sync owns its copies: one deleted or changed by hand in a CalDAV target is restored by the next cycle, and by the next full sync on any target (see One sync on several computers). To keep an event out of the target, leave it out with a skip transform, switch the sync off, or remove its copies with Remove Synced Events… or capycal --purge <sync>.
Lost or corrupt state
The state files are a cache. If one is missing (deleted state directory, new machine with the same target) the sync starts with a full sync that rebuilds it from the markers in the target, without creating events twice. A state file that cannot be parsed is renamed to <sync>.json.corrupt-<timestamp>, logged as a warning (“state file corrupt, moved aside”), and the sync rebuilds it the same way. The renamed file can be deleted.
“most synced events are gone from the source, removing them in the next cycle”
When the source suddenly has less than half as many events as were synced, Capycal waits one cycle before removing their copies (see Full sync). If you cleared the source on purpose, the copies go with the next cycle; Now in the desktop app or another capycal --once starts it right away. If the source events are still there, the source answered incompletely and the next cycle normally finds them again.
Changes to fields or transforms do not show
Changing a sync’s source, target, fields or transforms makes its next cycle a full sync by itself: it applies the new rules to every event, updates the events in the target and removes those the new filters drop. The copies change at the next cycle (interval), or at once with Sync Now. If they still look old, check the log for errors of that cycle, or start a full sync yourself: capycal --full-sync --once, or Full Sync Now in the desktop app (Settings, the sync’s Advanced settings > Troubleshooting).
Changes in a calendar feed show up hours late
Capycal reads a feed at every interval, but the service decides how fresh it is: Google refreshes the secret iCal address only every few hours. Nothing in Capycal makes it faster. For changes within minutes, read the calendar through Calendar.app on a Mac, or over CalDAV where the service offers it with a password.
“the feed answered 404 Not Found” / “does not return an iCalendar file”
The feed’s address is wrong or no longer valid, for example after resetting Google’s secret address. Copy it again from the service and paste it into the sync’s Feed address (or ics_url). “does not return an iCalendar file” means the address answers with a web page, often a sign-in page: use the address meant for calendar apps (Google: Secret address in iCal format, not the public or embed link).
Blockers in the wrong place with a time condition
A condition on the start (days, start_time, end_time) uses the timezone of the machine running Capycal, which is often UTC on servers and in containers. See Timezone.
Desktop app
macOS asks for the Keychain password after an update
macOS ties the permission to read a Keychain item to the exact build of the app, since Capycal is not signed with an Apple Developer ID. Always Allow keeps it until the next update; after an update macOS asks once more for each account. The app explains this before the prompt appears. To avoid the prompts, take the password from a file or an environment variable instead (see Credentials).
The app won’t open (“cannot be opened”, “unidentified developer”)
The app is not notarized. Right-click Capycal.app in Finder and choose Open, or run xattr -dr com.apple.quarantine /Applications/Capycal.app. For the CLI binary: xattr -d com.apple.quarantine /usr/local/bin/capycal. If the app opens but no menu bar icon appears, the menu bar may be full; quit other menu bar apps or check with pgrep -l capycal-ui.
Calendar access denied / no Calendar.app events
- Check System Settings > Privacy & Security > Calendars. The permission belongs to “Capycal” for the app, to the terminal app for the CLI in a terminal, and to the
capycalbinary for a LaunchAgent (see Permissions). - If you denied the prompt once, macOS doesn’t ask again. Enable Capycal in the settings, or reset it with
tccutil reset Calendar app.capycal(app) and start again. calendar '...' not found in Calendar.app: compare theeventkit_calendarvalue with the output ofcapycal --discover-eventkit.- Events outside the query window are not synced.
Linux desktop app
The Linux app is capycal-gtk (see Desktop app (Linux)); capycal-ui is the macOS app and only prints a message on Linux. If the app says it has no tray icon, your desktop doesn’t show StatusNotifierItem icons: on GNOME install the AppIndicator extension. The icon then appears without a restart.
Config file and CLI
“already in use by another Capycal process”
Another process holds the config lock: a running daemon, a LaunchAgent or systemd service, or the desktop app. The message names the pid. Stop that process, or use it instead (send it SIGHUP to reload). The desktop app shows this as “Another Capycal runs this config (pid N)”.
“sync ‘…’ is also run by config … with the same state directory”
Two different configs share a state directory and have a sync with the same name; both would write the same state file. Rename one of the syncs, or give one config its own state_dir.
“unknown field ...”
The config has a key Capycal doesn’t know at this place. The message shows the line and the keys allowed there. Check spelling and placement: eventkit_days_past belongs to the [syncs.<name>] table, not to the calendar; concurrency belongs to the calendar; [daemon] options don’t go into a sync.
“syncs are named tables now: replace [[syncs]] …”
The config uses the format of older Capycal versions, where each sync was a [[syncs]] entry with a name key and its transforms followed as [[syncs.transforms]]. Each sync is now a table named by its key:
# before
[[syncs]]
name = "work-to-home"
source = "work"
[[syncs.transforms]]
field = "SUMMARY"
action = "set_field"
value = "Busy"
# now
[syncs.work-to-home]
source = "work"
transforms = [
{ field = "SUMMARY", action = "set_field", value = "Busy" },
]
See [syncs.<name>] and Transforms.
“no syncs defined in … - add a [syncs.<name>] table”
The config is valid but has no [syncs.<name>] table, so the CLI has nothing to run. Add a sync (see Syncs). A reload to a config without syncs is rejected the same way, and the running syncs continue.
Name resolution fails on Linux (*.local, LDAP hosts)
The static Linux binaries don’t use NSS. Use the server’s IP address or a DNS name, or build from source.
Building and testing
Capycal is a Rust crate (edition 2024): a library with the capycal CLI and the capycal-ui menu bar app (feature ui). Contributor conventions are in CLAUDE.md.
Build and test
make build # cargo build
make build-ui # with the menu bar app (feature ui)
make run-ui # run the menu bar app as a plain binary (macOS)
make run-app # build Capycal.app and open it (macOS)
make test # cargo test
make test-ui # tests of the menu bar app
make test-caldav # end-to-end tests against a throwaway Radicale server
make test-caldav-docker # the same in a Linux container, needs only Docker
make test-eventkit # tests against Calendar.app (macOS)
make lint # cargo fmt --check + clippy -D warnings, without and with the app
make fmt # format the code
make lint test test-ui checks what CI checks, except the GTK app. make help lists every target with a short description.
EventKit code is compiled on macOS only. Its conversion logic (eventkit/convert.rs) and the app’s display-free modules are compiled and tested on Linux too, so make test test-ui works on both platforms.
make test-caldav (scripts/test-caldav.sh) installs Radicale into target/radicale, starts it on 127.0.0.1:5299 with a temporary storage directory and runs tests/caldav_e2e.rs: the capycal binary syncs between two calendars named capycal-test-... and the tests check what arrives in the target (busy blocker fields, updates, series with exceptions, buffers, cancelled events, full sync after lost state, purge). The tests are #[ignore]d, so a plain cargo test skips them. It needs python3 and curl.
make test-eventkit runs the #[ignore]d live_tests in src/eventkit/client.rs against the real Calendar.app database: Calendar.app as a source (all-day, multi-day, timed, alarms, series as occurrences) copied into a second calendar with nothing left to change for a full sync, series with excluded, moved and cancelled occurrences, UNTIL, monthly and all-day rules, rewriting a series, a single event round trip with the full sync comparison, unsupported rules, delete and purge. Every test creates its own calendar named capycal-test-<pid>-<test> (“On My Mac” if available, else in the account of the default calendar) and removes it afterwards, also when the test fails. Calendars with that prefix left over by an aborted run are removed at the next run; no other calendar is touched. Run it from Terminal: macOS asks once whether Terminal may access your calendars.
The first line of the output names the source the calendars go to. Prefer “On My Mac” (type 0): in an account that syncs (iCloud and CalDAV report type 2), a removed calendar can come back when the account syncs its creation afterwards, so the tests remove it again until it stays away, and other devices briefly see it. macOS hides “On My Mac” while an account syncs calendars. To get it back, turn off Calendars for every account in System Settings > Internet Accounts, create a calendar under “On My Mac” in Calendar.app (not named capycal-test-..., the tests would remove it), and turn the accounts back on. “On My Mac” stays as long as it holds a calendar.
The Linux desktop app needs the GTK 4 and libadwaita development files (sudo apt install libgtk-4-dev libadwaita-1-dev, or sudo dnf install gtk4-devel libadwaita-devel): make build-gtk, make run-gtk, make test-gtk, make clippy-gtk. It is not part of make lint, so working on the rest needs no GTK. On macOS, brew install gtk4 libadwaita pkgconf lets make run-gtk show its windows for development; the tray exists only on Linux, so there the app opens its menu as a window.
For manual tests against a real server, make up-dev starts a local Radicale CalDAV server on port 5232 without authentication (docker-compose.dev.yml); make down-dev stops it.
Release builds
The distribution targets write to dist/:
| Target | Output in dist/ | Host |
|---|---|---|
make dist-macos | aarch64-apple-darwin/capycal (CLI) | macOS, Apple silicon |
make app | Capycal.app (app with the CLI inside, signed ad hoc, see below) | macOS, Apple silicon |
make dmg | capycal-v<version>-macos.dmg | macOS, Apple silicon |
make dist-linux-x86_64 | x86_64-unknown-linux-musl/capycal (static) | any, needs Docker buildx |
make dist-linux-arm64 | aarch64-unknown-linux-musl/capycal (static) | any, needs Docker buildx |
make dist | everything the host can build |
The Linux builds run in Docker (packaging/linux/Dockerfile.musl); a foreign architecture is emulated and therefore slow. The Linux release packages are made by packaging/linux/build-packages.sh <target> from a native release build: it generates man page, completions and /usr/bin units into dist/pkg, then writes the tarball, a .deb (cargo-deb) and an .rpm (cargo-generate-rpm) according to [package.metadata.deb] and [package.metadata.generate-rpm] in Cargo.toml. The desktop app is packaged separately by packaging/linux/build-gtk-packages.sh <target> from a glibc build with --features gtk (the gtk variants in the same metadata: desktop entry, icons, GTK dependencies); the release builds it on Ubuntu 24.04, whose GTK 4.14 and libadwaita 1.5 are the oldest it supports. The release job writes the AUR PKGBUILD from packaging/linux/aur/PKGBUILD.in with the checksums of the musl tarballs; publishing it to the AUR is manual. It also writes the Homebrew formula capycal.rb and cask capycal-app.rb from the templates in packaging/homebrew/ and attaches them to the release; with the secret HOMEBREW_TAP_TOKEN (write access to twiebe/homebrew-capycal) it commits them to the tap. The app is assembled by packaging/macos/build-app.sh and packed by build-dmg.sh.
Every package and the app’s About > Acknowledgements include THIRD-PARTY-NOTICES.txt, the licenses of all crates compiled into the binaries. After adding or updating a dependency, regenerate it with make notices (needs cargo install cargo-about --features cli); CI fails with make check-notices when it is out of date or a crate uses a license about.toml does not accept.
build-app.sh signs the bundle ad hoc. macOS ties Always Allow of a Keychain item to the code hash of the build unless the app is signed with a Developer ID (an Apple team), so every changed build, including make run-ui, asks again for each Keychain item. A config whose passwords come from files or environment variables avoids the prompts during development. Its icon comes from packaging/macos/icon/: make-icon.py draws capycal.svg, the PNGs in AppIcon.iconset/ and the book’s favicons in docs/theme/ (all checked in; rerun it after changing the drawing, see its header), and build-app.sh turns the iconset into AppIcon.icns with iconutil.
Documentation
This book is built with mdBook from docs/. Install mdBook (cargo install mdbook or a release binary), then:
make docs # build into docs/book/
make docs-serve # serve on http://localhost:3000 with live reload
Without mdBook, make docs-docker and make docs-serve-docker do the same in a container and need only Docker.
The book is published at capycal.app/docs by the Pages workflow of the website repository twiebe/capycal-web, which builds it from main. Pushes to main that change docs/ check the build and trigger that workflow (.github/workflows/docs.yml, needs the secret WEBSITE_DISPATCH_TOKEN).
Architecture
Capycal follows a pragmatic ports-and-adapters layout. The crate is a library (src/lib.rs) plus three binaries: the capycal CLI (src/main.rs), the capycal-ui menu bar app for macOS (src/bin/capycal-ui/, feature ui) and the capycal-gtk tray app for Linux (src/bin/capycal-gtk/, feature gtk), which reuse the same sync engine. The domain core (iCal processing, sync loop, etag diff, UID mapping) knows nothing about CalDAV, EventKit, the file system or the config file format. Adapters plug into small traits (ports).
Dependency rule
ical/, sync/runner.rs, sync/diff.rs, sync/status.rs and state/mapping.rs must not depend on config, the adapters (caldav, eventkit), reqwest or std::fs. Config is translated into domain types (RunnerSettings, Transform) at the edge, in the coordinator. Time is passed in as a parameter instead of read from the wall clock.
Source tree
src/
lib.rs Library root: module tree and the public API for front ends
main.rs The `capycal` binary: parse the CLI and hand over to `cli::execute`
bin/capycal-ui/ The menu bar app (feature `ui`, macOS only; a stub elsewhere)
main.rs Module gating, stub main on other platforms
args.rs -c / --config of both desktop apps
app.rs macOS glue: tao event loop, tray menu, dialogs (rfd)
text_window_macos.rs macOS glue shared by the windows: toolbar row above a read-only text view, focus, close
log_window_macos.rs macOS glue: the native AppKit log window
panel_window_macos.rs macOS glue: header, buttons and text ("Problems")
about_window_macos.rs macOS glue: About window like libadwaita's (built-in icon, links)
dialog_window_macos.rs macOS glue: centered dialog (icon, heading, spinner, message, links, popup, buttons, footnote) changed in place
backend.rs Config, ConfigLock and Coordinator on the tokio thread; commands in, updates out; applies a SettingsChange (purge, keychain copy, ConfigEditor save, keychain cleanup, reload, resume)
menu.rs Menu model from a snapshot, status lines, relative time, menu bar icon state
problems.rs Problems window text: current problems in full plus recent warnings and errors
dialog.rs Dialog contents (heading, message, buttons, links, spinner) shared by the dialog models
purge.rs Remove Synced Events dialog model: counting, choosing the syncs, confirm, removing, result
purge_all.rs Remove All Events dialog model: counting, warning, confirm by typing the calendar's name, removing, result
settings_view.rs Settings window as a display-free view (SettingsView of typed controls with stable ids) and its input handling; the glue only draws it
preview.rs the Settings preview: a sample event and the copy a sync makes of it (SyncConfig::preview)
settings_window_macos.rs macOS glue: sidebar and editor drawn from a SettingsView, updated in place while its shape stays
settings.rs Settings model: syncs and accounts of the config, selection, drafts, changes on disk; save and delete build one SettingsChange
sync_form.rs Sync form: texts, field errors, fields, transforms, what a save does to existing copies
transform_form.rs Transform forms: fields per action, parsing and errors, titles and summaries
account_form.rs Account form: URL, user name, password in the keychain (KeychainOp)
picker.rs Calendar picker: calendars per account and Calendar.app, matched with [calendars] entries, key proposals
sponsor.rs Ways to sponsor (OPTIONS, logos in assets/), when to ask, its dialog and answers
logview.rs Log view model (filter, follow, trimming, line colors), export
autostart.rs Start at Login: SMAppService, LaunchAgent fallback
bin/capycal-gtk/ The Linux tray app (feature `gtk`), sharing the models above by #[path]
app.rs GTK glue: application, backend thread, actions, Problems, purge and sponsor dialogs
tray.rs StatusNotifierItem tray via ksni (Linux only)
tray_icon.rs Tray icon pixels: badge and fade per state, ARGB
log_window.rs Log window on a GtkTextBuffer, driven by logview
settings_window.rs Settings window drawn from the shared SettingsView (DropDowns, entries, toggle buttons), updated in place
text_window.rs Heading, header buttons and read-only text ("Problems")
dialog_window.rs adw::Dialog built from dialog::View, changed in place
about_window.rs About on one page like the macOS window (tagline, links)
menu_window.rs The menu as a window: no tray, a second start, macOS
icons.rs App and symbolic icons for the icon theme when not installed
autostart.rs Start at Login: XDG autostart entry
cli/
mod.rs CLI flags, dispatch, logging init, CliError, config loading, --validate, --only
discover.rs --discover (CalDAV) and --discover-eventkit, snippet formatting shared with the app
generate.rs --generate-config starter config
purge.rs --purge and --purge-all (with its confirmation)
status.rs --status: lock holder and per-sync state, read-only
service.rs --install-service / --uninstall-service: systemd user unit
run.rs --once, --full-sync and daemon mode with signal handling
config_watch.rs ConfigWatcher: debounced notifications when the config file changes
logging.rs LogBuffer tracing layer and file + buffer logging for front ends
config.rs TOML config types, validation, transform compilation, credential resolution
config_edit.rs ConfigEditor: writes accounts, calendars and syncs into capycal.toml for the desktop apps (toml_edit, keeps comments and order, validates before an atomic save)
source.rs EventSource / EventSink ports
error.rs Error types
caldav/
client.rs CalDAV HTTP client (PROPFIND, REPORT, PUT, DELETE, retry policy); discovery reads privileges and component sets
auth.rs ResolvedAuth (Basic auth credentials)
source.rs CalDavSource + CalDavSink (port implementations)
eventkit/
client.rs macOS EventKit adapter (cfg(target_os = "macos")): reads/writes EKEvent
convert.rs Pure EventKit <-> iCal conversion, etag and notes marker (all platforms)
tzid.rs TZIDs NSTimeZone does not know: Windows and prefixed names, VTIMEZONE offsets (all platforms)
ics/
mod.rs IcsSource: read-only iCalendar feed (ics_url), one GET split into events per UID
ical/
mod.rs Processing pipeline, line unfolding, shared iCal helpers
filter.rs Field filtering (STRUCTURAL_FIELDS always, `fields` on top), VTIMEZONE carry-over
timezone.rs VTIMEZONE offsets: local <-> UTC for zones the event defines (buffers, unknown TZIDs)
transform.rs Transform (action: set, regex, remove, buffer, alarm, skip; conditions) and application
marker.rs X-CAPYCAL-SOURCE/SYNC stamping and detection
sync/
runner.rs Generic sync loop (source -> transform -> sink) and full sync
diff.rs Etag-based changed/removed detection shared by sources
status.rs SyncStatus, CycleStats and the StatusReporter port
coordinator.rs Multi-sync management, shared semaphores, lifecycle, reload
maintenance.rs Purge: find a sync's sink events by marker, delete them, clear state;
the next source event for the Settings preview
factory.rs AnySource / AnySink, adapter construction (the one place to add a backend)
state/
mod.rs SyncState and the StateStore port
store.rs Atomic JSON file store, moves corrupt files aside
lock.rs ConfigLock: one process per config, sync name collision check
mapping.rs Source UID -> sink UID/href mapping
Library and binaries
The modules are private to the library. lib.rs re-exports only what a front end needs (Config, Coordinator, Error, …), so the compiler still reports unused code inside the modules. The CLI lives in the library too (cli::execute), which keeps main.rs a few lines long. The library and the default build have no GUI dependencies; a GUI binary brings its own as optional dependencies behind a feature.
Ports
#![allow(unused)]
fn main() {
pub trait EventSource: Send + Sync {
/// Changed events and removed hrefs since the last sync.
fn fetch_changes(&self, state: &mut SyncState)
-> impl Future<Output = Result<(Vec<SourceEvent>, Vec<String>)>> + Send;
}
pub trait EventSink: Send + Sync {
fn put_event(&self, href: &str, ical_data: &str)
-> impl Future<Output = Result<PutResult>> + Send;
fn delete_event(&self, href: &str) -> impl Future<Output = Result<()>> + Send;
/// The events this sync created in the sink, found by marker.
fn list_synced(&self, sync_name: &str, source_name: &str)
-> impl Future<Output = Result<Vec<SinkEvent>>> + Send;
/// Hrefs changed since `token` and a new token; None if the sink cannot tell.
fn changes_since(&self, token: Option<&str>)
-> impl Future<Output = Result<Option<SinkChanges>>> + Send { ready(Ok(None)) }
/// Whether an in-flight put may be abandoned on shutdown (default false).
fn cancel_safe(&self) -> bool { false }
}
pub trait StateStore: Send + Sync {
/// None if missing, or corrupt and moved aside.
fn load(&self, sync_name: &str) -> Result<Option<SyncState>>;
fn save(&self, sync_name: &str, state: &SyncState) -> Result<()>;
fn remove(&self, sync_name: &str) -> Result<()>;
}
pub trait StatusReporter: Send + Sync {
fn report(&self, status: &SyncStatus);
}
}
The sync’s target calendar in the config is the sink in the code. CalDAV and EventKit implement both EventSource and EventSink, so every combination works: CalDAV to CalDAV, EventKit to CalDAV, CalDAV to EventKit, EventKit to EventKit. A feed (ics::IcsSource) is only an EventSource: validation rejects it as a target and build_sink refuses it. AnySource / AnySink in sync/factory.rs are enums that dispatch to the concrete adapter, so the runner stays generic without dyn.
Status and sync now
A running sync reports its SyncStatus (running flag, start time of the last cycle, and either the CycleStats with created/updated/deleted/error counts or the error message) through the StatusReporter port when a cycle starts and when it ends. A full sync also reports its progress in SyncStatus::full_sync (FullSyncProgress: changes written of a total that is known once source and sink are read) and counts finished full syncs in full_syncs, so the desktop apps can follow a requested full sync even when a normal cycle runs first. The coordinator implements the port with a tokio::sync::watch channel holding the status of all syncs; Coordinator::status() subscribes to it. Coordinator::sync_now(name) wakes one sync (or all with None) through a per-runner Wake, so the next cycle starts without waiting for the interval; Coordinator::full_sync(name) does the same and sets the Wake’s flag, so that cycle is a full sync. The CLI uses neither.
Coordinator::pause(name) stops one sync and remembers its name in memory; its status stays in the list with paused set. start and reload skip paused syncs and forget the names of syncs no longer configured, so a pause survives reloads but not a restart of the process. Coordinator::resume builds and spawns the runner again, sharing the sink semaphores of the running config.
Markers and state as a cache
Every event Capycal creates carries a marker naming the sync and the source event:
- CalDAV (and iCal in general):
X-CAPYCAL-SOURCE:<source calendar>/<source uid>andX-CAPYCAL-SYNC:<sync>(ical/marker.rs). Sink hrefs are deterministic (capycal-<uuidv5(sync, source uid)>.ics). - EventKit keeps no custom properties, so the sink writes a notes line
[capycal:sync=<sync>;source=<source calendar>;uid=<source uid>], values percent-encoded where they contain%,;,=,]or line breaks (eventkit/convert.rs:NotesMarker,find_marker,with_marker). The legacy[capycal:<source>]line of older versions is still read everywhere; every put replaces all marker lines with the current one. Reading an EventKit event with either marker emitsX-CAPYCAL-SOURCE, so a bidirectional setup skips it (loop prevention). A legacy marker names no sync, so purge and listing attribute it by source.
EventSink::list_synced returns the events of one sync from the sink (SinkEvent: href, sink UID if known, source UID from the marker, stored iCal if available). CalDAV lists the calendar, fetches it with a multiget and keeps the events with exactly this X-CAPYCAL-SYNC (caldav::source::synced_events, shared with purge). EventKit queries the sink calendar 20 years back and 4 ahead in chunks, deduplicated by identifier, and returns no UID and no content.
So the state file (SyncState: cursor, UidMapping, pending_removals, last_full_sync) is a cache: everything in it can be rebuilt from the source and the sink markers.
Full sync
The runner’s cycle picks an incremental cycle or a full sync (full_sync_at, internally the reconcile). A full sync runs when
- the state is missing or corrupt:
FsStateStore::loadmoves a file it cannot parse to<name>.json.corrupt-<unix seconds>and logs a warning; without a state the runner setslast_full_syncto the Unix epoch, meaning “never”; full_sync_interval(per sync, default 7 days) passed sincelast_full_sync. A state saved before the field existed (None) is set to the first cycle’snowand saved, so an upgrade does not fully sync everything at once;- it was requested:
Wake::full_sync(Coordinator::full_sync, the app’s Full Sync,--full-syncin daemon mode) orSyncRunner::full_sync_once(--full-sync --once); - the last one did not complete:
full_sync_atsetsSyncState::full_sync_pendingwhen it starts and clears it on completion, so a cancelled or failed full sync, even a requested one, runs again in the next cycle. A disabled sync getsfull_sync_pendingtoo (mark_disabledin the coordinator), so enabling it starts with a full sync; - the sink changed elsewhere:
changed_elsewhereasksEventSink::changes_since(SyncState::sink_token)at the start of a cycle and compares the changed hrefs by file name with the mapping’s sink hrefs. One of them, or an expired token (Error::SyncTokenExpired), makes the cycle a full sync. After every cycle that wrote,note_own_writesmoves the token past the cycle’s own writes, so they don’t count. The CalDAV sink answers with a sync-collection REPORT withoutcalendar-data(CalDavClient::sync_collection_hrefs); a server without sync-collection and the EventKit sink answerNone, which skips the check. This lets several computers run the same sync with their own state; - the output settings changed:
RunnerSettings::output_key(fromSyncConfig::output_key: source and target calendar,fields, transforms, EventKit window) differs fromSyncState::output_key, which a completed full sync stores. A state without one takes the current key, so an upgrade does not fully sync everything at once.
Algorithm:
- Fetch the source against an empty
SyncState: without cursor and mapping, CalDAV (sync-collection without token or the ctag/etag fallback), EventKit and feeds (EtagDiffagainst no known etags, a feed without its HTTP validator) report every current event. Thenlist_syncedthe sink. Both are cancellable and change nothing. rebuild_mapping: group the sink events by source UID (a legacy EventKit event without UID is attributed through the old mapping by sink href, compared by last path segment because CalDAV listings are absolute). Old entries stay unless the sink names another source UID for their event (they may point outside the listing range). Of several sink events for one UID keep the one in the old mapping, else the one at the deterministic href, else the first; the others and unattributed legacy events are “stray” and deleted directly. A rebuilt entry without source event gets the source hreforphan:<sink href>, so its removal goes through the normal path.- Every mapped source UID that is no longer in the source, and every mapped event the transforms now reject, is removed with the usual guarantees: the mapping entry is dropped after a successful delete, a failed delete goes to
pending_removals. - Every other source event is processed.
list_syncedreturns with each sink event what the sink keeps of it (EventSink::Stored), andEventSink::is_unchangedtells whether a put would change it. If it would not, only the mapping is updated; otherwise the event is put to its mapped href (update in place) or created. CalDAV keeps the stored iCal and compares withical::same_content(lines in order after unfolding, ignoring line endings, blank lines andDTSTAMP,LAST-MODIFIED,CREATED,SEQUENCE). EventKit keeps the fieldsapply_updatesets (convert::StoredEvent, read while listing) and compares them withconvert::is_unchanged: title, location, notes including the marker line (a legacy marker counts as a change), start and end as instants (all-day boundaries as local dates, aDURATIONend from the start), the all-day flag, alarms as a set, and availability only if the calendar supports it. Fields the iCal leaves out are not compared, since a put keeps them too. - Puts, deletes and stray deletes share
pushwith the incremental cycle. On success the cursor from step 1 andlast_full_sync = noware saved. If a put failed, the cursor stays empty, so the next cycle fetches everything again. A cancelled full sync saves its progress (mapping, pending removals) with an empty cursor, withoutlast_full_syncand withfull_sync_pending, so the next cycle runs it again and nothing is duplicated.
Purge
sync::maintenance::count_synced_events and purge_sync find the events one sync created in its sink: on CalDAV by listing the calendar, fetching the events with a multiget and keeping those whose X-CAPYCAL-SYNC is exactly the sync’s name; on EventKit by the notes marker naming the sync, or for a legacy marker the sync’s source (count_events_by_marker, delete_events_by_marker, sharing synced_events with the sink listing). EventKit calls run in spawn_blocking, each inside an autoreleasepool. purge_sync deletes with the sink’s concurrency (delete_concurrently), reports the number of handled events to a Progress callback (the menu bar app sends it throttled as Update::PurgeProgress), counts single failures and then removes the sync’s state through the StateStore. --purge and the menu bar app share it; Coordinator::purge pauses the sync first and resumes it if the sink could not be read. count_calendar_events and purge_calendar do the same for every event of one calendar (CalDAV list_events, EventKit count_all_events / delete_all_events) and remove the state of every sync with that target; --purge-all and the menu bar app’s Remove All Events share them, and Coordinator::purge_calendar pauses the syncs writing into the calendar meanwhile. next_source_event reads a sync’s source with an empty state and returns the first event starting at or after now (by DTSTART), for the Settings window’s preview.
Config lock
Two processes running the same config would write the same state files and create duplicates. ConfigLock::acquire(config, config_path) takes an exclusive flock (File::try_lock) on <state_dir>/locks/<hash>.lock, where the hash is a truncated UUIDv5 of the canonical config path (stable across builds, unlike std::hash). The file holds pid, config path and sync names; the lock is released when the ConfigLock is dropped or the process exits, and the file is left in place. After locking its own file, a process probes the other lock files with a shared lock: one that is still held and names a sync of the same name means another config shares the state directory and a state file, so acquiring fails. Both processes write their own file before probing, so of two configs starting at once at least one sees the other. The CLI locks for daemon mode, --once, --full-sync, --purge and --purge-all; its sync names are recorded at start and not updated on reload. ConfigLock::update rewrites the names of a held lock (and checks them for collisions, keeping the old names on failure); the menu bar app calls it on every reload and holds the lock for its lifetime.
Config watcher
ConfigWatcher::new(path) returns the watcher and a tokio::sync::mpsc::Receiver<()>; changes are reported while the watcher is alive. It uses notify (inotify on Linux, FSEvents on macOS), ignores access events (inotify reports the open of every reload, which would loop), debounces on a small thread (a change is reported 500 ms after the last event) and watches the parent directory non-recursively, filtering by file name. That way a replace by rename (how TextEdit, vim and VS Code save), deletion and re-creation are seen, which a watch on the file itself would miss after the first rename. Several changes in a row become one message (the channel holds at most one). The CLI daemon creates a watcher when watch_config is set and turns a change into the same reload as SIGHUP. The menu bar app always watches, creating the config directory first so a config written later by “Config > Edit” is picked up. Capycal never writes the config itself except the starter config and the commented calendar list of “Config > Edit”, whose reloads are harmless.
Log buffer
LogBuffer is a tracing Layer that formats each event into a LogLine (timestamp, level, target and the full text with message and key=value fields), keeps the last N lines in a ring buffer and sends new lines through a tokio::sync::broadcast channel. subscribe() returns the backlog and a receiver under one lock, so a live log view has no gap or overlap; snapshot() is the export fallback when the log file cannot be read. init_with_buffer(level, file, capacity) installs the global logger of a front end: a LogFile (default_log_file("capycal-ui") is ~/Library/Logs/capycal/capycal-ui.log on macOS) plus the buffer, and returns both.
LogFile is the file writer (MakeWriter over a Mutex; tracing-appender only rotates by time). Opening it rotates a non-empty file away, so each session starts a fresh file; a write that would push the file over LOG_FILE_MAX_SIZE (10 MB) rotates it first, so an event is never split across files. Rotation shifts <file> to <file>.1, .1 to .2 and so on and deletes .5 (LOG_FILES_KEPT). The writer counts its rotations since start, so session_content() (the export of the menu bar app) concatenates <file>.<rotations> down to .1 and the current file: the whole unfiltered session, without earlier sessions. It reads under the lock, so no rotation happens in between. Parts deleted by the retention are replaced by a note in the first line. The CLI keeps its own stdout logging (pretty or JSON).
Menu bar app
capycal-ui is a driving adapter like the CLI: it only uses the library’s public API (Config, ConfigLock, Coordinator, LogBuffer, …). Its GUI crates (tao, tray-icon/muda, rfd, objc2-service-management, objc2-app-kit) are optional dependencies enabled by the ui feature and declared for macOS only, so cargo build never compiles them. On other platforms capycal-ui is a stub that exits with a message; its display-free modules still compile under cfg(test), so their tests run on Linux.
Threading:
main thread (AppKit) "capycal-sync" thread (tokio runtime)
tao event loop Backend::run
tray icon + muda menu --- Command ------> Config::load, ConfigLock, Coordinator
log window (AppKit) <-- Snapshot ----- status watch channel (throttled 300 ms)
rfd dialogs (EventLoopProxy user event)
- AppKit requires the event loop, the tray icon and the windows on the main thread. The tray icon is created on
StartCause::Init.ActivationPolicy::Accessory(andLSUIElementin the bundle) hides the Dock icon. - Menu clicks arrive through
MenuEvent::set_event_handler, forwarded as user events. Menu item ids are stable strings (menu::Action::id), so the menu can be rebuilt frommenu::build(snapshot, start_at_login, now)on every snapshot and every 30 seconds (for “synced N min ago”). - The app writes the starter config at start if the file is missing. A config without syncs is no
Problem; the menu shows it as not configured yet. - The backend owns config, lock and coordinator and handles
SyncNow,FullSync,Reload,EditConfig,CountPurge,Purge(which disables the purged syncs in the config afterwards),CountCalendar,PurgeCalendar(the same for all syncs writing into the calendar),SetEnabled, the Settings commands (DiscoverAccount,TestAccount,ListCalendarApp,SaveSettings) andShutdown. It answers withUpdates: aSnapshotafter every change,ConfigChangedafter every reload, andConfigReady,PurgePreview,PurgeProgress,CalendarCounted,CalendarPurgeProgress,CalendarPurged,AccountCalendars,AccountTested,CalendarAppCalendarsandSettingsSavedfor the commands that need an answer.ListCalendarAppcalls EventKit (the access prompt may block for minutes), so it runs inspawn_blocking; keychain and credential lookups do too; counting and purging run in the command loop, with anactivityline in the snapshot meanwhile. The confirmations stay on the main thread: Full Sync asks in a dialog before sending the command, Remove Synced Events opens its window before counting and enables Remove Events when thePurgePreviewarrives. A missing, invalid or locked config is aProblemin the snapshot, not a fatal error: the app keeps running and “Config > Reload” or a change of the config file retries. A failed reload leaves the running syncs and the lock’s sync names as they were. - Log window: a native AppKit window (no WebKit, which would start helper processes and grow the memory). It is split into a platform-independent model and thin platform glue, so a later GTK window (
GtkTextView) can reuse the model:logview::LogViewholds the received lines (at most the buffer capacity), the level and search filter and the follow state. Every operation returns aChange: reset the text, trim N UTF-16 units from the start, append runs of text with oneTone(error, warn, normal, dim) each, scroll to the end. New lines are only appended; a filter change or “Clear View” rebuilds. The view may grow to capacity plus a tenth before the oldest lines are trimmed, so trimming is not per line. Follow turns off when the view is scrolled up away from the end and on again when it is scrolled down to the end; resizing and trimming do not move the offset up, so they leave it alone.text_window_macos.rsholds what the windows share:TextWindowcreates the window with a toolbar row pinned to the top andNSTextView::scrollableTextViewfilling the rest (Auto Layout anchors), makes the text read-only and selectable, brings the window to the front (focus) and clears the delegate and closes the window when dropped. Each window keeps its owndefine_class!target and clears the targets of its controls in itsDrop, before theTextWindowis dropped.panel_window_macos.rs(PanelWindow) is a header, a row of buttons (reported asPanelInput::Button(index)through the sender’s tag) and a monospaced text;set_textkeeps the scroll position when the text is unchanged. “Problems” showsproblems::textwith Edit Config, Reload Config and Show Log, or an empty state (problems::nothing_to_show) without problems and recent warnings; the app refreshes it with the menu and opens it onUpdate::ReloadFailed(a config or lock error while loading). “Remove Synced Events” opens with “Counting synced events” and a disabled Remove Events button, sendsCommand::CountPurgeand fills inPurgePreview::confirmationwhen the preview arrives; a preview for a window closed in the meantime is ignored. The text view is the first responder, so Cmd-A and Cmd-C work through the Edit menu.log_window_macos.rsbuilds the log window on aTextWindow(toolbar row withNSSearchField, levelNSPopUpButton, “Follow” checkbox, “Clear View” and “Export” buttons; the search field takes the free width with a minimum of 240 pt) and appliesChanges to the text storage withNSAttributedStringruns (monospaced system font, semantic colors for dark mode). Onedefine_class!object (CapycalLogTarget) is the controls’ target, the observer ofNSViewBoundsDidChangeNotificationon the clip view and the window delegate. It only sends alogview::Inputas a user event; the event loop then reads the control values and calls the model, so nothing re-enters the window from an AppKit callback.LogBuffer::subscribe()gives the backlog and a broadcast receiver. While the window is open the event loop wakes every 250 ms and drains the receiver; lagging behind the broadcast inserts a “lines skipped” marker. Closing the window (windowWillClose:) drops it with its views and the receiver; AppKit holds the target only weakly, and the drop clears the targets, the delegate and the observer. Showing it again starts from the backlog. Showing the window activates the app withactivateIgnoringOtherAppsand makes the window key (the search field is the initial first responder): an accessory app is not activated by opening a window, and a window that is not key takes no text input.activate()is not enough, since it may decline under cooperative activation (macOS 14).
- Quit hides the icon and windows, sends
Shutdownand waits up to 35 seconds for the syncs to stop (longer than the coordinator waits for one sync). - Start at Login uses
SMAppService.mainAppinside an app bundle and falls back to a LaunchAgent plist (app.capycal-ui) otherwise or on error, logging which method was used.
Packaging lives in packaging/macos/: build-app.sh assembles Capycal.app (capycal-ui as CFBundleExecutable, the capycal CLI next to it, Info.plist with the calendar usage strings and LSUIElement) and signs it ad hoc; build-dmg.sh makes the release disk image.
Both apps share dialog::layout (margins, dialog width, text and log window sizes) and follow the platform guides for dialogs: the last button is the default unless View::destructive, which shows it in red and makes Cancel the default; dialogs have no window title, their heading says it; window titles name the menu item (“Log”, “Problems”, “Settings”). Errors on macOS use the app’s dialog (App::show_error), the GTK app uses adw::AlertDialog. A View::choice is a popup above the buttons (NSPopUpButton, gtk::DropDown); choosing an item reports it as button FIRST_CHOICE plus its index, like the links with FIRST_LINK, so the dialog models handle it in press. View::lines follow the message as “label: text” with a bold label (View::runs; an attributed string on macOS, Pango markup in GTK).
Linux desktop app
capycal-gtk (feature gtk, binary in src/bin/capycal-gtk/) is the same driving adapter with GTK 4 and libadwaita windows. It includes the display-free modules of capycal-ui with #[path] (args, backend, dialog, logview, menu, problems, purge, sponsor), so both apps share the menu model, icon state, Problems text, log view model and the dialog models; each uses only part of them, hence allow(dead_code) on those includes.
main thread (GLib main loop) "capycal-sync" thread (tokio) ksni thread
adw::Application (held, no main window) Backend::run StatusNotifierItem
windows, dialogs --- Command ------------> + dbusmenu over D-Bus
async_channel <-- Update -------------- menu click -> Action
async_channel <-- Action, host online/offline ----------------------------
- The tray is a StatusNotifierItem (
ksni, blocking API with its own runtime thread), Linux only.Tray::updatereplaces the menu items, icon state and tooltip; the D-Bus menu is updated in place, so an open menu stays open. A left click opens the menu (MENU_ON_ACTIVATE). The icon is a symbolic name permenu::Icon(capycal-tray-*-symbolic, SVGs with the GTKerrorand KDEColorScheme-*classes, so GNOME and Plasma draw them in the panel’s colors), withicons::cache_diras theme path when not installed; the app icon as ARGB pixmaps with a red or blue dot (tray_icon.rs) is the fallback.assume_sni_availablelets the app start before the panel; if no StatusNotifierWatcher appears within ten seconds, the app opensmenu_window.rs(themenu::builditems as libadwaita rows, rebuilt when they change) with a banner about the GNOME extension. The same window opens when the app is started a second time (GApplicationactivate) and, on macOS, at start. - The GApplication id
app.capycalmakes the app single-instance. Windows (log_window.rs,text_window.rs,settings_window.rs) report back through callbacks that run on the main loop;defermoves their work to an idle callback, so a window callback never re-enters a borrowedApp; Quit drops the windows (destroying them without close callbacks) before it waits for the syncs. The log window renderslogview::Changes into aGtkTextBufferwith tags (it converts the model’s UTF-16 trim counts to characters) and polls the broadcast receiver every 250 ms. - Dialogs are an
adw::Dialog(dialog_window.rs) built fromdialog::View, sinceadw::AlertDialogcloses on every response: Remove Synced Events, Remove All Events, Sponsor and the Full Sync question (dialog::full_sync_view, with the two circular arrows like the macOS app’sarrow.triangle.2.circlepath). Errors and “already running” areadw::AlertDialogs. About is anadw::Dialog(about_window.rs) laid out like the macOS About window, so the tagline (dialog::quip) and the links (dialog::about_links) are on its only page;adw::AboutDialogputs both on a Details page. - Icons: packages install them into
/usr/share/icons/hicolor; otherwiseicons::installwrites the app icon and the symbolic SVGs (heart, calendar with minus, the GitHub mark of the sponsor link) into the user cache and adds it to the icon theme search path. - Start at Login writes an XDG autostart entry (
autostart.rs); the sponsor answers are akey=valuefile (SponsorState::to_text). - The windows build on macOS too (Homebrew’s gtk4 and libadwaita), without the tray, for developing them on a Mac.
Sync algorithm
Each cycle is incremental unless a full sync is due (see Full sync):
- Fetch changes from the source (RFC 6578 sync-token, ctag/etag diff, EventKit query, or a feed’s conditional GET)
- Skip events that carry a Capycal marker (prevents sync loops)
- Process each event: skip a cancelled one (
transform::is_cancelled, master VEVENT only; the runner then removes an earlier copy like a filtered event), apply the transforms to the whole event, each only where its conditions match (a condition can look at a property that is not copied;add_alarmonly collects its alarm), filter fields (filter::STRUCTURAL_FIELDS,fieldsand the fields a matchingset_fieldset), add the collected alarms, stamp markers, assign a deterministic sink UID - Push to the sink, limited by the per-calendar semaphore. If any put fails, the cursor from before the fetch (
sync_token,ctag) is kept while the mappings of the successful puts are saved, so the next cycle fetches the batch again: a change log source reports the whole batch again (the events already put are updated in place through their mapping), a ctag/etag source diffs against the mappings and reports only the failed event. An event the sink keeps rejecting is fetched again every cycle until its put succeeds; it is counted inCycleStats.errorseach time and does not block other events - Delete sink events whose source event was removed (the mapping is dropped only after a successful delete). Before the state changes,
defer_removalsstops a cycle that removes events when at least 5 are synced and the source now has less than half as many (synced minus removed plus new events; a full sync counts the fetched events) and setsSyncState::removals_deferred(a full sync checks the rebuilt mapping against the listing the same way and stays pending). The cycle succeeds withCycleStats::deferred; the next one that finds the events gone again removes them, so a source that is briefly empty without an error (Calendar.app loading at login) removes nothing, while a cleared calendar loses its copies one cycle later. The CalDAV client follows truncated sync-collection results (507) and rejects answers that are no multistatus. Failed deletes are kept inpending_removalsand retried in the next cycle, because RFC 6578 sources report a removal only once - Persist state atomically (write to a temp file, then rename)
Between cycles SyncRunner::run waits interval. After a cycle that failed or counted CycleStats.errors it waits the next step of ERROR_RETRY (1, 2, 5, then 15 minutes, at most interval) instead, so an outage longer than the CalDAV client’s own retries does not wait a whole interval; a successful cycle starts over. A cancelled cycle changes nothing.
Cancellation
Shutdown (also the stop before a reload or a purge) cancels a running cycle, incremental or full, instead of waiting for it, since a CalDAV request in retry backoff can take over 30 seconds. The runner watches its shutdown receiver at these points and ends the cycle with Error::Cancelled, which leaves the last reported result unchanged:
- Fetch: the source runs against a copy of
SyncStatethat replaces the state only on success. A cancelled (or failed) fetch is dropped with any request or backoff sleep in it and leaves the state exactly as before; nothing was written to the sink yet. - Waiting for a sink permit and between deletes: no new put or delete starts.
- In-flight puts: aborted only if
EventSink::cancel_safe()is true. CalDAV is: the sink href is derived from the sink UID (or taken from the mapping), so a put whose result was lost is repeated to the same resource. EventKit is not: a newEKEventgets its identifier from EventKit on save, and a put dropped before its mapping is recorded would create a duplicate next cycle. Its puts are local and have no retry waits, so the runner simply lets them finish. - In-flight deletes: always cancellable. Deletes are idempotent and the mapping is dropped only after success.
After a cancellation during the push phase the runner keeps the mappings of the completed puts, adds every delete that did not complete to pending_removals, restores the cursor from before the fetch (sync_token, ctag) and saves the state. The next cycle therefore fetches the unprocessed changes again: a change log source reports them again from the old token, a ctag/etag source sees the old ctag differ and diffs the etags against the mappings. Events that were already put are updated in place through their mapping.
The coordinator waits up to 30 seconds (SHUTDOWN_TIMEOUT) for a stopped sync’s task and aborts it after that, so a hung task cannot save state while a reload’s new runner or a purge works on the same sync. A panic inside a runner is caught (CatchUnwind), logged and reported as the sync’s last result, so the sync is not shown as running forever.
Feed specifics
IcsSource::fetch_changessends one GET withIf-None-MatchorIf-Modified-Sincefrom the validator it keeps inSyncState::ctag(etag:ormodified:prefix). 304 means no changes; an empty state (full sync) never sends a validator.split_feedunfolds the body, rejects one withoutBEGIN:VCALENDAR(an HTML sign-in page), and groups the VEVENTs by UID, so a series and its overrides become one source event with the VTIMEZONEs its TZIDs name. The UID is the href. The etag is a v5 UUID of the content withoutDTSTAMPandUIDlines, since feeds such as Google’s stamp every event with the request time. Some feeds are generated on each request with new random UIDs:keep_uidsgives an event whose UID is not in the mapping the UID (href andUIDline) of a mapped event that is gone from the feed and had the same etag, in href order for identical events, so it counts as unchanged.EtagDiffagainst the mapping then gives changed and removed events. An event of such a feed whose content changed becomes a new copy plus a removal, and a full sync (empty mapping) replaces their copies once. Events without UID are skipped.- The address is a
Credential(CalendarConfig::ics_url), resolved inbuild_source. It is never logged: errors usereqwest::Error::without_urland name the calendar key,{{source_url}}isics://feed, andSyncConfig::output_keyonly hashes it. Auser:password@in the address becomes Basic auth (reqwest).webcal://is HTTPS. - No retry loop: a failed GET fails the cycle, and the runner retries it after
ERROR_RETRY(see Sync algorithm).
EventKit specifics
- Apple limits EventKit predicate queries to about 4 years.
eventkit_all_eventsuses chunked queries, each inside its ownautoreleasepool. The source’sfetch_changesruns the whole query inspawn_blockinginside anautoreleasepool, so it does not stall the tokio workers of other syncs and a shutdown can drop the cycle while it waits. - As a source, recurring events are synced as individual occurrences without RRULE. When a single occurrence is deleted, EventKit simply stops returning it; an RRULE in the target would bring it back.
- As a sink, a recurring event becomes a native series:
convert::parse_seriesturns the RRULE into aRecurrence(anEKRecurrenceRulein its terms), EXDATEs and cancelled overrides into occurrences to remove and the other overrides into occurrences to change.client.rssaves the master with the rule, thenapply_exceptionsfinds each occurrence by itsoccurrenceDateand removes or changes it withEKSpan::ThisEvent. A put rewrites an existing series as a whole (deleted withEKSpan::FutureEvents, saved new), because EventKit keeps the exceptions of an existing series. Deletes and purge remove whole series. A full sync never skips a series (StoredEvent::recurring). Rules EventKit can’t represent are logged and written as their first occurrence; RDATE is left out. - Occurrence identity is
{calendarItemExternalIdentifier}_{start rounded to the minute}. - Timed events keep their timezone (
DTSTART;TZID=) so local times survive DST changes. NSTimeZone only knows IANA names, soparse_icalandparse_seriesfirst runeventkit/tzid.rs(resolve_timezones): Windows names map to IANA zones, prefixed IANA names lose the prefix, other names defined by a VTIMEZONE become UTC values computed from its observances.ical_to_nsdatetreats floating times and still unknown names as local time.apply_updatealso setsEKEvent.timeZone(ical_time_zone), without which Calendar.app keeps a timed event at the same wall-clock time in every zone;convert::is_unchangedtreats a stored timed event without a zone (StoredEvent::floating) as changed, so a full sync fixes older copies. All-day events use local dates. EventKit ends an all-day event at the last second of its last day, iCal at the following midnight:apply_updatewrites the end one second before the iCal end, andall_day_endreads one second after the stored end, which gives the following day for both conventions. - Alarms map to
VALARM(relative or absoluteTRIGGER), availability “free” maps toTRANSP:TRANSPARENT.STATUSis read-only in EventKit. - capycal-created events are recognised by a
[capycal:sync=...;source=...;uid=...]line in the notes (or the legacy[capycal:<source>]), because EventKit does not keep custom iCal properties. See Markers. - Everything except the ObjC calls lives in
eventkit/convert.rsand is unit tested on Linux.
UI and UX guidelines
These rules hold for the desktop apps: the macOS menu bar app (capycal-ui) and the Linux app (capycal-gtk). Every change to a window follows them; a change that needs a new pattern adds it here first. /ui-review checks a change against this page, and the tests in settings_view::tests::ui_rules catch the rules a machine can check.
Principles
- The model decides, the renderers draw. Logic, texts and states live in the display-free models (
settings_view.rs,sync_form.rs,transform_form.rs,menu.rs,dialog.rs, …) with unit tests.settings_window_macos.rsand the GTK windows only draw a view and report input. - Both platforms, always. A control, a text or a behaviour added to one renderer is added to the other in the same change. Where a platform lacks something (GTK cannot grey out dropdown items), the model copes, not the user.
- Calm by default. Nothing blinks, nothing is red. Attention goes to the one thing that needs it: the accent marks what is chosen or active, orange marks a problem.
- One way to do a thing. A task has one entry point (a sync is added through the assistant, which has a way out to the editor). Similar tasks start from what exists (Duplicate), not from an empty form.
- Show the effect. The preview shows what a sync does to an event, the header says how the sync is doing. Prefer showing over explaining.
Layout
- Spacing scale, on a 4 point grid:
GAP_S4,GAP_M8,GAP_L12,GAP_XL20 (settings_window_macos.rs; GTK uses the same values in pixels). No other gaps. - Labels sit left of their control in a column of
LABEL_WIDTH, left-aligned. A row of short fields (Control::Row) puts each field’s label after it (“30 days back”). - Boxes: a sync is a pipeline of steps (
Stage: From, Keep, one per transform, Into), each a rounded box without border, joined by aJointwhose “+” adds a step there. Other content goes inGroupboxes. Header, groups and the Advanced pill line up with the steps (the gutter column on the right holds only drag handles). - Corners follow where a control stands: what stands on its own line or ends a part of the window is a pill, like the buttons of a dialog: the footer buttons, a row of buttons (
Control::Buttons, e.g. Add Condition, Test Connection), the and / or switch, Sample / Next event above the preview, the Advanced pill (macOSNSControlSize::Large, GTKcapycal-round/capycal-join). What sits in a form row next to a field keeps the field’s corners: popups, text fields, the buttons beside a popup, segments with a label (Check every, Keep). Boxes (steps, groups, cards) keep their own rounded corners. - The footer is the save bar: Delete on the left; on the right a text, Revert and Save (the default button). The text says what is unsaved, what is saving, or what keeps Save off. Its buttons sit centered between the hairline above and the window’s edge (
MARGINon macOS, 12 px in GTK); the content scrolls up to the hairline. - No Liquid Glass of our own. macOS 27 puts glass only on toolbar controls; sidebars are full height again (the floating glass sidebar was macOS 26), sheet footers are flat with a hairline, and buttons in content are plain bezels with the default one filled in the accent color. Settings has no toolbar, so it uses no
NSGlassEffectView: no floating sidebar, no glass capsule around the footer, no glass bezels. A control AppKit draws in glass by itself stays as it comes. Check how macOS’s own apps draw a pattern before adopting one. - The aside holds the preview, a column of its own.
- Sidebar rows are source-list rows: icon, name, a dot for unsaved changes. “Add …” rows have an accent “+”. The app’s version sits small and in secondary text at the bottom of the sidebar, level with the footer.
- Settings is the one place to set things. The menu bar menu holds actions (Sync Now, Settings…, About, Quit), not settings, not even switching a sync off; repairs are in Settings too, next to what they repair (a sync’s Advanced settings > Troubleshooting); the app’s own settings, the config file and the log are under General at the top of the Settings sidebar.
Color
| Use | macOS | GTK |
|---|---|---|
| Chosen, active, the “+” | controlAccentColor | @accent_bg_color / @accent_color |
| A problem (warning sign, sidebar icon) | systemOrangeColor | #e66100 |
| Secondary text (notes, captions, status) | secondaryLabelColor | dim-label |
| Inactive (disabled sync, chevrons) | systemGrayColor, tertiaryLabelColor | alpha(@window_fg_color, …) |
- No red, not even for Delete: the question before deleting is the warning. Delete still reports a destructive action to accessibility (
setHasDestructiveAction). - A problem’s text is in the normal text color, after an orange warning sign. Never colored text.
- Account and sync icons use the accent alike.
Text
- Buttons and menu items use title case and say what happens (“Set Up in Editor”, “Move Down”). One that opens a window or asks first ends with “…” (“Add Sync…”).
- Everything else uses sentence case: labels, notes, status lines, errors.
- A menu of things to add says what each does in a second, secondary line under its title (
PopupItem::hint: macOSNSMenuItem.subtitle, a tooltip before macOS 14.4; GTK a dim caption label and the accessible description). Keep it to one line, about 50 characters, starting with a verb (“Leaves out events on Saturday and Sunday”). - Ellipsis is “…” (U+2026), never three dots, also for progress (“Saving…”, “Syncing now…”).
- No dashes as punctuation: no em or en dash, no “–”. Use a colon, a comma or a new sentence.
- Errors say what to do: “Set field (step 1): fill in Field and Value”, “Use a duration such as 15m or 1h 30m”. Name the field by its label. No “invalid”, no “error:” prefix, no blame.
- Name things by what the user sees: “Field” not “property”, “target” in user text (the code says “sink”).
- A placeholder in a summary reads as “…” or “a field” (“Set a field to …”), never as empty quotes.
Feedback and errors
- A field’s mistake shows below the field, with the warning sign, once the user leaves the field (
SettingsInput::Left), not while typing. - What keeps Save off shows in the footer, next to Save, with the warning sign. Save stays disabled meanwhile.
- Long work shows its progress where it was started: what is happening and how far (“Removing the synced events of “work”… 40 of 120“) with a bar, counted first so the bar has an end. A save that removes copies shows it in the footer, so does a full sync of the shown sync (after a save that changed what it copies, or Full Sync Now), the Remove Synced Events dialog in its window.
- A saved sync’s status line under its name says how it is doing (“Synced 2 min ago: 3 new, 1 removed”); a failed sync shows the warning sign and turns its sidebar icon orange.
- Long texts are cut off with “…” and are complete in the tooltip.
Interaction
- Undo over questions. Small, reversible changes (adding, moving, removing a step) happen at once and Cmd+Z takes them back. Ask only before data is lost (deleting a sync, discarding a draft, removing copies).
- Deleting what Capycal did not create takes two steps: a warning in an orange box (
dialog::View::warning, bold, with the warning sign) saying what else goes, then a second warning where the user types the name shown in Settings (View::entry) before the destructive button enables (View::last_disabled). Remove All Events is the only such action. - Keyboard: Cmd+S saves, Cmd+N adds a sync, Cmd+Delete deletes, Cmd+Z undoes (outside a field), Cmd+Option+Up / Down moves the open step; Ctrl instead of Cmd on Linux. Each only while its button is enabled. Esc or a click on the background ends typing in a field. Tab follows the order on screen.
- Nothing only by mouse: what a drag does, a context menu does too (Move Up, Move Down).
- Suggestions, not restrictions: a field with common values offers them (field names) and takes any other; field names are upper case as they are stored.
- An assistant opens over its window, not in place of it: the new-sync wizard is a sheet on Settings (macOS) or a modal window (GTK), so what was shown stays in view and Cancel returns to it (
SettingsScreen::wizard_view). - Defaults that work: presets first (interval 1 / 5 / 15 min, what to copy), “Custom” for the rest. Common steps come filled in. An assistant step asks one thing; a page says that everything can be changed later.
- The way out comes with the problem: a problem of the whole config shows at the top of General (Needs attention, orange General entry) with the one button that fixes it; Settings opens even for a file it cannot read, with only General, so the way to fix it is never closed.
- Fixes belong to what they fix: an action on one sync (full sync, removing its copies) sits on its page under Advanced settings > Troubleshooting, not only in a menu with a picker.
- Something that is not listed is added where it is chosen: a choice menu ends with an action for what it cannot list (“Add Account…”, “Add Calendar Feed…”), and what that needs appears right below it as a field (Feed address), not in another window. A secret the config keeps elsewhere (
env,keychain) shows as a disabled field saying so. - Conditions read as a sentence down the form: below a step’s own fields, each condition is a popup of what it looks at labelled Only if (If for Skip events), then if, with Move Up and Move Down (arrows, where there is a neighbour) and Remove as its buttons, then the test popup and only the inputs that test needs (text, weekday checkboxes, From / Until); Add Condition ends the list. A step that needs a condition starts with one and cannot lose its last.
- A join is a switch between the parts it joins: between two conditions an and / or
Segmentscontrol without a label sits centered on its own line (centered). Conditions joined by or share a thin outlined box (Control::AnyOf, caption One of these) inside the step, so the group shows by its frame and its words, not by color. Each condition is a card (Control::Condition, a light fill, no border) with a drag handle; the model places a drop mark (Control::DropMark) at each place it can land, two at the edge of a box (inside at its top or bottom, and outside), and the renderers snap to the mark nearest the pointer, none where the dragged one would stay as it is. Dropped inside a box it joins it with or, outside with and; its Move Up / Move Down buttons, small arrows with their titles as tooltip and accessibility label so moving works without a mouse, swap it with its neighbour and leave the joins where they are. A condition drag carries a different value type than a step drag (GTKu64vsu32), so neither lands in the other’s target. The join switch is a pill like the dialog buttons (macOSNSControlSize::Large, GTKcapycal-join). A rebuild keeps the scroll position of the same entry (GTK drops the focus first, else it scrolls to the first field). An update in place leaves a focused field alone, except after a move (SettingsView::moveschanged): the field then shows another condition and takes its text. - Tags on a list row (“the source”, “read-only”) are small pills in secondary text, centered on the row.
- The window remembers its size and the entry shown.
- A menu bar app with a window behaves like an app: on macOS it shows in the Dock, in Cmd+Tab and with its own menu bar (app menu with Settings… and Quit, Edit, Window) while any of its windows is open, and leaves the Dock with the last one. Cmd+W closes, Cmd+Q quits (Ctrl on Linux), asking only about unsaved changes.
- Ask for permissions in context: say why before the system asks (Calendar.app access). Before a Keychain prompt (
keychain_notice.rs), check silently whether one will come and only then explain what Capycal reads, what stays closed and which button keeps the system from asking again.
Motion
- Motion answers an action and shows what changed: a chevron turns (0.2 s, ease in and out), opened fields fade in, the worked-in step gets a ring. Nothing moves by itself.
- With “Reduce motion” on (macOS), changes happen without animation.
- A change of state never changes the size of what is next to it (fixed boxes for icons that change).
Accessibility
- Every control without a title has an accessibility label that says what it is and what it is about (“Remove step 2”, “Step 2: Add buffer: 15 min before, closed”).
- Information is never only color: a problem has a sign and words, a draft a dot and the footer text.
Checking a change
- The model change has unit tests;
ui_rulespasses (make test-ui,make test-gtk). - Both renderers draw it: macOS type-checks (
clippy --target aarch64-apple-darwin --features ui), GTK builds and passes clippy. - Look at it: screenshots of the macOS app (in the VM) and the GTK app (under Xvfb), light and dark, including a problem state and the keyboard path.
- The user docs (
app/, and theIn the applines inconcepts/andcookbook/) describe what changed.
Releasing
Releases are built by GitHub Actions (.github/workflows/release.yml) when a tag v* is pushed.
-
Set
versioninCargo.tomland updateCargo.lock(cargo build). -
Commit, then tag and push. The tag must be
vplus the version fromCargo.toml; the workflow fails otherwise.git tag v0.2.0 git push origin main v0.2.0
The workflow:
- builds and tests the CLI for
aarch64-apple-darwin,x86_64-unknown-linux-muslandaarch64-unknown-linux-muslon native runners, and packs each intocapycal-v<version>-<target>.tar.gz(binary,README.md,LICENSE,CONTRIBUTORS.md,THIRD-PARTY-NOTICES.txt,capycal.example.toml); - builds
Capycal.appandcapycal-v<version>-macos.dmg; - writes a
.sha256file per archive; - publishes a GitHub release with generated notes.
A manual run (workflow_dispatch) builds and uploads the artifacts without publishing a release.
Other workflows
| Workflow | Trigger | Does |
|---|---|---|
ci.yml | push to main, pull requests | fmt, clippy and tests on Linux and macOS, with and without ui |
docs.yml | push to main or pull request changing docs/, manual | builds this book; on main it asks twiebe/capycal-web to publish it on capycal.app/docs |
Licensing
Capycal is licensed under the GPL-3.0-only (LICENSE), while contributions come in under the MIT License (CONTRIBUTING.md, confirmed in the pull request template). This page explains why, so the setup is not changed by accident.
Why the GPL
App store terms (Apple’s in particular) are widely read as incompatible with the GPL; the FSF holds this view, and VLC was removed from the App Store over it in 2011. So nobody but the copyright holder can ship a copy of Capycal to an app store. The maintainer, who owns the copyright to their own code, can. No court has decided this, so it is a practical protection, not a legal guarantee; clones have to be found and reported to the store.
Why contributions are MIT
A contributor keeps the copyright to their code and only licenses it. Code contributed under the GPL could not go into an app store build without breaking the contributor’s license: they could demand it be taken down and claim damages. Under the MIT License the maintainer may ship it anywhere, including app stores and a later change of the project’s license.
Under German copyright law an author has a claim to fair remuneration (§ 32 UrhG) and, for a big success, to more afterwards (§ 32a UrhG). Both claims do not exist when the author grants a simple right of use to everyone free of charge (§ 32 Abs. 3 Satz 3, § 32a Abs. 3 Satz 3, the “Linux clause”). The MIT License is such a grant, so a paid build owes contributors nothing.
| How a contribution comes in | Remuneration claim | In an app store build | Later relicensing |
|---|---|---|---|
| GPL (a pull request merged as is) | no | no | no |
| MIT (the project’s way) | no | yes | yes |
| A CLA granting rights to the maintainer only | possible | yes | yes |
BSD, ISC, 0BSD and Apache-2.0 would work like MIT here, they also grant rights to everyone for free. They differ only in their duties (notices, Apache’s NOTICE file and patent grant). MIT keeps it simplest.
Rules that follow
- Every contribution needs the MIT confirmation of the pull request template and a line in
CONTRIBUTORS.md, whose notices every package ships. - No CLA that grants rights only to the maintainer: it would bring back the remuneration claim.
- Contributed portions can be reused by anyone under MIT. That is fine as long as most of Capycal is the maintainer’s GPL code; a clone of the app still needs it.
- No GPL or LGPL crate may be compiled into the binaries, or the app store build breaks.
about.tomllists the accepted licenses andmake check-notices(run by CI) rejects any other; decide before adding one. GTK and libadwaita are system libraries of the Linux app only.
This is a reading of the law, not legal advice; have the wording of CONTRIBUTING.md checked before the first store release.