Skip to content
Merged
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
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
37 changes: 24 additions & 13 deletions src/mergecal/calendar_merger.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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

Expand Down
36 changes: 36 additions & 0 deletions tests/calendars/x_wr_timezone.ics
Original file line number Diff line number Diff line change
@@ -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
16 changes: 15 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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):
Expand Down
29 changes: 29 additions & 0 deletions tests/test_issue_1_include_timezones.py
Original file line number Diff line number Diff line change
@@ -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"
14 changes: 14 additions & 0 deletions tests/test_merge_calendars.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion tests/test_with_doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
16 changes: 16 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.