Skip to content

Commit fe46a3c

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

10 files changed

Lines changed: 376 additions & 20 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: 92 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,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

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

tests/test_component_selection.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Select which components to merge (Issue #182)."""
2+
3+
from mergecal import CalendarMerger, merge_calendars
4+
5+
6+
def test_default_components_vevent_and_vtimezone_only(calendars):
7+
"""Only VEVENT and VTIMEZONE are merged by default."""
8+
result = merge_calendars(calendars.color_rfc7986.stream)
9+
10+
assert len(list(result.events)) == 1
11+
assert len(list(result.todos)) == 0
12+
assert len(list(result.walk("VJOURNAL"))) == 0
13+
14+
15+
def test_select_events_only(calendars):
16+
"""Only VEVENTs are merged; no timezones when VTIMEZONE excluded."""
17+
result = merge_calendars(calendars.color_rfc7986.stream, components=["VEVENT"])
18+
19+
assert len(list(result.events)) == 1
20+
assert len(list(result.todos)) == 0
21+
assert len(list(result.walk("VJOURNAL"))) == 0
22+
assert len(list(result.timezones)) == 0
23+
24+
25+
def test_select_todos_only(calendars):
26+
"""Only VTODOs are merged."""
27+
result = merge_calendars(calendars.color_rfc7986.stream, components=["VTODO"])
28+
29+
assert len(list(result.events)) == 0
30+
assert len(list(result.todos)) == 1
31+
assert len(list(result.timezones)) == 0
32+
33+
34+
def test_select_journals_only(calendars):
35+
"""Only VJOURNALs are merged."""
36+
result = merge_calendars(calendars.color_rfc7986.stream, components=["VJOURNAL"])
37+
38+
assert len(list(result.events)) == 0
39+
assert len(list(result.todos)) == 0
40+
assert len(list(result.walk("VJOURNAL"))) == 1
41+
assert len(list(result.timezones)) == 0
42+
43+
44+
def test_select_events_and_timezones(calendars):
45+
"""Timezones are generated when VTIMEZONE is included in components."""
46+
result = merge_calendars(
47+
calendars.no_vtimezone_google.stream, components=["VEVENT", "VTIMEZONE"]
48+
)
49+
50+
assert len(list(result.events)) == 2
51+
assert len(list(result.timezones)) > 0
52+
53+
54+
def test_calendar_merger_components_parameter(calendars):
55+
"""CalendarMerger respects the components parameter."""
56+
result = CalendarMerger(
57+
calendars.color_rfc7986.stream, components=["VEVENT", "VTODO"]
58+
).merge()
59+
60+
assert len(list(result.events)) == 1
61+
assert len(list(result.todos)) == 1
62+
assert len(list(result.walk("VJOURNAL"))) == 0
63+
assert len(list(result.timezones)) == 0
64+
65+
66+
def test_empty_components_list(calendars):
67+
"""An empty components list produces an empty calendar."""
68+
result = merge_calendars(calendars.color_rfc7986.stream, components=[])
69+
70+
assert len(list(result.events)) == 0
71+
assert len(list(result.todos)) == 0
72+
assert len(list(result.walk("VJOURNAL"))) == 0
73+
assert len(list(result.timezones)) == 0
74+
75+
76+
def test_timezone_generation_only_when_vtimezone_in_components(calendars):
77+
"""generate_vtimezone has no effect when VTIMEZONE is not in the components list."""
78+
cals = calendars.no_vtimezone_google.stream
79+
80+
result_with = merge_calendars(
81+
cals, components=["VEVENT", "VTIMEZONE"], generate_vtimezone=True
82+
)
83+
result_without = merge_calendars(
84+
cals, components=["VEVENT"], generate_vtimezone=True
85+
)
86+
87+
assert len(list(result_with.timezones)) > 0
88+
assert len(list(result_without.timezones)) == 0
89+
90+
91+
def test_multiple_calendars_component_filtering(calendars):
92+
"""Component filtering applies across all merged calendars."""
93+
cals = calendars.one_event.stream + calendars.color_rfc7986.stream
94+
result = merge_calendars(cals, components=["VEVENT"])
95+
96+
assert len(list(result.events)) == 2
97+
assert len(list(result.todos)) == 0
98+
assert len(list(result.walk("VJOURNAL"))) == 0
99+
100+
101+
def test_unknown_components_silently_ignored(calendars):
102+
"""Unknown component names do not crash the merger."""
103+
cals = calendars.test_empty_calendar.stream
104+
105+
result = CalendarMerger(cals, components=["VEVENT", "UNKNOWN"]).merge()
106+
107+
assert len(list(result.events)) == 0

0 commit comments

Comments
 (0)