#!/usr/bin/env python3 # desktop-assistant tool: events from a vdir (vdirsyncer's .ics folders). # Copyright (C) 2026 Danilo M. # # 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. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """agenda.py [START] [DAYS]: events from START (YYYY-MM-DD, empty = today) for DAYS days (empty = 1), one line each. Reads every .ics under CALENDAR_DIR (default ~/.local/share/calendars). --test runs the self-check. Not named calendar.py: that would shadow the stdlib module dateutil imports. """ import datetime as dt import glob import os import re import sys import time from dateutil import rrule, tz def unfold(text): return re.sub(r"\r?\n[ \t]", "", text).splitlines() def events(text): # The lines we need from each VEVENT; VTIMEZONE and VALARM are skipped, # their RRULE and SUMMARY lines are not the event's. ev, alarm = None, False for line in unfold(text): if line == "BEGIN:VEVENT": ev = {"EXDATE": []} elif line == "END:VEVENT": yield ev ev = None elif line == "BEGIN:VALARM": alarm = True elif line == "END:VALARM": alarm = False elif ev is not None and not alarm: name = re.split("[;:]", line, maxsplit=1)[0] if name == "EXDATE": ev["EXDATE"].append(line) elif name in ("UID", "DTSTART", "RRULE", "RECURRENCE-ID", "SUMMARY"): ev.setdefault(name, line) def local(params, value): # ICS date or date-time to naive local time, and whether it is all-day. if len(value) == 8: return dt.datetime.strptime(value, "%Y%m%d"), True t = dt.datetime.strptime(value.rstrip("Z"), "%Y%m%dT%H%M%S") m = re.search(r"TZID=([^;:]+)", params) zone = tz.UTC if value.endswith("Z") else tz.gettz(m.group(1)) if m else None if zone: t = t.replace(tzinfo=zone).astimezone(tz.tzlocal()).replace(tzinfo=None) return t, False def when(line): params, value = line.split(":", 1) return local(params, value) def agenda(d, start, days): # ponytail: naive local times, so a recurring event in a zone other than # ours drifts by an hour across DST; multi-day events show on day one only. lo = dt.datetime.fromisoformat(start) hi = lo + dt.timedelta(days=days) evs = [] for path in glob.glob(os.path.join(d, "**", "*.ics"), recursive=True): with open(path, encoding="utf-8", errors="replace") as f: evs += [e for e in events(f.read()) if "DTSTART" in e] moved = {(e.get("UID"), when(e["RECURRENCE-ID"])[0]) for e in evs if "RECURRENCE-ID" in e} out = [] for e in evs: first, allday = when(e["DTSTART"]) if "RRULE" in e and "RECURRENCE-ID" not in e: # dateutil rejects a UTC UNTIL next to a naive start: make it local. rule = re.sub(r"UNTIL=(\d{8}T\d{6})Z", lambda m: "UNTIL=" + local("", m.group(1) + "Z")[0].strftime("%Y%m%dT%H%M%S"), e["RRULE"].split(":", 1)[1]) rs = rrule.rruleset() rs.rrule(rrule.rrulestr(rule, dtstart=first)) for line in e["EXDATE"]: params, values = line.split(":", 1) for v in values.split(","): rs.exdate(local(params, v)[0]) for uid, t in moved: if uid == e.get("UID"): rs.exdate(t) occ = [t for t in rs.between(lo, hi, inc=True) if t < hi] else: occ = [first] if lo <= first < hi else [] summary = e.get("SUMMARY", "SUMMARY:(no title)").split(":", 1)[1] summary = summary.replace("\\,", ",").replace("\\;", ";").replace("\\n", " ") out += [(t, f"{t:%Y-%m-%d} {'all day' if allday else f'{t:%H:%M}'} {summary}") for t in occ] return [line for _, line in sorted(out)] def test(): import tempfile os.environ["TZ"] = "Europe/Rome" time.tzset() cal = lambda body: f"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n" files = { "once.ics": cal("BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20261001T100000\r\n" "SUMMARY:Dentist\\, checkup\r\nEND:VEVENT\r\n"), "yearly.ics": cal("BEGIN:VEVENT\r\nUID:b\r\nDTSTART;VALUE=DATE:19900312\r\n" "RRULE:FREQ=YEARLY\r\nSUMMARY:Birthday\r\n" "BEGIN:VALARM\r\nACTION:EMAIL\r\nSUMMARY:not the event\r\n" "END:VALARM\r\nEND:VEVENT\r\n"), "weekly.ics": cal( "BEGIN:VTIMEZONE\r\nTZID:Europe/Rome\r\nBEGIN:DAYLIGHT\r\nDTSTART:19700329T020000\r\n" "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n" "BEGIN:VEVENT\r\nUID:c\r\nDTSTART;TZID=Europe/Rome:20260901T180000\r\n" "RRULE:FREQ=WEEKLY;UNTIL=20261231T170000Z\r\n" "EXDATE;TZID=Europe/Rome:20260908T180000\r\nSUMMARY:Gym\r\nEND:VEVENT\r\n" "BEGIN:VEVENT\r\nUID:c\r\nRECURRENCE-ID;TZID=Europe/Rome:20260915T180000\r\n" "DTSTART;TZID=Europe/Rome:20260916T190000\r\nSUMMARY:Gym moved\r\nEND:VEVENT\r\n"), "utc.ics": cal("BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20261001T160000Z\r\n" "SUMMARY:Call\r\nEND:VEVENT\r\n"), } with tempfile.TemporaryDirectory() as d: os.mkdir(os.path.join(d, "cal")) for name, text in files.items(): with open(os.path.join(d, "cal", name), "w", newline="") as f: f.write(text) assert agenda(d, "2026-09-01", 30) == [ "2026-09-01 18:00 Gym", "2026-09-16 19:00 Gym moved", "2026-09-22 18:00 Gym", "2026-09-29 18:00 Gym"], agenda(d, "2026-09-01", 30) assert agenda(d, "2026-03-10", 5) == ["2026-03-12 all day Birthday"] assert agenda(d, "2026-10-01", 1) == [ "2026-10-01 10:00 Dentist, checkup", "2026-10-01 18:00 Call"] # 16Z = 18 local assert agenda(d, "2026-10-02", 1) == [] print("ok") if __name__ == "__main__": if sys.argv[1:] == ["--test"]: test() else: a = sys.argv[1:] + ["", ""] d = os.environ.get("CALENDAR_DIR") or os.path.expanduser("~/.local/share/calendars") print("\n".join(agenda(d, a[0] or dt.date.today().isoformat(), int(a[1] or 1))) or "no events")