Skip to content

Commit 9da26ae

Browse files
committed
Handle whole-day recurring events properly
Fixes #365, #308
1 parent ee1acff commit 9da26ae

File tree

2 files changed

+86
-8
lines changed

2 files changed

+86
-8
lines changed

xandikos/icalendar.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@
2121

2222
import logging
2323
from collections.abc import Iterable
24-
from datetime import datetime, time, timedelta, timezone
24+
from datetime import date, datetime, time, timedelta, timezone
2525
from typing import Callable, Optional, Union
2626
from zoneinfo import ZoneInfo
2727

2828
import dateutil.rrule
2929
from icalendar.cal import Calendar, Component, component_factory
30-
from icalendar.prop import TypesFactory, vCategory, vDatetime, vDDDTypes, vText
30+
from icalendar.prop import TypesFactory, vCategory, vDate, vDatetime, vDDDTypes, vText
3131

3232
from xandikos.store import File, Filter, InvalidFileContents
3333

@@ -974,19 +974,34 @@ def _expand_rrule_component(
974974
if "RRULE" not in incomp:
975975
return
976976
rs = rruleset_from_comp(incomp)
977+
978+
# Check if original DTSTART is date-only
979+
original_dtstart = incomp["DTSTART"]
980+
is_date_only = isinstance(original_dtstart.dt, date) and not isinstance(
981+
original_dtstart.dt, datetime
982+
)
983+
977984
for field in ["RRULE", "EXRULE", "UNTIL", "RDATE", "EXDATE"]:
978985
if field in incomp:
979986
del incomp[field]
980987
# Work our magic
981-
for ts in rs.between(start, end):
982-
utcts = asutc(ts)
988+
for ts in rs.between(start, end, inc=True):
989+
# For date-only events, convert rrule's datetime back to date
990+
if is_date_only:
991+
ts_normalized = ts.date()
992+
else:
993+
ts_normalized = asutc(ts)
994+
983995
try:
984-
outcomp = existing.pop(utcts)
985-
outcomp["DTSTART"] = vDatetime(asutc(outcomp["DTSTART"].dt))
996+
outcomp = existing.pop(ts_normalized)
997+
# Preserve the original DTSTART value and type from the exception
998+
# It's already the correct type
986999
except KeyError:
9871000
outcomp = incomp.copy()
988-
outcomp["DTSTART"] = vDatetime(utcts)
989-
outcomp["RECURRENCE-ID"] = vDatetime(utcts)
1001+
outcomp["DTSTART"] = create_prop_from_date_or_datetime(ts_normalized)
1002+
1003+
# Set RECURRENCE-ID with appropriate type
1004+
outcomp["RECURRENCE-ID"] = create_prop_from_date_or_datetime(ts_normalized)
9901005
yield outcomp
9911006

9921007

@@ -1016,4 +1031,15 @@ def expand_calendar_rrule(incal: Calendar, start: datetime, end: datetime) -> Ca
10161031

10171032

10181033
def asutc(dt):
1034+
if isinstance(dt, date) and not isinstance(dt, datetime):
1035+
# Return date as-is - dates are timezone-agnostic
1036+
return dt
10191037
return dt.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
1038+
1039+
1040+
def create_prop_from_date_or_datetime(dt):
1041+
"""Create appropriate vDate or vDatetime property based on input type."""
1042+
if isinstance(dt, date) and not isinstance(dt, datetime):
1043+
return vDate(dt)
1044+
else:
1045+
return vDatetime(dt)

xandikos/tests/test_icalendar.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
TextMatcher,
3737
apply_time_range_vevent,
3838
as_tz_aware_ts,
39+
expand_calendar_rrule,
3940
validate_calendar,
4041
)
4142

@@ -509,3 +510,54 @@ def test_missing_dtstart(self):
509510
ev,
510511
self._tzify,
511512
)
513+
514+
515+
class ExpandCalendarRRuleTests(unittest.TestCase):
516+
def test_expand_recurring_date_only_with_exception(self):
517+
"""Test expansion of recurring events with date-only values and exceptions.
518+
519+
This test reproduces issue #365 where expanding recurring all-day events
520+
with exceptions would fail due to date vs datetime type mismatch.
521+
"""
522+
from icalendar import Calendar
523+
524+
# Create a calendar with a bi-weekly recurring event and an exception
525+
test_ical = b"""BEGIN:VCALENDAR
526+
VERSION:2.0
527+
PRODID:-//Test//Test//EN
528+
BEGIN:VEVENT
529+
530+
DTSTART;VALUE=DATE:20240101
531+
SUMMARY:Bi-weekly event
532+
RRULE:FREQ=WEEKLY;INTERVAL=2
533+
END:VEVENT
534+
BEGIN:VEVENT
535+
536+
RECURRENCE-ID;VALUE=DATE:20240115
537+
DTSTART;VALUE=DATE:20240116
538+
SUMMARY:Bi-weekly event (moved)
539+
END:VEVENT
540+
END:VCALENDAR"""
541+
542+
cal = Calendar.from_ical(test_ical)
543+
544+
# Expand the calendar
545+
start = datetime(2024, 1, 1)
546+
end = datetime(2024, 2, 1)
547+
548+
expanded = expand_calendar_rrule(cal, start, end)
549+
550+
# Verify we got the expected events
551+
events = [comp for comp in expanded.walk() if comp.name == "VEVENT"]
552+
self.assertEqual(len(events), 3) # Jan 1, Jan 15 (moved to 16), Jan 29
553+
554+
# Check that the exception was properly handled
555+
dates = sorted([ev["DTSTART"].dt for ev in events])
556+
from datetime import date
557+
558+
expected_dates = [
559+
date(2024, 1, 1),
560+
date(2024, 1, 16), # Moved from Jan 15
561+
date(2024, 1, 29),
562+
]
563+
self.assertEqual(dates, expected_dates)

0 commit comments

Comments
 (0)