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,69 @@ def __init__(
6474
6575 self .calendars : list [Calendar ] = []
6676 self ._merged = False
77+ self .generate_vtimezone = generate_vtimezone
78+ self .components = (
79+ [c .strip ().upper () for c in components if c .strip ()]
80+ if components is not None
81+ else list (DEFAULT_COMPONENTS )
82+ )
6783
6884 for calendar in calendars :
6985 self .add_calendar (calendar )
7086
87+ def _get_components (self , cal : Calendar ) -> list [Component ]:
88+ result : list [Component ] = []
89+ if "VEVENT" in self .components :
90+ result .extend (cal .events )
91+ if "VTODO" in self .components :
92+ result .extend (cal .todos )
93+ if "VJOURNAL" in self .components :
94+ result .extend (cal .walk ("VJOURNAL" ))
95+ return result
96+
97+ def _should_generate_timezones (self ) -> bool :
98+ return "VTIMEZONE" in self .components and self .generate_vtimezone
99+
71100 def add_calendar (self , calendar : Calendar ) -> None :
72101 """Add a calendar to be merged."""
73- self .calendars .append (to_standard (calendar , add_timezone_component = True ))
102+ cal = to_standard (
103+ calendar , add_timezone_component = self ._should_generate_timezones ()
104+ )
105+
106+ if self ._should_generate_timezones ():
107+ for tz in cal .timezones :
108+ if tz .tz_name not in _timezone_cache :
109+ _timezone_cache [tz .tz_name ] = tz
110+
111+ # to_standard() may add a VTIMEZONE even when one already exists for
112+ # the same TZID, causing get_missing_tzids() to raise KeyError
113+ # (collective/icalendar#1124). Deduplicate before calling
114+ # add_missing_timezones().
115+ seen_tzids : set [str ] = set ()
116+ deduped : list [Component ] = []
117+ for c in cal .subcomponents :
118+ if isinstance (c , Timezone ):
119+ if c .tz_name in seen_tzids :
120+ continue
121+ seen_tzids .add (c .tz_name )
122+ deduped .append (c )
123+ cal .subcomponents [:] = deduped
124+
125+ if cal .get_used_tzids ():
126+ missing_tzids = cal .get_missing_tzids ()
127+
128+ for tzid in missing_tzids :
129+ if tzid in _timezone_cache :
130+ cal .add_component (_timezone_cache [tzid ])
131+
132+ remaining = cal .get_missing_tzids ()
133+ if remaining :
134+ cal .add_missing_timezones ()
135+ for tz in cal .timezones :
136+ if tz .tz_name in remaining :
137+ _timezone_cache [tz .tz_name ] = tz
138+
139+ self .calendars .append (cal )
74140
75141 def merge (self ) -> Calendar :
76142 """Merge the calendars."""
@@ -90,20 +156,36 @@ def merge(self) -> Calendar:
90156 if calendar_color and not self .merged_calendar .color :
91157 self .merged_calendar .color = calendar_color
92158
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 :
159+ if "VTIMEZONE" in self .components :
160+ for timezone in cal .timezones :
161+ if timezone .tz_name == "UTC" :
162+ # UTC needs no VTIMEZONE per RFC 5545 §3.6.5; skip spurious
163+ # entries generated by add_missing_timezones()
164+ # (collective/icalendar#1124)
165+ continue
166+ if timezone .tz_name not in tzids :
167+ self .merged_calendar .add_component (timezone )
168+ tzids .add (timezone .tz_name )
169+
170+ for component in self ._get_components (cal ):
99171 tracker .add (component , calendar_color )
100172
101173 return self .merged_calendar
102174
103175
104- def merge_calendars (calendars : list [Calendar ], ** kwargs : object ) -> Calendar :
176+ def merge_calendars (
177+ calendars : list [Calendar ],
178+ generate_vtimezone : bool = True ,
179+ components : list [str ] | None = None ,
180+ ** kwargs : object ,
181+ ) -> Calendar :
105182 """Convenience function to merge calendars."""
106- merger = CalendarMerger (calendars , ** kwargs ) # type: ignore
183+ merger = CalendarMerger (
184+ calendars ,
185+ generate_vtimezone = generate_vtimezone ,
186+ components = components ,
187+ ** kwargs , # type: ignore[arg-type]
188+ )
107189 return merger .merge ()
108190
109191
0 commit comments