309 lines
14 KiB
Bash
Executable File
309 lines
14 KiB
Bash
Executable File
#!/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)
|
||
<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
|
||
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"
|