diff --git a/README.md b/README.md index b73c087..ed48de3 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,8 @@ You can use MergeCal in your Python code as follows: >>> merged_calendar : Calendar = merge_calendars([calendar1, calendar2]) # Write the merged calendar to a file ->>> with (CALENDARS / "merged_calendar.ics").open("wb") as f: -... f.write(merged_calendar.to_ical()) -559 +>>> (CALENDARS / "merged_calendar.ics").write_bytes(merged_calendar.to_ical()) +933 # The merged calendar will contain all the events of both calendars >>> [str(event["SUMMARY"]) for event in calendar1.walk("VEVENT")] diff --git a/pyproject.toml b/pyproject.toml index dbb9c40..ece3a76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "icalendar>=6.1.1", "rich>=10", "typer>=0.15,<1", + "x-wr-timezone>=2.0.1" ] urls."Bug Tracker" = "https://github.com/abe-101/mergecal/issues" urls.Changelog = "https://github.com/abe-101/mergecal/blob/main/CHANGELOG.md" diff --git a/src/mergecal/calendar_merger.py b/src/mergecal/calendar_merger.py index b726197..0e08ae1 100644 --- a/src/mergecal/calendar_merger.py +++ b/src/mergecal/calendar_merger.py @@ -1,6 +1,7 @@ from typing import Optional -from icalendar import Calendar +from icalendar import Calendar, Event +from x_wr_timezone import to_standard def generate_default_prodid() -> str: @@ -18,9 +19,6 @@ def __init__( version: str = "2.0", method: Optional[str] = None, ): - if not calendars: - raise ValueError("At least one calendar must be provided") - self.merged_calendar = Calendar() # Set required properties @@ -31,29 +29,42 @@ def __init__( if method: self.merged_calendar.add("method", method) - self.calendars: list[Calendar] = calendars + self.calendars: list[Calendar] = [] + + for calendar in calendars: + self.add_calendar(calendar) def add_calendar(self, calendar: Calendar) -> None: """Add a calendar to be merged.""" - self.calendars.append(calendar) + self.calendars.append(to_standard(calendar, add_timezone_component=True)) def merge(self) -> Calendar: """Merge the calendars.""" - existing_uids = set() + existing_uids: set[tuple[Optional[str], int, Optional[str]]] = set() + no_uid_events: list[Event] = [] + tzids: set[str] = set() for cal in self.calendars: - for component in cal.events: - uid = component.get("uid", None) - sequence = component.get("sequence", 0) - recurrence_id = component.get("recurrence-id", None) + for timezone in cal.timezones: + if timezone.tz_name not in tzids: + self.merged_calendar.add_component(timezone) + tzids.add(timezone.tz_name) + for event in cal.events: + uid = event.get("uid", None) + sequence = event.get("sequence", 0) + recurrence_id = event.get("recurrence-id", None) # Create a unique identifier for the component component_id = (uid, sequence, recurrence_id) - if uid is not None and component_id in existing_uids: + if uid is None: + if event in no_uid_events: + continue + no_uid_events.append(event) + elif component_id in existing_uids: continue existing_uids.add(component_id) - self.merged_calendar.add_component(component) + self.merged_calendar.add_component(event) return self.merged_calendar diff --git a/tests/calendars/x_wr_timezone.ics b/tests/calendars/x_wr_timezone.ics new file mode 100644 index 0000000..4538286 --- /dev/null +++ b/tests/calendars/x_wr_timezone.ics @@ -0,0 +1,36 @@ +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:This calendar features two events of which DTSTART and DTEND must be changed. +X-WR-TIMEZONE:America/New_York +BEGIN:VEVENT +DTSTART:20211222T170000Z +DTEND:20211222T180000Z +DTSTAMP:20211228T180046Z +UID:3bc4jff97631or97ntnk75n4se@google.com +CREATED:20211222T190737Z +DESCRIPTION: +LAST-MODIFIED:20211222T190947Z +LOCATION: +SEQUENCE:2 +STATUS:CONFIRMED +SUMMARY:Google Calendar says this is noon to 1PM on 12/22/2021 +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +DTSTART:20211223T020000Z +DTEND:20211223T030000Z +DTSTAMP:20211228T180046Z +UID:14n7h56i35m32ukcq76s46d45p@google.com +CREATED:20211222T190622Z +DESCRIPTION: +LAST-MODIFIED:20211222T190622Z +LOCATION: +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Google says this is 9PM to 10PM on 12/22/2021 +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/tests/conftest.py b/tests/conftest.py index f97b912..e559dda 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,9 @@ def merge_func(calendars: list[str]) -> icalendar.Calendar: """Use the main merge function to merge the calendars.""" icalendars = [] for calendar in calendars: + if isinstance(calendar, icalendar.Calendar): + icalendars.append(calendar) + continue if not calendar.endswith(".ics"): calendar += ".ics" calendar_path = CALENDARS_DIR / calendar @@ -48,12 +51,17 @@ class ICSCalendars: def get_calendar(self, content): """Return the calendar given the content.""" - return icalendar.Calendar(content) + return icalendar.Calendar.from_ical(content) def __getitem__(self, name): """Return the calendar from the calendars directory.""" return getattr(self, name) + @staticmethod + def keys() -> list[str]: + """The names of all calendars.""" + return [calendar_path.stem for calendar_path in CALENDARS_DIR.iterdir()] + for calendar_path in CALENDARS_DIR.iterdir(): content = calendar_path.read_bytes() @@ -72,6 +80,12 @@ def calendars() -> ICSCalendars: return ICSCalendars() +@pytest.fixture(params=ICSCalendars.keys()) +def a_calendar(request): + """Return a calendar.""" + return request.param + + def doctest_print(obj): """Doctest print.""" if isinstance(obj, bytes): diff --git a/tests/test_issue_1_include_timezones.py b/tests/test_issue_1_include_timezones.py new file mode 100644 index 0000000..d530b3a --- /dev/null +++ b/tests/test_issue_1_include_timezones.py @@ -0,0 +1,29 @@ +"""The timezones are a valuable part of the calendar and must be included.""" + +from icalendar import Calendar + + +def test_merged_calendars_include_a_timezone(merge, calendars): + """The timezones should be included.""" + calendar: Calendar = merge([calendars.one_event]) + assert len(calendar.timezones) == 1 + assert calendar.timezones[0].tz_name == "Europe/Berlin" + + +def test_merge_with_no_calendars(merge): + """No calendars no timezone.""" + calendar: Calendar = merge([]) + assert len(calendar.timezones) == 0 + + +def test_empty_calendar_has_no_timezone(merge): + """Empty calendar merged ahs no timezone.""" + calendar: Calendar = merge([Calendar()]) + assert len(calendar.timezones) == 0 + + +def test_x_wr_timezone_is_included(merge, calendars): + """The x-wr-timezone property should create a timezone.""" + calendar: Calendar = merge([calendars.x_wr_timezone]) + assert calendar.timezones[0].tz_name == "America/New_York" + assert calendar.events[1].start.tzname() == "EST" diff --git a/tests/test_merge_calendars.py b/tests/test_merge_calendars.py index d2b71cb..2ec7407 100644 --- a/tests/test_merge_calendars.py +++ b/tests/test_merge_calendars.py @@ -37,3 +37,17 @@ def test_merge_calendars_with_recurring_events(merge): assert modified_event.get("sequence", 0) > recurring_event.get("sequence", 0), ( "Modified event should have a higher sequence number" ) + + +def test_merging_a_calendar_with_itself_returns_the_same_calendar(merge, a_calendar): + """The calendar should not be duplicated.""" + cal_1 = merge([a_calendar]) + cal_2 = merge([a_calendar, a_calendar]) + assert cal_1 == cal_2 + + +def test_merging_is_reproducible(merge, a_calendar): + """We expect the same result when merging a calendar.""" + cal_1 = merge([a_calendar]) + cal_2 = merge([a_calendar]) + assert cal_1 == cal_2 diff --git a/tests/test_with_doctest.py b/tests/test_with_doctest.py index 8a4841c..1e6d630 100644 --- a/tests/test_with_doctest.py +++ b/tests/test_with_doctest.py @@ -47,4 +47,4 @@ def test_documentation_file(document, env_for_doctest): globs=env_for_doctest, raise_on_error=False, ) - assert test_result.failed == 0, f"{test_result.failed} errors in {document.name}" + assert test_result.failed == 0, f"{test_result.failed} errors in {document}" diff --git a/uv.lock b/uv.lock index 0532530..5e1a81a 100644 --- a/uv.lock +++ b/uv.lock @@ -430,6 +430,7 @@ dependencies = [ { name = "icalendar" }, { name = "rich" }, { name = "typer" }, + { name = "x-wr-timezone" }, ] [package.dev-dependencies] @@ -449,6 +450,7 @@ requires-dist = [ { name = "icalendar", specifier = ">=6.1.1" }, { name = "rich", specifier = ">=10" }, { name = "typer", specifier = ">=0.15,<1" }, + { name = "x-wr-timezone", specifier = ">=2.0.1" }, ] [package.metadata.requires-dev] @@ -1048,3 +1050,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/cc/adc9fb85f031b8df8e9f3d96cc004df25d2643e503953af5223c5b6825b7/websockets-14.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3c1426c021c38cf92b453cdf371228d3430acd775edee6bac5a4d577efc72365", size = 164457 }, { url = "https://files.pythonhosted.org/packages/7b/c8/d529f8a32ce40d98309f4470780631e971a5a842b60aec864833b3615786/websockets-14.2-py3-none-any.whl", hash = "sha256:7a6ceec4ea84469f15cf15807a747e9efe57e369c384fa86e022b3bea679b79b", size = 157416 }, ] + +[[package]] +name = "x-wr-timezone" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "icalendar" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/2b/8ae5f59ab852c8fe32dd37c1aa058eb98aca118fec2d3af5c3cd56fffb7b/x_wr_timezone-2.0.1.tar.gz", hash = "sha256:9166c40e6ffd4c0edebabc354e1a1e2cffc1bb473f88007694793757685cc8c3", size = 18212 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/b7/4bac35b4079b76c07d8faddf89467e9891b1610cfe8d03b0ebb5610e4423/x_wr_timezone-2.0.1-py3-none-any.whl", hash = "sha256:e74a53b9f4f7def8138455c240e65e47c224778bce3c024fcd6da2cbe91ca038", size = 11102 }, +]