aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/island/calendar/calctl
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-13 10:25:03 +0200
committerDanilo M. <danix@danix.xyz>2026-10-13 10:25:03 +0200
commit9bc2dba1f595ed5ed7121d17b3361a718cc3b1a6 (patch)
tree598eeda7b551aff960ee7249859c21470772a19a /desktop/island/calendar/calctl
parent35bce0a57b6839471e6d8a31f1af4870af0ab94d (diff)
downloadquickshell-9bc2dba1f595ed5ed7121d17b3361a718cc3b1a6.tar.gz
quickshell-9bc2dba1f595ed5ed7121d17b3361a718cc3b1a6.zip
feat(calendar): calctl reads the vdir as JSON
The calendar list comes from khal's config, so there is no second list. Recurrence is expanded with dateutil in naive local time, which keeps a weekly 09:00 at 09:00 across the DST change; the test pins that on 26 October. A rule richer than a plain FREQ, or alarms other than one preset, read as custom, which is what lets an edit leave them alone. A file that fails to parse is skipped rather than blanking the month. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/island/calendar/calctl')
-rwxr-xr-xdesktop/island/calendar/calctl307
1 files changed, 307 insertions, 0 deletions
diff --git a/desktop/island/calendar/calctl b/desktop/island/calendar/calctl
new file mode 100755
index 0000000..7988f0b
--- /dev/null
+++ b/desktop/island/calendar/calctl
@@ -0,0 +1,307 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The island calendar's backend: the vdirsyncer calendars as JSON. Reads the
+# calendar list from khal's config so there is no second list to keep in
+# step; khal and cal-notif read the same files and are not affected.
+#
+# calctl calendars {default, calendars: [{name, color, path}]}
+# calctl list FROM TO occurrences in [FROM, TO), local time
+# calctl get UID the series itself, for the editor
+# calctl new EVENT-JSON {uid}
+# calctl edit UID EVENT-JSON {uid}
+# calctl delete UID {}
+#
+# Every command prints one object: {"ok": true, "data": ...} or
+# {"ok": false, "error": "..."} with exit 1. Environment, for the tests:
+# CALCTL_KHAL_CONFIG, CALCTL_OVERRIDES, CALCTL_NOSYNC.
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import tomllib
+import uuid
+from datetime import date, datetime, time, timedelta, timezone
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import icalendar
+from dateutil.rrule import rrulestr
+
+HOME = Path.home()
+KHAL_CONFIG = Path(os.environ.get("CALCTL_KHAL_CONFIG", HOME / ".config/khal/config"))
+OVERRIDES = Path(os.environ.get("CALCTL_OVERRIDES", HOME / ".config/cal-notif/overrides.toml"))
+SYNC_LOG = HOME / ".local/share/vdirsyncer/sync.log"
+
+PRESET_REPEAT = {"daily": "DAILY", "weekly": "WEEKLY", "monthly": "MONTHLY", "yearly": "YEARLY"}
+PRESET_ALARM = {"10m": timedelta(minutes=10), "1h": timedelta(hours=1), "1d": timedelta(days=1)}
+
+# khal's colour names as Theme property names; a #rrggbb colour passes
+# through and anything else is the accent.
+COLORS = {
+ "black": "crust", "white": "text", "gray": "subtext", "light gray": "subtext",
+ "dark gray": "overlay", "brown": "peach",
+ "red": "red", "dark red": "red", "light red": "red",
+ "green": "green", "dark green": "green", "light green": "green",
+ "yellow": "yellow", "light yellow": "yellow",
+ "blue": "blue", "dark blue": "blue", "light blue": "blue",
+ "magenta": "mauve", "dark magenta": "mauve", "light magenta": "mauve",
+ "cyan": "teal", "dark cyan": "teal", "light cyan": "teal",
+}
+
+USAGE = "usage: calctl calendars | list FROM TO | get UID | new JSON | edit UID JSON | delete UID"
+
+
+class Fail(Exception):
+ pass
+
+
+def local_zone():
+ tz = os.environ.get("TZ", "").lstrip(":")
+ if tz:
+ try:
+ return ZoneInfo(tz)
+ except Exception:
+ pass
+ real = os.path.realpath("/etc/localtime")
+ if "zoneinfo/" in real:
+ return ZoneInfo(real.split("zoneinfo/", 1)[1])
+ return ZoneInfo("UTC")
+
+
+LOCAL = local_zone()
+
+
+def calendars():
+ """([{name, path, color}], default name) from khal's [calendars].
+
+ ponytail: khal's `type = discover` globs are not expanded; every
+ calendar here is a plain path.
+ """
+ try:
+ lines = KHAL_CONFIG.read_text().splitlines()
+ except OSError as e:
+ raise Fail(f"cannot read {KHAL_CONFIG}: {e.strerror}")
+ cals, section, cur, default = [], None, None, None
+ for raw in lines:
+ line = raw.strip()
+ # Only whole-line comments: a colour value can start with '#'.
+ if not line or line.startswith("#"):
+ continue
+ if line.startswith("[["):
+ cur = None
+ if section == "calendars":
+ cur = {"name": line.strip("[]").strip(), "path": None, "color": "accent"}
+ cals.append(cur)
+ continue
+ if line.startswith("["):
+ section, cur = line.strip("[]").strip(), None
+ continue
+ if "=" not in line:
+ continue
+ key, value = (s.strip() for s in line.split("=", 1))
+ value = value.strip("'\"")
+ if cur is not None and key == "path":
+ cur["path"] = Path(os.path.expanduser(value))
+ elif cur is not None and key == "color":
+ cur["color"] = COLORS.get(value, value if value.startswith("#") else "accent")
+ elif section == "default" and key == "default_calendar":
+ default = value
+ cals = [c for c in cals if c["path"]]
+ if not cals:
+ raise Fail(f"no calendars in {KHAL_CONFIG}")
+ names = [c["name"] for c in cals]
+ return cals, default if default in names else names[0]
+
+
+def master(ics):
+ """The series' VEVENT: the first one that is not an occurrence override."""
+ for ev in ics.walk("VEVENT"):
+ if "RECURRENCE-ID" not in ev:
+ return ev
+ return None
+
+
+def events():
+ """(calendar, path, VEVENT) for every event file. A file that fails to
+ parse is skipped: one bad file must not blank the month."""
+ cals, _ = calendars()
+ for cal in cals:
+ for path in sorted(cal["path"].glob("*.ics")):
+ try:
+ ev = master(icalendar.Calendar.from_ical(path.read_bytes()))
+ except Exception:
+ continue
+ # ponytail: occurrence overrides (RECURRENCE-ID) and EXDATE are not
+ # applied; none exist in the live calendars (spec, Scope).
+ if ev is not None:
+ yield cal, path, ev
+
+
+def find(uid):
+ for cal, path, ev in events():
+ if str(ev.get("UID", "")) == uid:
+ return cal, path
+ raise Fail(f"no event {uid}")
+
+
+def as_local(value):
+ """A date-time as naive local time; a date stays a date. A floating
+ time (no zone) is local by definition."""
+ if isinstance(value, datetime) and value.tzinfo is not None:
+ return value.astimezone(LOCAL).replace(tzinfo=None)
+ return value
+
+
+def span(ev):
+ """(start, end, all_day): naive local datetimes, or dates with an
+ exclusive end for an all-day event."""
+ start = as_local(ev["DTSTART"].dt)
+ all_day = not isinstance(start, datetime)
+ if "DTEND" in ev:
+ end = as_local(ev["DTEND"].dt)
+ elif "DURATION" in ev:
+ end = start + ev["DURATION"].dt
+ else:
+ end = start + timedelta(days=1) if all_day else start
+ return start, end, all_day
+
+
+def repeat_of(ev):
+ """(repeat, until) as the form shows them."""
+ rule = ev.get("RRULE")
+ if rule is None:
+ return "none", ""
+ names = {v: k for k, v in PRESET_REPEAT.items()}
+ freq = rule.get("FREQ", [""])[0]
+ # WKST changes nothing for a plain FREQ; the live birthdays all carry it.
+ if set(rule) - {"WKST"} <= {"FREQ", "UNTIL"} and freq in names:
+ until = ""
+ if "UNTIL" in rule:
+ u = as_local(rule["UNTIL"][0])
+ until = (u.date() if isinstance(u, datetime) else u).isoformat()
+ return names[freq], until
+ return "custom", ""
+
+
+def alarm_of(ev):
+ alarms = [c for c in ev.subcomponents if c.name == "VALARM"]
+ if not alarms:
+ return "none"
+ if len(alarms) == 1:
+ trig = alarms[0].get("TRIGGER")
+ if (trig is not None and isinstance(trig.dt, timedelta)
+ and trig.params.get("RELATED", "START") == "START"):
+ for name, before in PRESET_ALARM.items():
+ if trig.dt == -before:
+ return name
+ return "custom"
+
+
+def overrides():
+ """UIDs with a per-event alarm in cal-notif, which wins over the VALARM."""
+ try:
+ return set(tomllib.loads(OVERRIDES.read_text()))
+ except (OSError, tomllib.TOMLDecodeError):
+ return set()
+
+
+def stamp(value):
+ if isinstance(value, datetime):
+ return value.isoformat(timespec="minutes")
+ return value.isoformat()
+
+
+def to_json(cal, ev, start, end, all_day, ov):
+ repeat, until = repeat_of(ev)
+ uid = str(ev.get("UID", ""))
+ return {
+ "uid": uid,
+ "calendar": cal["name"],
+ "color": cal["color"],
+ "title": str(ev.get("SUMMARY", "")),
+ "allDay": all_day,
+ "start": stamp(start),
+ # All-day ends go out inclusive: the last day the event covers.
+ "end": stamp(max(start, end - timedelta(days=1))) if all_day else stamp(end),
+ "location": str(ev.get("LOCATION", "")),
+ "notes": str(ev.get("DESCRIPTION", "")),
+ "repeat": repeat,
+ "until": until,
+ "alarm": alarm_of(ev),
+ "override": uid in ov,
+ }
+
+
+def cmd_list(lo_s, hi_s):
+ lo, hi = datetime.fromisoformat(lo_s), datetime.fromisoformat(hi_s)
+ ov, out = overrides(), []
+ for cal, _, ev in events():
+ try:
+ start, end, all_day = span(ev)
+ s = datetime.combine(start, time()) if all_day else start
+ e = datetime.combine(end, time()) if all_day else end
+ dur = e - s
+ if "RRULE" in ev:
+ # Naive local time keeps the wall-clock time across DST.
+ rule = rrulestr(ev["RRULE"].to_ical().decode(), dtstart=s, ignoretz=True)
+ starts = rule.between(lo - dur, hi, inc=True)
+ else:
+ starts = [s]
+ except Exception:
+ continue
+ for o in starts:
+ if not (o < hi and (o + dur > lo or (not dur and o >= lo))):
+ continue
+ if all_day:
+ out.append(to_json(cal, ev, o.date(), (o + dur).date(), True, ov))
+ else:
+ out.append(to_json(cal, ev, o, o + dur, False, ov))
+ out.sort(key=lambda x: (x["start"][:10], not x["allDay"], x["start"]))
+ return out
+
+
+def cmd_get(uid):
+ cal, path = find(uid)
+ ev = master(icalendar.Calendar.from_ical(path.read_bytes()))
+ start, end, all_day = span(ev)
+ return to_json(cal, ev, start, end, all_day, overrides())
+
+
+def main(argv):
+ cmd, args = (argv[1], argv[2:]) if len(argv) > 1 else ("", [])
+ try:
+ if cmd == "calendars" and not args:
+ cals, default = calendars()
+ data = {"default": default,
+ "calendars": [{"name": c["name"], "color": c["color"], "path": str(c["path"])}
+ for c in cals]}
+ elif cmd == "list" and len(args) == 2:
+ data = cmd_list(*args)
+ elif cmd == "get" and len(args) == 1:
+ data = cmd_get(*args)
+ else:
+ raise Fail(USAGE)
+ except Exception as e:
+ msg = str(e) if isinstance(e, Fail) else f"{type(e).__name__}: {e}"
+ print(json.dumps({"ok": False, "error": msg}))
+ print(f"calctl: {msg}", file=sys.stderr)
+ return 1
+ print(json.dumps({"ok": True, "data": data}, ensure_ascii=False))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))