Repository navigation
Khal Integration
Every event the shell shows — in the Timetable, the Calendar desktop widgets, At a Glance, the dock, the lock screen — comes from khal. The shell does not embed a CalDAV or Google client for events; it reads and writes plain .ics files through two standard tools:
Google Calendar / CalDAV / ICS URL ⇄ vdirsyncer ⇄ .ics files on disk ⇄ khal ⇄ ii shell
(remote) (2-way sync) (the truth) (index + CLI) (Timetable)
-
khal keeps an SQLite index of a set of local calendar directories (
~/.config/khal/config) and answerskhal list. The shell reads that, and edits the same files. - vdirsyncer synchronizes those local directories with the remote service. It is optional: with only khal and a local calendar, the Timetable is a fully working offline calendar.
Everything is opt-in and runs as your tools with your config; the shell never rewrites your khal / vdirsyncer configuration except for two clearly marked blocks it owns (subscribed ICS links) and the optional per-calendar colour line.
khal and vdirsyncer are the shell's optional calendar feature. The easiest way is the shell's own installer — ii-p3drovfx deps add calendar (or the Optional features table in Settings → About & Updates), which installs khal, vdirsyncer and the icalendar Python module with your package manager (Installer and CLI). Or install them by hand:
| Distro | Command |
|---|---|
| Fedora / RHEL | sudo dnf install khal vdirsyncer |
| Arch | sudo pacman -S khal vdirsyncer |
| Debian / Ubuntu | sudo apt install khal vdirsyncer |
| openSUSE | sudo zypper install khal vdirsyncer |
| NixOS | nix-env -iA nixos.khal nixos.vdirsyncer |
| Gentoo | sudo emerge --ask app-misc/khal net-misc/vdirsyncer |
| Void | sudo xbps-install -S khal vdirsyncer |
| Alpine | sudo apk add khal vdirsyncer |
The shell's helper (scripts/calendar/ics.py) imports khal, icalendar, dateutil and configobj from the system python3, which is how khal itself is installed, so no extra package is needed. The Welcome app's Set up Google Calendar tutorial detects your distribution and shows the right command, then walks through the rest of this page with copyable snippets and live "complete / pending" states.
How the shell knows khal is usable: it runs khal list today at startup; exit code 0 means khalAvailable. Press recheck in the Timetable's setup card after installing (or restart the shell). The paths that matter are then read from khal's own config — the SQLite database and every calendar directory — so deletion and editing work wherever your calendars live.
Google Calendar needs an OAuth Desktop client. The shell uses one client for Gmail, Google Tasks, Google Drive and Calendar — create it once, following Google Cloud Setup — enable the Google Calendar API, and click Publish App on the consent screen. This last point is important: while the app is in Testing mode Google expires refresh tokens after 7 days, and your calendar would silently stop syncing every week.
Put the client ID and secret in ~/.config/quickshell/ii/.env as GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET (shared with the other Google features) and copy the same pair into vdirsyncer — do not create a second OAuth client.
~/.config/vdirsyncer/config:
[general]
status_path = "~/.local/share/vdirsyncer/status/"
[pair google_calendar]
a = "google_calendar_local"
b = "google_calendar_remote"
collections = ["from a", "from b"]
conflict_resolution = "b wins"
[storage google_calendar_local]
type = "filesystem"
path = "~/.local/share/calendars/"
fileext = ".ics"
[storage google_calendar_remote]
type = "google_calendar"
token_file = "~/.local/share/vdirsyncer/google_token"
client_id = "YOUR_CLIENT_ID"
client_secret = "YOUR_CLIENT_SECRET"Then, once:
vdirsyncer discover google_calendar # opens the browser to authorize; creates one directory per calendar
vdirsyncer syncFor other servers use the matching storage type (caldav, http …) — the shell does not care what is on the other side of vdirsyncer.
khal configure # or write ~/.config/khal/config by handPoint a [calendars] entry at the directory vdirsyncer fills, for example:
[calendars]
[[personal]]
path = ~/.local/share/calendars/*
type = discover
[default]
default_calendar = PersonalOnce khal list today works in a terminal, events appear in the shell. The shell asks khal for the list of calendars, which of them are writable, and khal's own default.
CalendarService runs a single khal list --json … over a date window (title, dates, times, description, calendar, UID, URL, location, categories, repeat symbol, status, organizer, all-day, calendar colour). The window starts around the current month and grows — with three months of slack in the direction you navigate — whenever you move beyond it, debounced by 150 ms, and never shrinks. The result is indexed once per day (eventsByDay); every view consumes that index.
All changes go through scripts/calendar/ics.py, a long-lived bridge that speaks JSON Lines on stdin — no event field is ever put into a shell command. A queue serializes requests because khal owns one SQLite index.
| Case | How it is written |
|---|---|
| New event |
khal import -a <calendar> --batch -, so khal chooses the file name and indexes it |
| Change to an existing event | The stored .ics is rewritten in place (temp file + atomic rename) with only the changed fields |
| Move between calendars |
khal import into the destination |
| Recurrence |
EXDATE, override occurrence, truncated RRULE or split series, depending on the scope you picked |
Two details explain why "edit by import" is never used for existing events. First, khal import names files after the UID, but a vdirsyncer collection uses random file names (0a7df412-….ics) that do not match Google's UID — importing would leave the original file and create a second one, so the event would exist twice. Second, khal's cache key for a collection is the directory's modification time; rewriting a file without replacing it does not change that, so khal would keep serving the old value. An atomic replace fixes both: it keeps the file name and bumps the directory mtime so khal re-indexes.
A newly created event is shown optimistically: the service generates a UID (ii-<time>-<random>@local), puts a transient event in the views immediately, and the canonical event from khal replaces it on the next list. If the write fails, only that transient event is removed.
After a write is confirmed locally, the shell runs vdirsyncer — only for the calendar that changed. scripts/calendar/vdirsyncer_sync.py maps the calendar's directory (from khal's config) to the filesystem storage of a vdirsyncer pair and runs vdirsyncer sync <pair>/<collection>; if it cannot find the mapping it falls back to a global vdirsyncer sync. This matters: a global sync stops at the first failing collection, and Google exposes some read-only collections — a read-only collection would then block the sync of the calendar that actually got your event. Requests are queued per collection, requests that arrive during a sync are kept, and the last status or error is shown in the Timetable's status card (and stays in CalendarService.lastCalendarSyncError).
Besides syncing after edits, the service reloads events and syncs every writable calendar at startup and then every 15 minutes. Run it by hand any time:
qs -c ii ipc call calendar syncGoogle collections whose name ends in @virtual are projected as read-only: the API lists them but answers every write with HTTP 403, so they are never offered in the "create in" picker.
New events go to, in order: calendar.timetable.defaultCalendar (set automatically to the last calendar you successfully created an event in), khal's own default_calendar, then the first writable calendar. A write failure removes only the transient event, never your stored preference.
Settings in the Cheat Sheet → Calendar colors edits a color = line inside the matching section of khal's config, with a value from a curated list of khal ANSI names; the helper validates the value, refuses read-only calendars, and touches nothing else in the file (it uses ConfigObj, which keeps your comments).
Calendar sources → Add URL mirrors a public ICS feed (a national holiday calendar, a team schedule, a sports season) as a read-only calendar. scripts/calendar/subscriptions.py adds a block between II TIMETABLE SUBSCRIPTIONS markers to both configs — a vdirsyncer pair from an http storage to a filesystem storage with partial_sync = "revert", and a khal [[…]] section with readonly = True. Both files are fully rendered before either is written, each write is fsync + os.replace, and anything outside the markers is untouched. Only http(s) URLs are accepted. Removing a link removes its block.
The Outlook mirror uses the same mechanism but has no remote vdirsyncer pair: its files are written directly by scripts/outlook/calendar_sync.py into a local collection registered read-only — see Calendar and Timetable.
When a sync fails with invalid_grant, expired or revoked, token_refresh_failed, scope has changed or unauthorized, CalendarService sets googleAuthRequired. The Timetable then shows a banner (sync_problem icon, error colour) with a Reconnect Google button, and the status card in the settings reads Auth required with the same button. No terminal is needed.
The button runs scripts/calendar/vdirsyncer_reauth.py, which:
- reads
client_id,client_secretandtoken_filefrom your vdirsyncer config (falling back toii/.env), - starts a loopback server on port 42072 and opens your browser (
xdg-open) with a PKCE authorization request, - exchanges the code and writes the new token JSON atomically to vdirsyncer's
token_filewith mode0600, - then resyncs all writable calendars.
It asks for exactly the scope vdirsyncer itself requests (https://www.googleapis.com/auth/calendar). That is deliberate: Google answers every refresh with the scope set of the original grant, and the OAuth library aborts the refresh (Warning: Scope has changed) when it differs from what vdirsyncer asked for. A helper that requested extra scopes would work for one hour — the lifetime of the first access token — and then fail on every sync. For tokens created by older versions of the helper, syncs started by the shell run with OAUTHLIB_RELAX_TOKEN_SCOPE=1; syncs started outside the shell (a systemd timer, a terminal) with such an old token keep failing until you reconnect once with the current helper.
| Symptom | Likely cause |
|---|---|
| "khal is not available" / "No calendar backend" |
khal list today fails — run it in a terminal and fix the config; then recheck |
| Events appear only after a long time or not at all |
vdirsyncer sync was never run; run discover once, then sync, or use qs -c ii ipc call calendar sync
|
| Banner "Google Calendar token expired or was revoked" | The OAuth app is in Testing (tokens last 7 days) — publish it, then use Reconnect Google |
| New event shows briefly then vanishes | The write failed (read-only or @virtual calendar, or khal config unreadable); check the log for [CalendarService]
|
| Duplicated events after editing | A very old version imported instead of rewriting in place; remove the extra file in the calendar directory |
| vdirsyncer reports a duplicate or malformed local file | Back up the exact collection first, then use vdirsyncer repair; do not use --repair-unsafe-uid without auditing the UIDs it would change |
| Colours from Google missing | They are not in the .ics; enable Show Google event colors (Calendar API authorization) |
Logs: [CalendarService] lines in the Quickshell log (qs log -c ii), and vdirsyncer output is carried into the status card.
| Piece | Role |
|---|---|
services/CalendarService.qml |
khal availability, reading, the mutation queue, vdirsyncer queue, optimistic events, googleAuthRequired
|
scripts/calendar/ics.py |
UID-addressed mutations; also lists calendars, khal's default and the writable flag |
scripts/calendar/vdirsyncer_sync.py |
khal path → pair/collection mapping and the targeted sync |
scripts/calendar/vdirsyncer_reauth.py |
PKCE re-authorization into vdirsyncer's token file |
scripts/calendar/subscriptions.py |
The managed config blocks |
modules/common/widgets/GoogleCalendarSetupGuide.qml |
The guide shown in the sources rail and the Timetable settings |
modules/welcome/tutorials/WelcomeCalendarTutorial.qml |
The Welcome tutorial |
scripts/calendar/tests/ |
Offline tests: isolated khal scenario for create / edit / move / delete with property preservation, sync mapping, re-auth token handling, subscriptions |
- Calendar and Timetable — what you can do with it
- Google Cloud Setup — the OAuth client
- Getting Started
- System Integration