Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ classifiers = [
]

dependencies = [
"icalendar>=7.0.2",
"icalendar>=7.0.3",
"rich>=10",
"typer>=0.15,<1",
"x-wr-timezone>=2.0.1"
Expand Down
97 changes: 92 additions & 5 deletions src/mergecal/calendar_merger.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from __future__ import annotations

from dataclasses import dataclass, field

from icalendar import Calendar, Component
from icalendar import Calendar, Component, Timezone
from x_wr_timezone import to_standard

ComponentId = tuple[str, int, str | None]

DEFAULT_COMPONENTS = ["VEVENT", "VTODO", "VJOURNAL"]


def calendars_from_ical(data: bytes) -> list[Calendar]:
"""Parse ICS data, returning one Calendar per VCALENDAR component found."""
Expand Down Expand Up @@ -52,7 +56,26 @@ def __init__(
version: str = "2.0",
calscale: str = "GREGORIAN",
method: str | None = None,
generate_vtimezone: bool = True,
components: list[str] | None = None,
):
"""
Initialize the merger.

Args:
calendars: Calendars to merge.
prodid: PRODID for the merged calendar. Defaults to the mergecal PRODID.
version: iCalendar version. Defaults to "2.0".
calscale: Calendar scale. Defaults to "GREGORIAN".
method: Calendar method (e.g. "PUBLISH").
generate_vtimezone: Generate missing VTIMEZONE components for any
referenced timezone IDs. Disable for performance when timezone
accuracy is not needed.
components: Component types to include in the merge. Defaults to all
event-like types: VEVENT, VTODO, VJOURNAL. Pass a subset to filter.
VTIMEZONEs are always included when present or generated.

"""
self.merged_calendar = Calendar()

self.merged_calendar.add("prodid", prodid or generate_default_prodid())
Expand All @@ -64,13 +87,64 @@ def __init__(

self.calendars: list[Calendar] = []
self._merged = False
self.generate_vtimezone = generate_vtimezone
self._timezone_cache: dict[str, Timezone] = {}
if components is not None:
self.components = [c.strip().upper() for c in components if c.strip()]
else:
self.components = list(DEFAULT_COMPONENTS)

for calendar in calendars:
self.add_calendar(calendar)

def _get_components(self, cal: Calendar) -> list[Component]:
result: list[Component] = []
if "VEVENT" in self.components:
result.extend(cal.events)
if "VTODO" in self.components:
result.extend(cal.todos)
if "VJOURNAL" in self.components:
result.extend(cal.journals)
return result

def add_calendar(self, calendar: Calendar) -> None:
"""Add a calendar to be merged."""
self.calendars.append(to_standard(calendar, add_timezone_component=True))
cal = to_standard(calendar, add_timezone_component=self.generate_vtimezone)

if self.generate_vtimezone:
for tz in cal.timezones:
if tz.tz_name not in self._timezone_cache:
self._timezone_cache[tz.tz_name] = tz

# to_standard() may add a duplicate VTIMEZONE for the same TZID when
# the calendar already has one; deduplicate before calling
# add_missing_timezones(). Also drop VTIMEZONEs not referenced by any
# component — get_missing_tzids() assumes every VTIMEZONE is used.
used_tzids = cal.get_used_tzids()
seen_tzids: set[str] = set()
deduped: list[Component] = []
for c in cal.subcomponents:
if isinstance(c, Timezone):
if c.tz_name in seen_tzids or c.tz_name not in used_tzids:
continue
seen_tzids.add(c.tz_name)
deduped.append(c)
cal.subcomponents[:] = deduped

missing_tzids = cal.get_missing_tzids()
if missing_tzids:
for tzid in missing_tzids:
if tzid in self._timezone_cache:
cal.add_component(self._timezone_cache[tzid])

remaining = cal.get_missing_tzids()
if remaining:
cal.add_missing_timezones()
for tz in cal.timezones:
if tz.tz_name in remaining:
self._timezone_cache[tz.tz_name] = tz

self.calendars.append(cal)

def merge(self) -> Calendar:
"""Merge the calendars."""
Expand All @@ -91,19 +165,32 @@ def merge(self) -> Calendar:
self.merged_calendar.color = calendar_color

for timezone in cal.timezones:
if timezone.tz_name == "UTC":
# UTC needs no VTIMEZONE per RFC 5545 §3.6.5
continue
if timezone.tz_name not in tzids:
self.merged_calendar.add_component(timezone)
tzids.add(timezone.tz_name)

for component in cal.events + cal.todos + cal.journals:
for component in self._get_components(cal):
tracker.add(component, calendar_color)

return self.merged_calendar


def merge_calendars(calendars: list[Calendar], **kwargs: object) -> Calendar:
def merge_calendars(
calendars: list[Calendar],
generate_vtimezone: bool = True,
components: list[str] | None = None,
**kwargs: object,
) -> Calendar:
"""Convenience function to merge calendars."""
merger = CalendarMerger(calendars, **kwargs) # type: ignore
merger = CalendarMerger(
calendars,
generate_vtimezone=generate_vtimezone,
components=components,
**kwargs, # type: ignore[arg-type]
)
return merger.merge()


Expand Down
26 changes: 24 additions & 2 deletions src/mergecal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,25 @@

app = typer.Typer()

# Define arguments and options outside of the function
calendars_arg = typer.Argument(..., help="Paths to the calendar files to merge")
output_opt = typer.Option(
"merged_calendar.ics", "--output", "-o", help="Output file path"
)
prodid_opt = typer.Option(None, "--prodid", help="Product ID for the merged calendar")
method_opt = typer.Option(None, "--method", help="Calendar method")
generate_vtimezone_opt = typer.Option(
True,
"--generate-vtimezone/--no-generate-vtimezone",
help=(
"Generate missing VTIMEZONE components. Disable for performance when"
" timezone accuracy is not needed."
),
)
components_opt = typer.Option(
None,
"--components",
help="Comma-separated component types to merge (VEVENT,VTODO,VJOURNAL,VTIMEZONE)",
)


@app.command()
Expand All @@ -22,6 +34,8 @@ def main(
output: Path = output_opt,
prodid: str | None = prodid_opt,
method: str | None = method_opt,
generate_vtimezone: bool = generate_vtimezone_opt,
components: str | None = components_opt,
) -> None:
"""Merge multiple iCalendar files into one."""
try:
Expand All @@ -31,7 +45,15 @@ def main(
calendar_objects.extend(calendars_from_ical(cal_file.read()))

merger = CalendarMerger(
calendars=calendar_objects, prodid=prodid, method=method
calendars=calendar_objects,
prodid=prodid,
method=method,
generate_vtimezone=generate_vtimezone,
components=(
[p.strip() for p in components.split(",") if p.strip()]
if components
else None
),
)
merged_calendar = merger.merge()

Expand Down
34 changes: 34 additions & 0 deletions tests/calendars/no_vtimezone_google.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:Test Calendar
X-WR-TIMEZONE:America/New_York
BEGIN:VEVENT
DTSTART;TZID=America/New_York:20240315T090000
DTEND;TZID=America/New_York:20240315T100000
DTSTAMP:20240301T120000Z
UID:test-event-1@google.com
CREATED:20240301T120000Z
DESCRIPTION:Test event with timezone reference but no VTIMEZONE component
LAST-MODIFIED:20240301T120000Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Meeting in New York timezone
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Europe/London:20240320T140000
DTEND;TZID=Europe/London:20240320T150000
DTSTAMP:20240301T120000Z
UID:test-event-2@google.com
CREATED:20240301T120000Z
DESCRIPTION:Another event with different timezone but no VTIMEZONE
LAST-MODIFIED:20240301T120000Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:London meeting
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
5 changes: 5 additions & 0 deletions tests/calendars/test_empty_calendar.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
BEGIN:VCALENDAR
PRODID:-//Test//Test//EN
VERSION:2.0
CALSCALE:GREGORIAN
END:VCALENDAR
12 changes: 12 additions & 0 deletions tests/calendars/test_single_event.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
PRODID:-//Test//Test//EN
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:test-single-event@test.com
SUMMARY:Test Single Event
DTSTART;TZID=America/Chicago:20240101T120000
DTEND;TZID=America/Chicago:20240101T130000
DTSTAMP:20240101T000000Z
END:VEVENT
END:VCALENDAR
12 changes: 9 additions & 3 deletions tests/test_color_merging.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@


def test_component_inherits_calendar_color(calendars, component_type):
result = merge_calendars(calendars.color_rfc7986.stream)
result = merge_calendars(
calendars.color_rfc7986.stream,
components=["VEVENT", "VTODO", "VJOURNAL"],
)
assert result.walk(component_type)[0].color == "turquoise"


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


def test_component_own_color_not_overwritten(calendars, component_type):
result = merge_calendars(calendars.color_event_own.stream)
result = merge_calendars(
calendars.color_event_own.stream,
components=["VEVENT", "VTODO", "VJOURNAL"],
)
assert result.walk(component_type)[0].color == "navy"


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

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


Expand Down
100 changes: 100 additions & 0 deletions tests/test_component_selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Select which components to merge (Issue #182)."""

import pytest

from mergecal import CalendarMerger, merge_calendars


def test_default_components_include_all_types(calendars):
"""All component types are merged by default."""
result = merge_calendars(calendars.color_rfc7986.stream)

assert len(list(result.events)) == 1
assert len(list(result.todos)) == 1
assert len(result.journals) == 1
assert len(list(result.timezones)) == 0


@pytest.mark.parametrize(
"components,num_events,num_todos,num_journals",
[
(["VEVENT"], 1, 0, 0),
(["VTODO"], 0, 1, 0),
(["VJOURNAL"], 0, 0, 1),
],
)
def test_select_single_component_type(
calendars, components, num_events, num_todos, num_journals
):
"""Only the selected component type is merged."""
result = merge_calendars(calendars.color_rfc7986.stream, components=components)

assert len(list(result.events)) == num_events
assert len(list(result.todos)) == num_todos
assert len(result.journals) == num_journals
assert len(list(result.timezones)) == 0


def test_events_include_required_timezones(calendars):
"""
VTIMEZONEs required by events are generated even without VTIMEZONE in components.

Timezones follow events automatically; the components list only filters event types.
"""
result = merge_calendars(
calendars.no_vtimezone_google.stream, components=["VEVENT"]
)

assert len(list(result.events)) == 2
assert "America/New_York" in {tz.tz_name for tz in result.timezones}
assert "Europe/London" in {tz.tz_name for tz in result.timezones}


def test_calendar_merger_components_parameter(calendars):
"""CalendarMerger respects the components parameter."""
result = CalendarMerger(
calendars.color_rfc7986.stream, components=["VEVENT", "VTODO"]
).merge()

assert len(list(result.events)) == 1
assert len(list(result.todos)) == 1
assert len(result.journals) == 0
assert len(list(result.timezones)) == 0


def test_empty_components_list(calendars):
"""components=[] produces no events/todos/journals; timezones are unaffected."""
result = merge_calendars(calendars.one_event.stream, components=[])

assert len(list(result.events)) == 0
assert len(list(result.todos)) == 0
assert len(result.journals) == 0
assert "Europe/Berlin" in {tz.tz_name for tz in result.timezones}


def test_generate_vtimezone_false_disables_generation(calendars):
"""generate_vtimezone=False suppresses all VTIMEZONE output."""
result = merge_calendars(
calendars.no_vtimezone_google.stream, generate_vtimezone=False
)

assert len(list(result.timezones)) == 0


def test_multiple_calendars_component_filtering(calendars):
"""Component filtering applies across all merged calendars."""
cals = calendars.one_event.stream + calendars.color_rfc7986.stream
result = merge_calendars(cals, components=["VEVENT"])

assert len(list(result.events)) == 2
assert len(list(result.todos)) == 0
assert len(result.journals) == 0


def test_unknown_components_silently_ignored(calendars):
"""Unknown component names do not crash the merger."""
result = CalendarMerger(
calendars.test_empty_calendar.stream, components=["VEVENT", "UNKNOWN"]
).merge()

assert len(list(result.events)) == 0
Loading
Loading