import argparse import re from pathlib import Path import yaml from caldav import DAVClient from icalendar import Calendar 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: client = DAVClient( url=url, username=username, password=password, ) principal = client.principal() calendars = principal.calendars() target = None for cal in calendars: if cal.name == calendar: target = cal break if target is None: raise RuntimeError( f"Calendar '{calendar}' not found for {username}" ) print(f"Exporting {username}/{target.name}") result = Calendar() result.add("prodid", f"-//{calendar} calendar export//") result.add("version", "2.0") objects = target.objects() print(f" {len(objects)} objects") for obj in objects: data = obj.data if data is None: print(f" fetching {obj.url}") obj.load() data = obj.data if not data: print(f" skipping empty object {obj.url}") continue cal = Calendar.from_ical(data) # 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") parser.add_argument( "-o", "--output", default="/srv/http/calendars", ) args = parser.parse_args() with open(args.config) as f: config = yaml.safe_load(f) 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") # TZID -> VTIMEZONE component timezones = {} for entry in config["calendars"]: cal = export_calendar( entry["url"], username, password, entry["calendar"], ) 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}") # 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(normalized.to_ical()) print() print(f"Combined export: {combined_file}") if __name__ == "__main__": main()