From 0c25d416c6e0e76f71358bef538a2019771b8989 Mon Sep 17 00:00:00 2001 From: "Ricardo (XenGi) Band" Date: Tue, 25 Aug 2026 09:35:12 +0200 Subject: [PATCH] fix calendar export --- hosts/www/caldav-export.py | 80 ++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 11 deletions(-) diff --git a/hosts/www/caldav-export.py b/hosts/www/caldav-export.py index 8ef1130..814a54b 100644 --- a/hosts/www/caldav-export.py +++ b/hosts/www/caldav-export.py @@ -1,5 +1,4 @@ import argparse -import os import re from pathlib import Path @@ -12,7 +11,12 @@ def safe_filename(name: str) -> str: return re.sub(r"[^a-zA-Z0-9._-]+", "_", name) -def export_calendar(url: str, username: str, password: str, calendar: str) -> Calendar: +def export_calendar( + url: str, + username: str, + password: str, + calendar: str, +) -> Calendar: client = DAVClient( url=url, username=username, @@ -58,13 +62,45 @@ def export_calendar(url: str, username: str, password: str, calendar: str) -> Ca cal = Calendar.from_ical(data) - for component in cal.walk(): - if component.name != "VCALENDAR": - result.add_component(component) + # Only copy top-level components. + # + # Do NOT use cal.walk() here. walk() also returns the + # STANDARD/DAYLIGHT components inside VTIMEZONE. + for component in cal.subcomponents: + result.add_component(component) return result +def add_normalized_components( + target: Calendar, + source: Calendar, + timezones: dict[str, object], +) -> None: + """ + Add components from source to target while collecting VTIMEZONE + components separately. + + Exactly one VTIMEZONE is retained for each TZID. + """ + + for component in source.subcomponents: + if component.name == "VTIMEZONE": + tzid = component.get("TZID") + + if tzid is None: + print(" warning: ignoring VTIMEZONE without TZID") + continue + + tzid = str(tzid) + + if tzid not in timezones: + timezones[tzid] = component + + else: + target.add_component(component) + + def main(): parser = argparse.ArgumentParser() parser.add_argument("config") @@ -81,12 +117,16 @@ def main(): output_dir = Path(args.output) output_dir.mkdir(parents=True, exist_ok=True) + username = config["username"] + password = config["password"] + combined = Calendar() combined.add("prodid", "-//Combined calendar//") combined.add("version", "2.0") - username = config["username"] - password = config["password"] + # TZID -> VTIMEZONE component + timezones = {} + for entry in config["calendars"]: cal = export_calendar( entry["url"], @@ -97,19 +137,37 @@ def main(): filename = output_dir / f"{safe_filename(entry['calendar'])}.ics" + # Individual calendar export stays as-is. with open(filename, "wb") as f: f.write(cal.to_ical()) print(f" -> {filename}") - for component in cal.walk(): - if component.name != "VCALENDAR": - combined.add_component(component) + # Normalize components for the combined calendar. + add_normalized_components( + combined, + cal, + timezones, + ) + + # Add each VTIMEZONE exactly once. + # + # Put them before events/components in the resulting VCALENDAR. + normalized = Calendar() + normalized.add("prodid", "-//Combined calendar//") + normalized.add("version", "2.0") + + for tzid, timezone in timezones.items(): + print(f" timezone: {tzid}") + normalized.add_component(timezone) + + for component in combined.subcomponents: + normalized.add_component(component) combined_file = output_dir / "all.ics" with open(combined_file, "wb") as f: - f.write(combined.to_ical()) + f.write(normalized.to_ical()) print() print(f"Combined export: {combined_file}")