Dotfiles/setup/modules/optional-Modules/apps/caldav-sync.sh

333 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/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
# In unattended installs (answerfile mode / no TTY) there is no operator to answer
# the server prompts below, so install the tools and stop cleanly here; the user
# configures their CalDAV account after first boot.
if [[ "${MARCHY_UNATTENDED:-0}" == "1" || ! -t 0 ]]; then
skip "Unattended mode — CalDAV tools installed; configure the account after first boot."
exit 0
fi
# ── 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.
# Best-effort: with placeholder credentials or no network at install time this
# is expected to fail, which must not abort the module. Warn and continue.
log "Running initial sync..."
vdirsyncer sync || warn "Initial CalDAV sync failed (credentials/network not ready) — run 'vdirsyncer sync' once configured."
# ── 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)
<id>/<YYYY>/<MM>/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.
# Best-effort: harmless to skip if the first sync pulled nothing.
log "Running initial calendar.vim cache build..."
~/.local/bin/ics-to-calendarim || warn "calendar.vim cache build skipped — rebuild later with: 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.
#
# Best-effort: when the installer runs from a bare TTY or chroot there is no
# per-user systemd session bus (and ~/.config may not yet be owned by the
# invoking user), so `systemctl --user` and even the mkdir can fail. That must
# not abort the module — the timer can be (re)created on first login. We warn
# and continue rather than letting `set -e` kill the install.
log "Installing vdirsyncer systemd user timer (every 15 min)..."
if mkdir -p ~/.config/systemd/user 2>/dev/null; then
# 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
# A successful daemon-reload means a user session bus is available; only then
# is it worth trying to enable/start the timer.
if systemctl --user daemon-reload 2>/dev/null; then
# Enable and immediately start the timer; "--now" starts it without a reboot.
systemctl --user enable --now vdirsyncer.timer 2>/dev/null \
|| warn "Could not enable vdirsyncer.timer now — run: systemctl --user enable --now vdirsyncer.timer"
else
warn "No user systemd session detected — enable the timer after login with: systemctl --user enable --now vdirsyncer.timer"
fi
else
warn "Could not create ~/.config/systemd/user — skipping the vdirsyncer timer (set it up after first login)."
fi
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"