Skip to content

Commit 7112605

Browse files
feat: merge RFC 7986 COLOR property for calendar components
1 parent c83241d commit 7112605

12 files changed

Lines changed: 620 additions & 540 deletions

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ jobs:
3636
fail-fast: false
3737
matrix:
3838
python-version:
39-
- "3.9"
4039
- "3.10"
4140
- "3.11"
4241
- "3.12"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ You can use MergeCal in your Python code as follows:
7575

7676
# Write the merged calendar to a file
7777
>>> (CALENDARS / "merged_calendar.ics").write_bytes(merged_calendar.to_ical())
78-
953
78+
998
7979

8080
# The merged calendar will contain all the events of both calendars
8181
>>> [str(event["SUMMARY"]) for event in calendar1.walk("VEVENT")]

pyproject.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,12 @@ license = { text = "GNU General Public License v3" }
1111
authors = [
1212
{ name = "Abe Hanoka", email = "abe@habet.dev" },
1313
]
14-
requires-python = ">=3.9"
14+
requires-python = ">=3.10"
1515
classifiers = [
1616
"Development Status :: 2 - Pre-Alpha",
1717
"Intended Audience :: Developers",
1818
"Natural Language :: English",
1919
"Operating System :: OS Independent",
20-
"Programming Language :: Python :: 3.9",
2120
"Programming Language :: Python :: 3.10",
2221
"Programming Language :: Python :: 3.11",
2322
"Programming Language :: Python :: 3.12",
@@ -26,7 +25,7 @@ classifiers = [
2625
]
2726

2827
dependencies = [
29-
"icalendar>=6.1.1",
28+
"icalendar>=7.0.0",
3029
"rich>=10",
3130
"typer>=0.15,<1",
3231
"x-wr-timezone>=2.0.1"
@@ -50,7 +49,7 @@ docs = [
5049
]
5150

5251
[tool.ruff]
53-
target-version = "py39"
52+
target-version = "py310"
5453
line-length = 88
5554
lint.select = [
5655
"B", # flake8-bugbear

src/mergecal/calendar_merger.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
from typing import Optional
2-
3-
from icalendar import Calendar, Event
1+
from icalendar import Calendar, Event, Journal, Todo
42
from x_wr_timezone import to_standard
53

64

@@ -20,19 +18,17 @@ class CalendarMerger:
2018
def __init__(
2119
self,
2220
calendars: list[Calendar],
23-
prodid: Optional[str] = None,
21+
prodid: str | None = None,
2422
version: str = "2.0",
2523
calscale: str = "GREGORIAN",
26-
method: Optional[str] = None,
24+
method: str | None = None,
2725
):
2826
self.merged_calendar = Calendar()
2927

30-
# Set required properties
3128
self.merged_calendar.add("prodid", prodid or generate_default_prodid())
3229
self.merged_calendar.add("version", version)
3330
self.merged_calendar.add("calscale", calscale)
3431

35-
# Set optional properties if provided
3632
if method:
3733
self.merged_calendar.add("method", method)
3834

@@ -47,32 +43,78 @@ def add_calendar(self, calendar: Calendar) -> None:
4743

4844
def merge(self) -> Calendar:
4945
"""Merge the calendars."""
50-
existing_uids: set[tuple[Optional[str], int, Optional[str]]] = set()
46+
seen_events: set[tuple[str | None, int, str | None]] = set()
47+
seen_todos: set[tuple[str | None, int, str | None]] = set()
48+
seen_journals: set[str] = set()
5149
no_uid_events: list[Event] = []
50+
no_uid_todos: list[Todo] = []
51+
no_uid_journals: list[Journal] = []
5252
tzids: set[str] = set()
5353
for cal in self.calendars:
54+
# .color resolves COLOR then X-APPLE-CALENDAR-COLOR (RFC 7986 §5.9)
55+
cal_color = cal.color
56+
57+
if cal_color and not self.merged_calendar.color:
58+
self.merged_calendar.color = cal_color
59+
5460
for timezone in cal.timezones:
5561
if timezone.tz_name not in tzids:
5662
self.merged_calendar.add_component(timezone)
5763
tzids.add(timezone.tz_name)
64+
5865
for event in cal.events:
5966
uid = event.get("uid", None)
6067
sequence = event.get("sequence", 0)
6168
recurrence_id = event.get("recurrence-id", None)
62-
63-
# Create a unique identifier for the component
6469
component_id = (uid, sequence, recurrence_id)
6570

6671
if uid is None:
6772
if event in no_uid_events:
6873
continue
6974
no_uid_events.append(event)
70-
elif component_id in existing_uids:
75+
elif component_id in seen_events:
7176
continue
7277

73-
existing_uids.add(component_id)
78+
if cal_color and not event.color:
79+
event.color = cal_color
80+
81+
seen_events.add(component_id)
7482
self.merged_calendar.add_component(event)
7583

84+
for todo in cal.todos:
85+
uid = todo.get("uid", None)
86+
recurrence_id = todo.get("recurrence-id", None)
87+
component_id = (uid, todo.get("sequence", 0), recurrence_id)
88+
89+
if uid is None:
90+
if todo in no_uid_todos:
91+
continue
92+
no_uid_todos.append(todo)
93+
elif component_id in seen_todos:
94+
continue
95+
96+
if cal_color and not todo.color:
97+
todo.color = cal_color
98+
99+
seen_todos.add(component_id)
100+
self.merged_calendar.add_component(todo)
101+
102+
for journal in cal.walk("VJOURNAL"):
103+
uid = journal.get("uid", None)
104+
105+
if uid is None:
106+
if journal in no_uid_journals:
107+
continue
108+
no_uid_journals.append(journal)
109+
elif uid in seen_journals:
110+
continue
111+
112+
if cal_color and not journal.color:
113+
journal.color = cal_color
114+
115+
seen_journals.add(uid)
116+
self.merged_calendar.add_component(journal)
117+
76118
return self.merged_calendar
77119

78120

src/mergecal/cli.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from pathlib import Path
2-
from typing import Optional
32

43
import typer
54
from rich import print
@@ -21,8 +20,8 @@
2120
def main(
2221
calendars: list[Path] = calendars_arg,
2322
output: Path = output_opt,
24-
prodid: Optional[str] = prodid_opt,
25-
method: Optional[str] = method_opt,
23+
prodid: str | None = prodid_opt,
24+
method: str | None = method_opt,
2625
) -> None:
2726
"""Merge multiple iCalendar files into one."""
2827
try:

tests/calendars/color_apple.ics

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//Test//Test//EN
4+
CALSCALE:GREGORIAN
5+
X-APPLE-CALENDAR-COLOR:#e78074
6+
BEGIN:VEVENT
7+
UID:color-apple-event-1@test
8+
SUMMARY:Event with Apple calendar color
9+
DTSTART:20190304T080000Z
10+
DTEND:20190304T083000Z
11+
DTSTAMP:20190303T111937Z
12+
END:VEVENT
13+
END:VCALENDAR
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//Test//Test//EN
4+
CALSCALE:GREGORIAN
5+
COLOR:plum
6+
BEGIN:VEVENT
7+
UID:color-event-own-1@test
8+
SUMMARY:Event with its own color
9+
DTSTART:20190304T080000Z
10+
DTEND:20190304T083000Z
11+
DTSTAMP:20190303T111937Z
12+
COLOR:navy
13+
END:VEVENT
14+
BEGIN:VTODO
15+
UID:color-todo-own-1@test
16+
SUMMARY:Todo with its own color
17+
DTSTAMP:20190303T111937Z
18+
COLOR:navy
19+
END:VTODO
20+
BEGIN:VJOURNAL
21+
UID:color-journal-own-1@test
22+
SUMMARY:Journal with its own color
23+
DTSTAMP:20190303T111937Z
24+
COLOR:navy
25+
END:VJOURNAL
26+
END:VCALENDAR

tests/calendars/color_none.ics

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//Test//Test//EN
4+
CALSCALE:GREGORIAN
5+
BEGIN:VEVENT
6+
UID:color-none-event-1@test
7+
SUMMARY:Event with no calendar color
8+
DTSTART:20190304T080000Z
9+
DTEND:20190304T083000Z
10+
DTSTAMP:20190303T111937Z
11+
END:VEVENT
12+
END:VCALENDAR

tests/calendars/color_rfc7986.ics

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//Test//Test//EN
4+
CALSCALE:GREGORIAN
5+
COLOR:turquoise
6+
BEGIN:VEVENT
7+
UID:color-rfc7986-event-1@test
8+
SUMMARY:Event with RFC 7986 calendar color
9+
DTSTART:20190304T080000Z
10+
DTEND:20190304T083000Z
11+
DTSTAMP:20190303T111937Z
12+
END:VEVENT
13+
BEGIN:VTODO
14+
UID:color-rfc7986-todo-1@test
15+
SUMMARY:Todo with RFC 7986 calendar color
16+
DTSTAMP:20190303T111937Z
17+
END:VTODO
18+
BEGIN:VJOURNAL
19+
UID:color-rfc7986-journal-1@test
20+
SUMMARY:Journal with RFC 7986 calendar color
21+
DTSTAMP:20190303T111937Z
22+
END:VJOURNAL
23+
END:VCALENDAR

tests/test_color_merging.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Tests for RFC 7986 COLOR property merging."""
2+
3+
import pytest
4+
5+
from mergecal import merge_calendars
6+
7+
8+
@pytest.mark.parametrize("component_type", ["VEVENT", "VTODO", "VJOURNAL"])
9+
def test_component_inherits_calendar_color(calendars, component_type):
10+
result = merge_calendars(calendars.color_rfc7986.stream) # type: ignore[attr-defined]
11+
assert result.walk(component_type)[0].color == "turquoise"
12+
13+
14+
def test_event_inherits_apple_calendar_color(calendars):
15+
result = merge_calendars(calendars.color_apple.stream) # type: ignore[attr-defined]
16+
assert result.events[0].color == "#e78074"
17+
18+
19+
@pytest.mark.parametrize("component_type", ["VEVENT", "VTODO", "VJOURNAL"])
20+
def test_component_own_color_not_overwritten(calendars, component_type):
21+
result = merge_calendars(calendars.color_event_own.stream) # type: ignore[attr-defined]
22+
assert result.walk(component_type)[0].color == "navy"
23+
24+
25+
def test_no_color_when_calendar_has_none(calendars):
26+
result = merge_calendars(calendars.color_none.stream) # type: ignore[attr-defined]
27+
assert not result.color
28+
assert not result.events[0].color
29+
30+
31+
def test_merged_calendar_color_first_wins(calendars):
32+
cals = calendars.color_rfc7986.stream + calendars.color_apple.stream # type: ignore[attr-defined]
33+
result = merge_calendars(cals)
34+
assert result.color == "turquoise"
35+
36+
37+
def test_merged_calendar_color_when_only_one_has_color(calendars):
38+
cals = calendars.color_none.stream + calendars.color_rfc7986.stream # type: ignore[attr-defined]
39+
result = merge_calendars(cals)
40+
assert result.color == "turquoise"

0 commit comments

Comments
 (0)