1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
|
#!/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.
# The weekday is printed because the model gets it wrong from a date.
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:%A %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) == [
"Tuesday 2026-09-01 18:00 Gym", "Wednesday 2026-09-16 19:00 Gym moved",
"Tuesday 2026-09-22 18:00 Gym", "Tuesday 2026-09-29 18:00 Gym"], agenda(d, "2026-09-01", 30)
assert agenda(d, "2026-03-10", 5) == ["Thursday 2026-03-12 all day Birthday"]
assert agenda(d, "2026-10-01", 1) == [
"Thursday 2026-10-01 10:00 Dentist, checkup", "Thursday 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")
|