Skip to content

Commit 42044a0

Browse files
feat: generate VTIMEZONEs and select components to merge
1 parent dec2f98 commit 42044a0

9 files changed

Lines changed: 372 additions & 16 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ classifiers = [
2525
]
2626

2727
dependencies = [
28-
"icalendar>=7.0.2",
28+
"icalendar>=7.0.3",
2929
"rich>=10",
3030
"typer>=0.15,<1",
3131
"x-wr-timezone>=2.0.1"

src/mergecal/calendar_merger.py

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
from __future__ import annotations
2+
13
from dataclasses import dataclass, field
24

3-
from icalendar import Calendar, Component
5+
from icalendar import Calendar, Component, Timezone
46
from x_wr_timezone import to_standard
57

68
ComponentId = 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

917
def 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

src/mergecal/cli.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,22 @@
77

88
app = typer.Typer()
99

10-
# Define arguments and options outside of the function
1110
calendars_arg = typer.Argument(..., help="Paths to the calendar files to merge")
1211
output_opt = typer.Option(
1312
"merged_calendar.ics", "--output", "-o", help="Output file path"
1413
)
1514
prodid_opt = typer.Option(None, "--prodid", help="Product ID for the merged calendar")
1615
method_opt = typer.Option(None, "--method", help="Calendar method")
16+
no_generate_vtimezone_opt = typer.Option(
17+
False,
18+
"--no-generate-vtimezone",
19+
help="Disable VTIMEZONE generation for performance",
20+
)
21+
components_opt = typer.Option(
22+
None,
23+
"--components",
24+
help="Comma-separated component types to merge (VEVENT,VTODO,VJOURNAL,VTIMEZONE)",
25+
)
1726

1827

1928
@app.command()
@@ -22,6 +31,8 @@ def main(
2231
output: Path = output_opt,
2332
prodid: str | None = prodid_opt,
2433
method: str | None = method_opt,
34+
no_generate_vtimezone: bool = no_generate_vtimezone_opt,
35+
components: str | None = components_opt,
2536
) -> None:
2637
"""Merge multiple iCalendar files into one."""
2738
try:
@@ -31,7 +42,15 @@ def main(
3142
calendar_objects.extend(calendars_from_ical(cal_file.read()))
3243

3344
merger = CalendarMerger(
34-
calendars=calendar_objects, prodid=prodid, method=method
45+
calendars=calendar_objects,
46+
prodid=prodid,
47+
method=method,
48+
generate_vtimezone=not no_generate_vtimezone,
49+
components=(
50+
[p.strip() for p in components.split(",") if p.strip()]
51+
if components
52+
else None
53+
),
3554
)
3655
merged_calendar = merger.merge()
3756

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
BEGIN:VCALENDAR
2+
PRODID:-//Google Inc//Google Calendar 70.9054//EN
3+
VERSION:2.0
4+
CALSCALE:GREGORIAN
5+
METHOD:PUBLISH
6+
X-WR-CALNAME:Test Calendar
7+
X-WR-TIMEZONE:America/New_York
8+
BEGIN:VEVENT
9+
DTSTART;TZID=America/New_York:20240315T090000
10+
DTEND;TZID=America/New_York:20240315T100000
11+
DTSTAMP:20240301T120000Z
12+
UID:test-event-1@google.com
13+
CREATED:20240301T120000Z
14+
DESCRIPTION:Test event with timezone reference but no VTIMEZONE component
15+
LAST-MODIFIED:20240301T120000Z
16+
SEQUENCE:0
17+
STATUS:CONFIRMED
18+
SUMMARY:Meeting in New York timezone
19+
TRANSP:OPAQUE
20+
END:VEVENT
21+
BEGIN:VEVENT
22+
DTSTART;TZID=Europe/London:20240320T140000
23+
DTEND;TZID=Europe/London:20240320T150000
24+
DTSTAMP:20240301T120000Z
25+
UID:test-event-2@google.com
26+
CREATED:20240301T120000Z
27+
DESCRIPTION:Another event with different timezone but no VTIMEZONE
28+
LAST-MODIFIED:20240301T120000Z
29+
SEQUENCE:0
30+
STATUS:CONFIRMED
31+
SUMMARY:London meeting
32+
TRANSP:OPAQUE
33+
END:VEVENT
34+
END:VCALENDAR
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
BEGIN:VCALENDAR
2+
PRODID:-//Test//Test//EN
3+
VERSION:2.0
4+
CALSCALE:GREGORIAN
5+
END:VCALENDAR
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
BEGIN:VCALENDAR
2+
PRODID:-//Test//Test//EN
3+
VERSION:2.0
4+
CALSCALE:GREGORIAN
5+
BEGIN:VEVENT
6+
UID:test-single-event@test.com
7+
SUMMARY:Test Single Event
8+
DTSTART;TZID=America/Chicago:20240101T120000
9+
DTEND;TZID=America/Chicago:20240101T130000
10+
DTSTAMP:20240101T000000Z
11+
END:VEVENT
12+
END:VCALENDAR

tests/test_color_merging.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44

55

66
def test_component_inherits_calendar_color(calendars, component_type):
7-
result = merge_calendars(calendars.color_rfc7986.stream)
7+
result = merge_calendars(
8+
calendars.color_rfc7986.stream,
9+
components=["VEVENT", "VTODO", "VJOURNAL"],
10+
)
811
assert result.walk(component_type)[0].color == "turquoise"
912

1013

@@ -14,7 +17,10 @@ def test_event_inherits_apple_calendar_color(calendars):
1417

1518

1619
def test_component_own_color_not_overwritten(calendars, component_type):
17-
result = merge_calendars(calendars.color_event_own.stream)
20+
result = merge_calendars(
21+
calendars.color_event_own.stream,
22+
components=["VEVENT", "VTODO", "VJOURNAL"],
23+
)
1824
assert result.walk(component_type)[0].color == "navy"
1925

2026

@@ -38,7 +44,7 @@ def test_merged_calendar_color_when_only_one_has_color(calendars):
3844

3945
def test_component_own_color_preserved_across_calendars(calendars, component_type):
4046
cals = calendars.color_event_own.stream + calendars.color_rfc7986.stream
41-
result = merge_calendars(cals)
47+
result = merge_calendars(cals, components=["VEVENT", "VTODO", "VJOURNAL"])
4248
assert result.walk(component_type)[0].color == "navy"
4349

4450

0 commit comments

Comments
 (0)