#!/bin/bash # ============================================================ # caldav-sync.sh — CalDAV calendar sync stack # ============================================================ # Sets up a full offline-capable CalDAV synchronisation stack: # # vdirsyncer — syncs CalDAV server ↔ local ICS files # khal — CLI/TUI calendar viewer and editor # python-icalendar — Python library for parsing .ics files # ics-to-calendarim — custom script that converts ICS files # into the JSON cache format expected by # the calendar.vim Neovim plugin # # A systemd user timer fires every 15 minutes to keep local # calendars current without any manual intervention. # # This is an optional module because CalDAV sync is only useful # if the user has a remote calendar server (Nextcloud, Radicale, # Google Calendar export, etc.). # ============================================================ set -euo pipefail # Load shared logging helpers from the dotfiles lib source "$(dirname "${BASH_SOURCE[0]}")/../../lib/logging.sh" # ── Core packages ───────────────────────────────────────────────────────────── # vdirsyncer : the CalDAV/CardDAV sync daemon that mirrors remote # calendars to local Maildir-style .ics files # khal : a CLI/TUI calendar reader/editor that reads the local # ICS files and presents them in a human-friendly view # python-icalendar : Python library needed by the ics-to-calendarim helper # to parse VEVENT components from .ics files log "Installing CalDAV sync stack..." sudo pacman -S --noconfirm --needed vdirsyncer khal python-icalendar # ── Credentials ─────────────────────────────────────────────────────────────── # Collect the minimum information needed to configure vdirsyncer. # -r = raw input (no backslash escaping), -p = prompt, -s = silent (password) read -rp "CalDAV server URL (e.g. https://dav.example.com/cal/): " CALDAV_URL read -rp "Username : " CALDAV_USER read -rsp "Password : " CALDAV_PASS; echo read -rp "Calendar display name [Personal] : " CAL_NAME # Default to "Personal" if the user pressed Enter without typing a name CAL_NAME="${CAL_NAME:-Personal}" # Local directory where vdirsyncer will store the .ics files, one sub- # directory per calendar collection discovered on the server. CALDAV_DIR="$HOME/.local/share/calendars" mkdir -p "$CALDAV_DIR" # ── vdirsyncer configuration ────────────────────────────────────────────────── # vdirsyncer reads a single INI-style config file. # We create the status directory first (vdirsyncer stores sync state there # to track what has been pushed/pulled for each item). log "Writing ~/.config/vdirsyncer/config..." mkdir -p ~/.config/vdirsyncer ~/.local/share/vdirsyncer/status # The config defines: # [general] — global options (where to store sync state) # [pair calendars] — a named pair that links local ↔ remote storage # [storage *] — individual storage backends (filesystem + caldav) # "collections = ["from b"]" means: discover all collections that exist on # the remote (b) side and create matching local directories automatically. cat > ~/.config/vdirsyncer/config << EOF [general] status_path = "$HOME/.local/share/vdirsyncer/status/" [pair calendars] a = "local_cals" b = "remote_cals" collections = ["from b"] metadata = ["color", "displayname"] [storage local_cals] type = "filesystem" path = "$CALDAV_DIR" fileext = ".ics" [storage remote_cals] type = "caldav" url = "$CALDAV_URL" username = "$CALDAV_USER" password = "$CALDAV_PASS" EOF # Restrict permissions: config contains plaintext password chmod 600 ~/.config/vdirsyncer/config # "discover" queries the server for all available collections and records them # in the status directory so future `vdirsyncer sync` commands know what to sync. # `yes |` pre-answers any interactive "create local directory?" prompts. # `|| true` prevents the script from aborting if discovery finds nothing new. log "Discovering CalDAV collections (confirm any prompts with y)..." yes | vdirsyncer discover calendars || true # Pull all events from the server for the first time. log "Running initial sync..." vdirsyncer sync # ── khal configuration ──────────────────────────────────────────────────────── # khal needs one [[calendar]] section per local ICS directory. # We use an inline Python script so we can iterate over discovered directories # dynamically instead of hard-coding calendar names. log "Writing ~/.config/khal/config (per-calendar entries)..." mkdir -p ~/.config/khal python3 - "$CALDAV_DIR" << 'PYEOF' import sys from pathlib import Path cal_root = Path(sys.argv[1]) # Cycle through a set of terminal colours so different calendars are visually # distinct in khal's output. colors = ["light blue", "light green", "light red", "light magenta", "light cyan"] dirs = sorted(d for d in cal_root.iterdir() if d.is_dir()) lines = ["[calendars]"] for i, d in enumerate(dirs): # Each sub-directory created by vdirsyncer is one calendar collection. lines += [f" [[{d.name}]]", f" path = {d}", f" color = {colors[i % len(colors)]}"] # [sqlite] — khal caches parsed events here for faster startup # [locale] — display preferences for times and dates lines += [ "", "[sqlite]", "path = ~/.local/share/khal/khal.db", "", "[locale]", "timeformat = %H:%M", "dateformat = %Y-%m-%d", "datetimeformat = %Y-%m-%d %H:%M", ] (Path.home() / ".config/khal/config").write_text("\n".join(lines) + "\n") PYEOF # ── ICS → calendar.vim cache converter ─────────────────────────────────────── # calendar.vim (a Neovim plugin) expects event data in a specific JSON cache # format at ~/.cache/calendar.vim/local/. This helper script reads the local # ICS files maintained by vdirsyncer and writes that JSON cache so calendar.vim # can show events without hitting the network. log "Installing ics-to-calendarim converter..." mkdir -p ~/.local/bin cat > ~/.local/bin/ics-to-calendarim << 'PYEOF' #!/usr/bin/env python3 """ Convert vdirsyncer ICS files to calendar.vim local event cache. Cache lives at ~/.cache/calendar.vim/local/ in the format: calendarList – list of calendars (JSON) ///0 – events per month (JSON) """ import json, sys from pathlib import Path from datetime import datetime, date, timezone try: from icalendar import Calendar as iCal except ImportError: sys.exit("python-icalendar is required: sudo pacman -S python-icalendar") CALDAV_DIR = Path.home() / ".local/share/calendars" CACHE_DIR = Path.home() / ".cache/calendar.vim/local" KHAL_CFG = Path.home() / ".config/khal/config" # Hex colours for calendar.vim's event display (one per calendar, cycled) COLORS = ["#4CAF50", "#2196F3", "#FF5722", "#9C27B0", "#FF9800"] # Matching terminal colour names for khal (kept in sync so both tools agree) KHAL_COLORS = ["light blue", "light green", "light red", "light magenta", "light cyan"] def to_naive(dt): """Normalise a DTSTART/DTEND value to a naive string + metadata. Returns (string_repr, is_timed, year, month). Timezone-aware datetimes are converted to UTC before stripping tzinfo so that comparisons are consistent across DST transitions. All-day dates return an ISO date string (YYYY-MM-DD) with is_timed=False. """ if isinstance(dt, datetime): if dt.tzinfo is not None: dt = dt.astimezone(timezone.utc).replace(tzinfo=None) return dt.strftime("%Y-%m-%dT%H:%M:%S"), True, dt.year, dt.month if isinstance(dt, date): return dt.strftime("%Y-%m-%d"), False, dt.year, dt.month return None, False, None, None def main(): CACHE_DIR.mkdir(parents=True, exist_ok=True) cal_items = [] # list of calendar metadata objects for calendarList # Iterate over each calendar collection directory (one per CalDAV calendar) for idx, cal_dir in enumerate(sorted(d for d in CALDAV_DIR.iterdir() if d.is_dir())): cal_id = cal_dir.name # Human-readable name: replace underscores with spaces and title-case cal_summary = cal_dir.name.replace("_", " ").title() cal_items.append({ "id": cal_id, "summary": cal_summary, "backgroundColor": COLORS[idx % len(COLORS)], "foregroundColor": "#ffffff", }) by_month: dict = {} # keyed by (year, month) → list of event objects for ics in cal_dir.glob("*.ics"): try: cal = iCal.from_ical(ics.read_bytes()) except Exception as e: print(f"warning: skipping {ics.name}: {e}", file=sys.stderr) continue # Walk all VEVENT components in the ICS file for comp in cal.walk("VEVENT"): dtstart = comp.get("DTSTART") # If DTEND is missing, fall back to DTSTART (zero-duration event) dtend = comp.get("DTEND") or comp.get("DTSTART") if not dtstart: continue s_str, is_timed, yr, mo = to_naive(dtstart.dt) e_str, _, _, _ = to_naive(dtend.dt) if not s_str: continue uid = str(comp.get("UID", ics.stem)) summary = str(comp.get("SUMMARY", "")) key = (yr, mo) # Group events by month so we write one JSON file per month by_month.setdefault(key, []).append({ "id": uid, "summary": summary, # calendar.vim distinguishes timed events ("dateTime") from # all-day events ("date") by which key is present "start": {"dateTime": s_str} if is_timed else {"date": s_str}, "end": {"dateTime": e_str} if is_timed else {"date": e_str}, }) # Write one JSON file per (calendar, year, month) combination. # Path structure mirrors what calendar.vim expects for its local cache. for (yr, mo), events in by_month.items(): out = CACHE_DIR / cal_id / f"{yr:04d}" / f"{mo:02d}" out.mkdir(parents=True, exist_ok=True) (out / "0").write_text(json.dumps({"items": events})) # Write the top-level calendar list so calendar.vim knows which calendars exist (CACHE_DIR / "calendarList").write_text(json.dumps({"items": cal_items})) # Also regenerate the khal config so any newly discovered calendars are # automatically added without requiring a manual config edit. cal_dirs = sorted(d for d in CALDAV_DIR.iterdir() if d.is_dir()) khal_lines = ["[calendars]"] for i, d in enumerate(cal_dirs): khal_lines += [f" [[{d.name}]]", f" path = {d}", f" color = {KHAL_COLORS[i % len(KHAL_COLORS)]}"] khal_lines += ["", "[sqlite]", "path = ~/.local/share/khal/khal.db", "", "[locale]", "timeformat = %H:%M", "dateformat = %Y-%m-%d", "datetimeformat = %Y-%m-%d %H:%M"] KHAL_CFG.write_text("\n".join(khal_lines) + "\n") print(f"calendar.vim cache updated: {len(cal_items)} calendar(s).") if __name__ == "__main__": main() PYEOF # Make the helper executable so it can be called directly from the PATH chmod +x ~/.local/bin/ics-to-calendarim # Build the initial calendar.vim cache from the events we just synced log "Running initial calendar.vim cache build..." ~/.local/bin/ics-to-calendarim # ── systemd user timer ──────────────────────────────────────────────────────── # A systemd user timer keeps calendars current automatically. # Using systemd (instead of cron) lets the unit run without requiring root and # integrates cleanly with the user's login session. log "Installing vdirsyncer systemd user timer (every 15 min)..." mkdir -p ~/.config/systemd/user # The service unit runs vdirsyncer sync then rebuilds the calendar.vim cache. # Type=oneshot: the service is considered active only while the process runs; # it exits when done rather than staying in the background. # After=network-online.target: ensures we don't try to sync before the network # is available (important at boot). cat > ~/.config/systemd/user/vdirsyncer.service << EOF [Unit] Description=Sync CalDAV and rebuild calendar.vim cache After=network-online.target [Service] Type=oneshot ExecStart=/usr/bin/vdirsyncer sync ExecStartPost=$HOME/.local/bin/ics-to-calendarim EOF # The timer unit triggers the service on a schedule. # OnBootSec=2min : first run 2 minutes after login (let the network settle) # OnUnitActiveSec=15min : repeat every 15 minutes after the last activation cat > ~/.config/systemd/user/vdirsyncer.timer << EOF [Unit] Description=Run vdirsyncer every 15 minutes [Timer] OnBootSec=2min OnUnitActiveSec=15min Unit=vdirsyncer.service [Install] WantedBy=timers.target EOF # Reload systemd user daemon so it picks up the new unit files systemctl --user daemon-reload # Enable and immediately start the timer; "--now" starts it without a reboot systemctl --user enable --now vdirsyncer.timer log "CalDAV sync configured. Events will appear in calendar.vim automatically." log "Manual sync: vdirsyncer sync && ics-to-calendarim" log "CLI calendar: khal list / khal interactive" log "Timer status: systemctl --user status vdirsyncer.timer"