1+ from __future__ import annotations
2+
13from dataclasses import dataclass , field
24
3- from icalendar import Calendar , Component
5+ from icalendar import Calendar , Component , Timezone
46from x_wr_timezone import to_standard
57
68ComponentId = tuple [str , int , str | None ]
79
10+ # Avoids expensive re-generation of VTIMEZONE components across CalendarMerger
11+ # instances.
12+ _timezone_cache : dict [str , Timezone ] = {}
13+
14+ DEFAULT_COMPONENTS = ["VEVENT" , "VTIMEZONE" ]
15+
816
917def calendars_from_ical (data : bytes ) -> list [Calendar ]:
1018 """Parse ICS data, returning one Calendar per VCALENDAR component found."""
@@ -52,6 +60,8 @@ def __init__(
5260 version : str = "2.0" ,
5361 calscale : str = "GREGORIAN" ,
5462 method : str | None = None ,
63+ generate_vtimezone : bool = True ,
64+ components : list [str ] | None = None ,
5565 ):
5666 self .merged_calendar = Calendar ()
5767
@@ -64,13 +74,67 @@ def __init__(
6474
6575 self .calendars : list [Calendar ] = []
6676 self ._merged = False
77+ self .generate_vtimezone = generate_vtimezone
78+ self .components = (
79+ list (components ) if components is not None else list (DEFAULT_COMPONENTS )
80+ )
6781
6882 for calendar in calendars :
6983 self .add_calendar (calendar )
7084
85+ def _get_components (self , cal : Calendar ) -> list [Component ]:
86+ result : list [Component ] = []
87+ if "VEVENT" in self .components :
88+ result .extend (cal .events )
89+ if "VTODO" in self .components :
90+ result .extend (cal .todos )
91+ if "VJOURNAL" in self .components :
92+ result .extend (cal .walk ("VJOURNAL" ))
93+ return result
94+
95+ def _should_generate_timezones (self ) -> bool :
96+ return "VTIMEZONE" in self .components and self .generate_vtimezone
97+
7198 def add_calendar (self , calendar : Calendar ) -> None :
7299 """Add a calendar to be merged."""
73- self .calendars .append (to_standard (calendar , add_timezone_component = True ))
100+ cal = to_standard (
101+ calendar , add_timezone_component = self ._should_generate_timezones ()
102+ )
103+
104+ if self ._should_generate_timezones ():
105+ for tz in cal .timezones :
106+ if tz .tz_name not in _timezone_cache :
107+ _timezone_cache [tz .tz_name ] = tz
108+
109+ # to_standard() may add a VTIMEZONE even when one already exists for
110+ # the same TZID, causing get_missing_tzids() to raise KeyError
111+ # (collective/icalendar#1124). Deduplicate before calling
112+ # add_missing_timezones().
113+ seen_tzids : set [str ] = set ()
114+ deduped : list [Component ] = []
115+ for c in cal .subcomponents :
116+ if isinstance (c , Timezone ):
117+ if c .tz_name in seen_tzids :
118+ continue
119+ seen_tzids .add (c .tz_name )
120+ deduped .append (c )
121+ cal .subcomponents [:] = deduped
122+
123+ if cal .get_used_tzids ():
124+ missing_tzids = cal .get_missing_tzids ()
125+
126+ for tzid in missing_tzids :
127+ if tzid in _timezone_cache :
128+ cal .add_component (_timezone_cache [tzid ])
129+
130+ remaining = cal .get_missing_tzids ()
131+ if remaining :
132+ cal .add_missing_timezones ()
133+ for tz in cal .timezones :
134+ if tz .tz_name in remaining :
135+ _timezone_cache [tz .tz_name ] = tz
136+
137+ self .calendars .append (cal )
74138
75139 def merge (self ) -> Calendar :
76140 """Merge the calendars."""
@@ -90,20 +154,36 @@ def merge(self) -> Calendar:
90154 if calendar_color and not self .merged_calendar .color :
91155 self .merged_calendar .color = calendar_color
92156
93- for timezone in cal .timezones :
94- if timezone .tz_name not in tzids :
95- self .merged_calendar .add_component (timezone )
96- tzids .add (timezone .tz_name )
97-
98- for component in cal .events + cal .todos + cal .journals :
157+ if "VTIMEZONE" in self .components :
158+ for timezone in cal .timezones :
159+ if timezone .tz_name == "UTC" :
160+ # UTC needs no VTIMEZONE per RFC 5545 §3.6.5; skip spurious
161+ # entries generated by add_missing_timezones()
162+ # (collective/icalendar#1124)
163+ continue
164+ if timezone .tz_name not in tzids :
165+ self .merged_calendar .add_component (timezone )
166+ tzids .add (timezone .tz_name )
167+
168+ for component in self ._get_components (cal ):
99169 tracker .add (component , calendar_color )
100170
101171 return self .merged_calendar
102172
103173
104- def merge_calendars (calendars : list [Calendar ], ** kwargs : object ) -> Calendar :
174+ def merge_calendars (
175+ calendars : list [Calendar ],
176+ generate_vtimezone : bool = True ,
177+ components : list [str ] | None = None ,
178+ ** kwargs : object ,
179+ ) -> Calendar :
105180 """Convenience function to merge calendars."""
106- merger = CalendarMerger (calendars , ** kwargs ) # type: ignore
181+ merger = CalendarMerger (
182+ calendars ,
183+ generate_vtimezone = generate_vtimezone ,
184+ components = components ,
185+ ** kwargs , # type: ignore[arg-type]
186+ )
107187 return merger .merge ()
108188
109189
0 commit comments