diff --git a/backend/caldav_client.py b/backend/caldav_client.py index 1268413..69ac449 100644 --- a/backend/caldav_client.py +++ b/backend/caldav_client.py @@ -4,7 +4,11 @@ from datetime import date, datetime, timedelta, timezone from typing import Dict, List, Optional import caldav -from icalendar import Calendar, Event, vDatetime, vRecur +from icalendar import Calendar, Event, vDatetime + +# One parser for both directions: this file used to carry its own copy with +# the same UNTIL defect. +from ical_io import _rrule_str_to_vrecur as _parse_rrule_str logger = logging.getLogger(__name__) @@ -306,20 +310,6 @@ def delete_event(url: str, username: str, password: str, event_url: str, resource.delete() -def _parse_rrule_str(rrule_str: str) -> vRecur: - """Parse an RRULE string like 'FREQ=WEEKLY;BYDAY=MO,WE' into a vRecur.""" - params = {} - for part in rrule_str.split(";"): - if "=" not in part: - continue - key, val = part.split("=", 1) - if "," in val: - params[key] = val.split(",") - else: - params[key] = val - return vRecur(params) - - def _parse_dt(s: str) -> datetime: s = s.replace("Z", "+00:00") dt = datetime.fromisoformat(s) diff --git a/backend/ical_io.py b/backend/ical_io.py index 6574fb6..694a530 100644 --- a/backend/ical_io.py +++ b/backend/ical_io.py @@ -135,13 +135,17 @@ def parse_ics(raw: bytes) -> dict: def _rrule_str_to_vrecur(rrule_str: str) -> vRecur: - params = {} - for part in rrule_str.split(";"): - if "=" not in part: - continue - key, val = part.split("=", 1) - params[key] = val.split(",") if "," in val else val - return vRecur(params) + """Parse an RRULE string into a vRecur with correctly typed values. + + Hand-splitting on ";" and "=" leaves every value a str, which icalendar + accepts silently here and then rejects inside ``to_ical()``: vRecur.types + maps UNTIL to vDDDTypes, and that demands a date. A single "repeats until + " event therefore made the whole calendar fail to serialise — a 500 + on REPORT, on GET and on export, while PROPFIND kept working because it + never builds ICS. Letting icalendar parse its own format gives UNTIL as a + datetime and COUNT/INTERVAL as ints. + """ + return vRecur(vRecur.from_ical(rrule_str)) def _parse_iso(s: str) -> datetime: @@ -198,7 +202,10 @@ def build_ics(calendar, events, *, name_cache: dict | None = None, if ev.color: item.add("x-calendarr-color", ev.color) if ev.rrule: - item.add("rrule", _rrule_str_to_vrecur(ev.rrule)) + try: + item.add("rrule", _rrule_str_to_vrecur(ev.rrule)) + except (ValueError, TypeError): + logger.warning("Skipping unparsable RRULE %r on event %s", ev.rrule, ev.id) # ORGANIZER from the creator (local user or imported name). organizer_name = None diff --git a/backend/tests/test_ical_rrule.py b/backend/tests/test_ical_rrule.py new file mode 100644 index 0000000..0186d09 --- /dev/null +++ b/backend/tests/test_ical_rrule.py @@ -0,0 +1,52 @@ +"""RRULE serialisation. + +An RRULE whose values stayed strings serialised fine until ``to_ical()``, where +icalendar rejects a str for UNTIL. One "repeats until " event therefore +returned 500 for every REPORT, GET and export of its calendar while PROPFIND +kept succeeding — which is how a Home Assistant calendar ends up discovered +under the right name and permanently unavailable. +""" + +import pytest + +from ical_io import _rrule_str_to_vrecur + + +@pytest.mark.parametrize("rule", [ + "FREQ=WEEKLY;UNTIL=20260501T235959Z", + "FREQ=DAILY;UNTIL=20260501T235959Z;INTERVAL=2", + "FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20260501T235959Z", +]) +def test_until_survives_serialisation(rule): + """The regression: these used to raise inside to_ical().""" + out = _rrule_str_to_vrecur(rule).to_ical().decode() + assert "UNTIL=20260501T235959Z" in out + + +@pytest.mark.parametrize("rule", [ + "FREQ=WEEKLY;BYDAY=MO", + "FREQ=DAILY;COUNT=5", + "FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2", + "FREQ=MONTHLY;BYMONTHDAY=15", +]) +def test_rules_without_until_still_round_trip(rule): + """These always worked; pin them so the fix did not narrow what is accepted.""" + out = _rrule_str_to_vrecur(rule).to_ical().decode() + for part in rule.split(";"): + key, val = part.split("=", 1) + assert key in out + for v in val.split(","): + assert v in out + + +def test_count_and_interval_become_integers(): + """icalendar types these; the hand-rolled parser left them as strings.""" + rec = _rrule_str_to_vrecur("FREQ=DAILY;COUNT=5;INTERVAL=2") + assert rec["COUNT"] == [5] + assert rec["INTERVAL"] == [2] + + +def test_malformed_rule_raises_rather_than_corrupting(): + """build_ics catches this and drops the recurrence; it must not pass silently.""" + with pytest.raises((ValueError, TypeError)): + _rrule_str_to_vrecur("FREQ=WEEKLY;UNTIL=not-a-date").to_ical()