feat(caldav): add CalDAV sync script with calendar.vim cache converter
Installs vdirsyncer + khal, writes vdirsyncer/khal configs, creates ics-to-calendarim converter to populate calendar.vim local JSON cache, and sets up a systemd user timer for 15-minute periodic sync. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
211763d2c8
commit
e454482970
|
|
@ -0,0 +1,192 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/../../lib/logging.sh"
|
||||||
|
|
||||||
|
log "Installing CalDAV sync stack..."
|
||||||
|
sudo pacman -S --noconfirm --needed vdirsyncer khal python-icalendar
|
||||||
|
|
||||||
|
# ── Credentials ───────────────────────────────────────────────────────────────
|
||||||
|
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
|
||||||
|
CAL_NAME="${CAL_NAME:-Personal}"
|
||||||
|
|
||||||
|
CALDAV_DIR="$HOME/.local/share/calendars"
|
||||||
|
mkdir -p "$CALDAV_DIR"
|
||||||
|
|
||||||
|
# ── vdirsyncer ────────────────────────────────────────────────────────────────
|
||||||
|
log "Writing ~/.config/vdirsyncer/config..."
|
||||||
|
mkdir -p ~/.config/vdirsyncer ~/.local/share/vdirsyncer/status
|
||||||
|
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
|
||||||
|
chmod 600 ~/.config/vdirsyncer/config
|
||||||
|
|
||||||
|
log "Discovering CalDAV collections (confirm any prompts with y)..."
|
||||||
|
yes | vdirsyncer discover calendars || true
|
||||||
|
|
||||||
|
log "Running initial sync..."
|
||||||
|
vdirsyncer sync
|
||||||
|
|
||||||
|
# ── khal (CLI calendar companion) ────────────────────────────────────────────
|
||||||
|
log "Writing ~/.config/khal/config..."
|
||||||
|
mkdir -p ~/.config/khal
|
||||||
|
cat > ~/.config/khal/config << EOF
|
||||||
|
[calendars]
|
||||||
|
[[personal]]
|
||||||
|
path = $CALDAV_DIR/*
|
||||||
|
color = light blue
|
||||||
|
|
||||||
|
[sqlite]
|
||||||
|
path = ~/.local/share/khal/khal.db
|
||||||
|
|
||||||
|
[locale]
|
||||||
|
timeformat = %H:%M
|
||||||
|
dateformat = %Y-%m-%d
|
||||||
|
datetimeformat = %Y-%m-%d %H:%M
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── ICS → calendar.vim cache converter ───────────────────────────────────────
|
||||||
|
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"
|
||||||
|
|
||||||
|
COLORS = ["#4CAF50", "#2196F3", "#FF5722", "#9C27B0", "#FF9800"]
|
||||||
|
|
||||||
|
def to_naive(dt):
|
||||||
|
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 = []
|
||||||
|
|
||||||
|
for idx, cal_dir in enumerate(sorted(d for d in CALDAV_DIR.iterdir() if d.is_dir())):
|
||||||
|
cal_id = cal_dir.name
|
||||||
|
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 = {}
|
||||||
|
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
|
||||||
|
for comp in cal.walk("VEVENT"):
|
||||||
|
dtstart = comp.get("DTSTART")
|
||||||
|
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)
|
||||||
|
by_month.setdefault(key, []).append({
|
||||||
|
"id": uid,
|
||||||
|
"summary": summary,
|
||||||
|
"start": {"dateTime": s_str} if is_timed else {"date": s_str},
|
||||||
|
"end": {"dateTime": e_str} if is_timed else {"date": e_str},
|
||||||
|
})
|
||||||
|
|
||||||
|
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}))
|
||||||
|
|
||||||
|
(CACHE_DIR / "calendarList").write_text(json.dumps({"items": cal_items}))
|
||||||
|
print(f"calendar.vim cache updated: {len(cal_items)} calendar(s).")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
PYEOF
|
||||||
|
chmod +x ~/.local/bin/ics-to-calendarim
|
||||||
|
|
||||||
|
log "Running initial calendar.vim cache build..."
|
||||||
|
~/.local/bin/ics-to-calendarim
|
||||||
|
|
||||||
|
# ── systemd user timer ────────────────────────────────────────────────────────
|
||||||
|
log "Installing vdirsyncer systemd user timer (every 15 min)..."
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
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"
|
||||||
Loading…
Reference in New Issue