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
102 changes: 97 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", "VTIMEZONE"]


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,25 @@ 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
types: VEVENT, VTODO, VJOURNAL, VTIMEZONE. Pass a subset to filter.

"""
self.merged_calendar = Calendar()

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

self.calendars: list[Calendar] = []
self._merged = False
self.generate_vtimezone = generate_vtimezone
self._timezone_cache: dict[str, Timezone] = {}
self.components = (
[c.strip().upper() for c in components if c.strip()]
if components is not None
else 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 _should_generate_timezones(self) -> bool:
return self.generate_vtimezone

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._should_generate_timezones()
)

if self._should_generate_timezones():
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 +170,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")
no_generate_vtimezone_opt = typer.Option(
False,
"--no-generate-vtimezone",
help=(
"Do not generate missing VTIMEZONE components although they might be"
" required. This increases performance."
),
)
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,
no_generate_vtimezone: bool = no_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=not no_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
Loading
Loading