aboutsummaryrefslogtreecommitdiffstats
path: root/examples
diff options
context:
space:
mode:
Diffstat (limited to 'examples')
-rwxr-xr-xexamples/tools/agenda.py159
-rw-r--r--examples/tools/calendar.toml11
2 files changed, 170 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")
diff --git a/examples/tools/calendar.toml b/examples/tools/calendar.toml
new file mode 100644
index 0000000..75e4898
--- /dev/null
+++ b/examples/tools/calendar.toml
@@ -0,0 +1,11 @@
+description = "Events in the user's calendar for a range of days. Use it for questions about appointments, birthdays, what is planned."
+command = ["agenda.py", "{start}", "{days}"]
+timeout = 10
+
+[params.start]
+type = "string"
+description = "First day, YYYY-MM-DD. Empty for today."
+
+[params.days]
+type = "integer"
+description = "How many days, 1 for a single day. Empty for 1."