diff options
Diffstat (limited to 'examples/tools/agenda.py')
| -rwxr-xr-x | examples/tools/agenda.py | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/examples/tools/agenda.py b/examples/tools/agenda.py new file mode 100755 index 0000000..44bc34b --- /dev/null +++ b/examples/tools/agenda.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# desktop-assistant tool: events from a vdir (vdirsyncer's .ics folders). +# 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. +# +# 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") |
