diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-13 10:25:03 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-13 10:25:03 +0200 |
| commit | 9bc2dba1f595ed5ed7121d17b3361a718cc3b1a6 (patch) | |
| tree | 598eeda7b551aff960ee7249859c21470772a19a /desktop/island | |
| parent | 35bce0a57b6839471e6d8a31f1af4870af0ab94d (diff) | |
| download | quickshell-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')
| -rwxr-xr-x | desktop/island/calendar/calctl | 307 | ||||
| -rw-r--r-- | desktop/island/calendar/test_calctl.py | 137 |
2 files changed, 444 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)) diff --git a/desktop/island/calendar/test_calctl.py b/desktop/island/calendar/test_calctl.py new file mode 100644 index 0000000..e6acd10 --- /dev/null +++ b/desktop/island/calendar/test_calctl.py @@ -0,0 +1,137 @@ +#!/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. +"""Run: /usr/bin/python3 desktop/island/calendar/test_calctl.py + +Drives calctl as a CLI against temporary vdirs and a temporary khal config, +never the live calendars. Plain asserts, no framework. +""" + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +CALCTL = Path(__file__).resolve().parent / "calctl" + +tmp = Path(tempfile.mkdtemp(prefix="calctl-test-")) +(tmp / "a").mkdir() +(tmp / "b").mkdir() +(tmp / "khal").write_text(f"""[calendars] + +[[home]] +path = {tmp}/a +type = calendar +color = light green + +[[birthdays]] +path = {tmp}/b +color = '#ff8800' + +[locale] +firstweekday = 0 + +[default] +default_calendar = home +""") +ENV = dict(os.environ, + CALCTL_KHAL_CONFIG=str(tmp / "khal"), + CALCTL_OVERRIDES=str(tmp / "overrides.toml"), + CALCTL_NOSYNC="1", + TZ="Europe/Rome") + + +def run(*args, ok=True): + p = subprocess.run([str(CALCTL), *args], env=ENV, capture_output=True, text=True) + r = json.loads(p.stdout) + assert r["ok"] is ok, (args, p.stdout, p.stderr) + assert (p.returncode == 0) is ok, (args, p.returncode) + return r["data"] if ok else r["error"] + + +def put(path, body): + """A hand-written .ics, CRLF like a real one.""" + Path(path).write_text(body.strip().replace("\n", "\r\n") + "\r\n") + + +# calendars: khal colour names mapped to Theme names, a hex passed through. +c = run("calendars") +assert c["default"] == "home" +assert [(x["name"], x["color"]) for x in c["calendars"]] == [("home", "green"), ("birthdays", "#ff8800")] +assert c["calendars"][0]["path"] == str(tmp / "a") + +# A yearly all-day event from long ago is expanded into the asked window. +put(tmp / "b" / "bday.ics", """ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test//EN +BEGIN:VEVENT +UID:bday-1 +DTSTAMP:20200101T000000Z +DTSTART;VALUE=DATE:19900417 +DTEND;VALUE=DATE:19900418 +RRULE:FREQ=YEARLY;WKST=TU +SUMMARY:Birthday +END:VEVENT +END:VCALENDAR +""") +apr = [e for e in run("list", "2026-04-01", "2026-05-01") if e["uid"] == "bday-1"] +assert len(apr) == 1, apr +assert apr[0]["start"] == "2026-04-17" and apr[0]["end"] == "2026-04-17", apr +assert apr[0]["allDay"] is True and apr[0]["repeat"] == "yearly" and apr[0]["color"] == "#ff8800" + +# A rich rule and two alarms read as custom; the weekly rule is expanded in +# local time, so 09:00 stays 09:00 across the DST change on 25 October. +put(tmp / "a" / "weird.ics", """ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test//EN +BEGIN:VEVENT +UID:weird-1 +DTSTAMP:20260101T000000Z +DTSTART;TZID=Europe/Rome:20261005T090000 +DTEND;TZID=Europe/Rome:20261005T100000 +RRULE:FREQ=WEEKLY;BYDAY=MO,WE +SUMMARY:Standup +X-FOO:bar +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:a +TRIGGER:-PT1H +END:VALARM +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:b +TRIGGER:-PT5M +END:VALARM +END:VEVENT +END:VCALENDAR +""") +w = run("get", "weird-1") +assert w["repeat"] == "custom" and w["alarm"] == "custom" and w["title"] == "Standup", w +assert w["start"] == "2026-10-05T09:00", w +occ = [e["start"] for e in run("list", "2026-10-01", "2026-11-01") if e["uid"] == "weird-1"] +assert len(occ) == 8 and occ[0] == "2026-10-05T09:00" and "2026-10-26T09:00" in occ, occ + +# A broken file is skipped, not fatal. +(tmp / "a" / "broken.ics").write_text("this is not a calendar\n") +assert run("list", "2026-10-01", "2026-11-01") + +# Unknown uid and bad usage: the error envelope, exit 1. +assert "no event" in run("get", "nope", ok=False) +assert "usage" in run("frobnicate", ok=False) + +# --- writes (Task 3) go here --- + +shutil.rmtree(tmp) +print("test_calctl: ok") |
