Skip to content

Commit 843854f

Browse files
author
Gerit Wagner
committed
add script
1 parent f877881 commit 843854f

File tree

1 file changed

+132
-0
lines changed

1 file changed

+132
-0
lines changed

src/update_calendar.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import uuid
5+
from pathlib import Path
6+
from datetime import datetime, timedelta
7+
8+
import yaml
9+
from dateutil import rrule
10+
from dateutil.parser import isoparse
11+
from zoneinfo import ZoneInfo
12+
13+
from icalendar import Calendar, Event
14+
15+
16+
BERLIN = ZoneInfo("Europe/Berlin")
17+
18+
19+
def parse_dt(value: str) -> datetime:
20+
"""
21+
Accepts:
22+
- "YYYY-MM-DD HH:MM"
23+
- ISO strings like "2025-01-17T13:00:00Z" / "2025-01-17T13:00:00+01:00"
24+
Returns timezone-aware datetime in Europe/Berlin.
25+
"""
26+
value = value.strip()
27+
if "T" in value: # ISO-ish
28+
dt = isoparse(value)
29+
if dt.tzinfo is None:
30+
dt = dt.replace(tzinfo=BERLIN)
31+
return dt.astimezone(BERLIN)
32+
33+
# "YYYY-MM-DD HH:MM"
34+
dt = datetime.strptime(value, "%Y-%m-%d %H:%M")
35+
return dt.replace(tzinfo=BERLIN)
36+
37+
38+
def expand_events(events: list[dict]) -> list[dict]:
39+
expanded: list[dict] = []
40+
41+
for ev in events:
42+
start = parse_dt(ev["start"])
43+
end = parse_dt(ev["end"])
44+
duration = end - start
45+
46+
if "recurrence" in ev and ev["recurrence"]:
47+
# RRULE.parseString equivalent:
48+
rule = rrule.rrulestr(ev["recurrence"], dtstart=start)
49+
50+
for occ_start in rule:
51+
occ_end = occ_start + duration
52+
expanded.append(
53+
{
54+
"start": occ_start,
55+
"end": occ_end,
56+
"title": ev.get("title", ""),
57+
"location": ev.get("location", ""),
58+
"description": ev.get("description", ""),
59+
"color": ev.get("color", ""),
60+
}
61+
)
62+
else:
63+
expanded.append(
64+
{
65+
"start": start,
66+
"end": end,
67+
"title": ev.get("title", ""),
68+
"location": ev.get("location", ""),
69+
"description": ev.get("description", ""),
70+
"color": ev.get("color", ""),
71+
}
72+
)
73+
74+
return expanded
75+
76+
77+
def generate_ical(events: list[dict]) -> str:
78+
cal = Calendar()
79+
cal.add("prodid", "-//Digital Work Lab//Calendar Export Tool//EN")
80+
cal.add("version", "2.0")
81+
cal.add("calscale", "GREGORIAN")
82+
cal.add("method", "PUBLISH")
83+
# Optional: many clients ignore this, but harmless
84+
cal.add("x-published-ttl", "PT1H")
85+
86+
now_utc = datetime.now(tz=ZoneInfo("UTC"))
87+
88+
for ev in events:
89+
e = Event()
90+
e.add("uid", f"{uuid.uuid4()}@digital-work-lab")
91+
e.add("dtstamp", now_utc)
92+
e.add("summary", ev.get("title", "") or " ")
93+
e.add("dtstart", ev["start"])
94+
e.add("dtend", ev["end"])
95+
96+
desc = ev.get("description") or ""
97+
loc = ev.get("location") or ""
98+
if desc:
99+
e.add("description", desc)
100+
if loc:
101+
e.add("location", loc)
102+
103+
# NOTE: "color" is not a standard iCalendar VEVENT property.
104+
# Some clients support non-standard props like COLOR or CATEGORIES.
105+
# If you want to encode it anyway:
106+
if ev.get("color"):
107+
e.add("x-apple-calendar-color", ev["color"])
108+
e.add("color", ev["color"]) # non-standard, but some tools read it
109+
110+
cal.add_component(e)
111+
112+
return cal.to_ical().decode("utf-8")
113+
114+
115+
def main() -> None:
116+
yaml_path = Path("./data/events.yaml")
117+
out_path = Path("assets/calendar/fs-ise.ical")
118+
out_path.parent.mkdir(parents=True, exist_ok=True)
119+
120+
raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
121+
if not isinstance(raw, list):
122+
raise ValueError("Parsed YAML is not a list")
123+
124+
expanded = expand_events(raw)
125+
ical_text = generate_ical(expanded)
126+
127+
out_path.write_text(ical_text, encoding="utf-8")
128+
print(f"Wrote {len(expanded)} events to {out_path}")
129+
130+
131+
if __name__ == "__main__":
132+
main()

0 commit comments

Comments
 (0)