From 9d5ac01f24bcab4e7d67e287d54514fa68a78f72 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 11 May 2026 02:41:10 +0200 Subject: [PATCH] fix(calendar): preserve floating/TZID semantics across CalDAV roundtrip (#782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CalDAV REPORT in `_search_events_by_date` unconditionally requested server-side ``. Per RFC 4791 §9.6.5 the server then normalizes every expanded DTSTART/DTEND to UTC `Z`, which destroyed two pieces of information on the read path: - RFC 5545 floating local times came back as fake-UTC (a `+00:00` suffix that did not match the stored value), so a 2:30 PM floating event was indistinguishable from a 14:30 UTC event in the MCP response. - TZID-bound events lost their IANA TZID context — a "10am America/New_York" event came back as `14:00:00+00:00`, making it impossible for callers to reconstruct DST-aware recurrence semantics. Replace `` with client-side recurrence expansion via the `recurring-ical-events` library (promoted from transitive to direct dep), so the wire response retains its original DTSTART format. Surface the TZID parameter as new `start_tz`/`end_tz` fields on `CalendarEventSummary`. Add an optional `timezone` (IANA name) parameter to `nc_calendar_create_event` and `nc_calendar_update_event` so callers can pin a TZID for naive input; the helper attaches `ZoneInfo(...)` and emits a paired `VTIMEZONE` component. Naive input without `timezone` continues to store as RFC 5545 floating local time (with a warning logged). Offset-aware input continues to store as UTC `Z`. Drive-by: switch the update path's DTSTART/DTEND assignment from raw `datetime` to `vDDDTypes(dt)` wrappers — the previous code produced invalid iCal like `DTSTART:2026-05-14 10:00:00+00:00` for any TZ-aware update. Co-Authored-By: Claude Opus 4.7 (1M context) --- nextcloud_mcp_server/client/calendar.py | 241 ++++++++++++----- nextcloud_mcp_server/models/calendar.py | 26 +- nextcloud_mcp_server/server/calendar.py | 32 ++- pyproject.toml | 1 + .../calendar/test_timezone_roundtrip.py | 242 ++++++++++++++++++ uv.lock | 2 + 6 files changed, 469 insertions(+), 75 deletions(-) create mode 100644 tests/client/calendar/test_timezone_roundtrip.py diff --git a/nextcloud_mcp_server/client/calendar.py b/nextcloud_mcp_server/client/calendar.py index 993abdbb..613af16e 100644 --- a/nextcloud_mcp_server/client/calendar.py +++ b/nextcloud_mcp_server/client/calendar.py @@ -5,12 +5,14 @@ import inspect import logging import uuid from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import anyio +import recurring_ical_events from caldav.aio import AsyncCalendar, AsyncDAVClient, AsyncEvent from caldav.elements import cdav, dav from caldav.lib import error as caldav_error -from icalendar import Alarm, Calendar, vDDDTypes, vRecur +from icalendar import Alarm, Calendar, Timezone, vDDDTypes, vRecur from icalendar import Event as ICalEvent from icalendar import Todo as ICalTodo from lxml import etree # type: ignore[import-untyped] @@ -320,49 +322,101 @@ class CalendarClient: calendar = self._get_calendar(calendar_name) if start_datetime or end_datetime: - # Build CalDAV REPORT with time-range filter for server-side filtering events = await self._search_events_by_date( calendar, start_datetime, end_datetime ) - # Expand is only used when both bounds are provided - expanded = bool(start_datetime and end_datetime) + # Client-side recurrence expansion preserves DTSTART format + # (floating / TZID / UTC). RFC 4791 would normalize + # everything to UTC and erase the original timezone context. + do_expand = bool(start_datetime and end_datetime) else: - # No date filter — fetch all events events = await calendar.events() # type: ignore[misc] # dual-mode - expanded = False + do_expand = False result = [] for event in events: await _maybe_await(event.load(only_if_unloaded=True)) - if event.data: - if expanded: - # Server-side expansion: each response resource may contain - # multiple VEVENTs (one per recurrence occurrence) - for event_dict in self._parse_all_ical_events(event.data): - event_dict["href"] = str(event.url) - event_dict["etag"] = "" - result.append(event_dict) - else: - event_dict = self._parse_ical_event(event.data) - if event_dict: - event_dict["href"] = str(event.url) - event_dict["etag"] = "" - result.append(event_dict) + if not event.data: + continue + + try: + cal = Calendar.from_ical(event.data) + except Exception as e: + logger.error("Error parsing iCalendar event: %s", e) + continue + + href = str(event.url) + event_dicts = self._expand_event_occurrences( + cal, start_datetime, end_datetime, do_expand + ) + for event_dict in event_dicts: + event_dict["href"] = href + event_dict["etag"] = "" + result.append(event_dict) + + if len(result) >= limit: + break if len(result) >= limit: break - logger.debug(f"Found {len(result)} events") + logger.debug("Found %d events", len(result)) return result + def _expand_event_occurrences( + self, + cal: Any, + start_datetime: dt.datetime | None, + end_datetime: dt.datetime | None, + do_expand: bool, + ) -> list[dict[str, Any]]: + """Return one event dict per occurrence in [start, end), or one dict for the master VEVENT. + + When ``do_expand`` is true and the resource has an RRULE, expand recurrences + client-side using ``recurring_ical_events`` so that TZID and floating-local + semantics are preserved on the wire (server-side ```` would + UTC-normalize every DTSTART per RFC 4791 §9.6.5). + """ + if not do_expand: + for component in cal.walk("VEVENT"): + return [self._extract_vevent_data(component)] + return [] + + has_rrule = any("rrule" in component for component in cal.walk("VEVENT")) + if not has_rrule: + for component in cal.walk("VEVENT"): + return [self._extract_vevent_data(component)] + return [] + + try: + assert start_datetime is not None and end_datetime is not None + occurrences = recurring_ical_events.of(cal).between( + start_datetime, end_datetime + ) + except Exception as e: + logger.warning( + "Client-side recurrence expansion failed (%s); returning master event", + e, + ) + return [ + self._extract_vevent_data(component) for component in cal.walk("VEVENT") + ] + + return [self._extract_vevent_data(occ) for occ in occurrences] + async def _search_events_by_date( self, calendar: AsyncCalendar, start_datetime: dt.datetime | None = None, end_datetime: dt.datetime | None = None, ) -> list: - """Execute a CalDAV REPORT with time-range filter.""" - # Ensure naive datetimes are treated as UTC + """Execute a CalDAV REPORT with time-range filter. + + Returns raw VEVENT resources (no server-side ````). The caller + is responsible for expanding recurring events client-side so that + TZID/floating semantics are preserved. + """ + # Ensure naive datetimes are treated as UTC for the wire-level filter if start_datetime and start_datetime.tzinfo is None: start_datetime = start_datetime.replace(tzinfo=dt.UTC) if end_datetime and end_datetime.tzinfo is None: @@ -374,13 +428,7 @@ class CalendarClient: outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter filter_element = cdav.Filter() + outer_comp_filter - # When both bounds are provided, request server-side expansion of - # recurring events (RFC 4791 §9.6.5). Each occurrence is returned as - # a separate VEVENT with its own DTSTART, with RRULE stripped. data = cdav.CalendarData() - if start_datetime and end_datetime: - data += cdav.Expand(start_datetime, end_datetime) - query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element body = etree.tostring( @@ -681,6 +729,51 @@ class CalendarClient: # ============= Helper Methods - Event iCalendar ============= + @staticmethod + def _resolve_timezone(tz_name: str) -> ZoneInfo | None: + """Resolve an IANA timezone name to ZoneInfo, returning None for invalid input.""" + if not tz_name: + return None + try: + return ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + logger.warning( + "Unknown IANA timezone %r — falling back to floating local time", + tz_name, + ) + return None + + @classmethod + def _parse_event_datetime( + cls, dt_str: str, tz_name: str | None = None + ) -> tuple[dt.datetime, ZoneInfo | None]: + """Parse an ISO datetime string with optional TZID application. + + Returns ``(parsed_dt, applied_zoneinfo)`` where ``applied_zoneinfo`` + is non-None only when ``tz_name`` was applied to a naive input — the + caller uses this to know whether to emit a VTIMEZONE component. + """ + parsed = dt.datetime.fromisoformat(dt_str.replace("Z", "+00:00")) + zi = cls._resolve_timezone(tz_name) if tz_name else None + + if parsed.tzinfo is not None: + if zi is not None: + logger.warning( + "Datetime %r has an explicit offset; ignoring timezone=%r", + dt_str, + tz_name, + ) + return parsed, None + + if zi is not None: + return parsed.replace(tzinfo=zi), zi + + logger.warning( + "Datetime %r is naive and no timezone was supplied — storing as RFC 5545 floating local time", + dt_str, + ) + return parsed, None + def _create_ical_event(self, event_data: dict[str, Any], event_uid: str) -> str: """Create iCalendar content from event data.""" cal = Calendar() @@ -697,6 +790,8 @@ class CalendarClient: start_str = event_data.get("start_datetime", "") end_str = event_data.get("end_datetime", "") all_day = event_data.get("all_day", False) + tz_name = event_data.get("timezone", "") + used_timezones: set[ZoneInfo] = set() if start_str: if all_day: @@ -706,10 +801,14 @@ class CalendarClient: end_date = dt.datetime.fromisoformat(end_str.split("T")[0]).date() event.add("dtend", end_date) else: - start_dt = dt.datetime.fromisoformat(start_str.replace("Z", "+00:00")) + start_dt, zi = self._parse_event_datetime(start_str, tz_name) + if zi is not None: + used_timezones.add(zi) event.add("dtstart", start_dt) if end_str: - end_dt = dt.datetime.fromisoformat(end_str.replace("Z", "+00:00")) + end_dt, zi = self._parse_event_datetime(end_str, tz_name) + if zi is not None: + used_timezones.add(zi) event.add("dtend", end_dt) # Add categories @@ -762,14 +861,14 @@ class CalendarClient: event.add("dtstamp", now) event.add("last-modified", now) + # VTIMEZONE must appear before the referencing VEVENT. + for zi in used_timezones: + cal.add_component(Timezone.from_tzinfo(zi)) cal.add_component(event) return cal.to_ical().decode("utf-8") def _extract_vevent_data(self, component) -> dict[str, Any]: - """Extract event data from a single VEVENT component. - - Shared helper used by both _parse_ical_event() and _parse_all_ical_events(). - """ + """Extract event data from a single VEVENT component.""" event_data: dict[str, Any] = { "uid": str(component.get("uid", "")), "title": str(component.get("summary", "")), @@ -781,24 +880,28 @@ class CalendarClient: "url": str(component.get("url", "")), } - # Handle dates + # Handle dates. The ``.isoformat()`` representation already encodes the + # storage semantics: no suffix for floating local, ``+00:00`` for UTC, + # and the offset (e.g. ``-04:00``) for TZID-bound datetimes. The IANA + # TZID name is surfaced separately as ``start_tz``/``end_tz`` so callers + # can distinguish "10am NY time" (recurs in local time across DST) from + # "14:00 UTC" (same UTC instant), which the offset alone cannot express. dtstart = component.get("dtstart") if dtstart: - if isinstance(dtstart.dt, dt.date) and not isinstance( + event_data["start_datetime"] = dtstart.dt.isoformat() + event_data["all_day"] = isinstance(dtstart.dt, dt.date) and not isinstance( dtstart.dt, dt.datetime - ): - event_data["start_datetime"] = dtstart.dt.isoformat() - event_data["all_day"] = True - else: - event_data["start_datetime"] = dtstart.dt.isoformat() - event_data["all_day"] = False + ) + tzid = dtstart.params.get("TZID") if dtstart.params else None + if tzid: + event_data["start_tz"] = str(tzid) dtend = component.get("dtend") if dtend: - if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime): - event_data["end_datetime"] = dtend.dt.isoformat() - else: - event_data["end_datetime"] = dtend.dt.isoformat() + event_data["end_datetime"] = dtend.dt.isoformat() + tzid = dtend.params.get("TZID") if dtend.params else None + if tzid: + event_data["end_tz"] = str(tzid) # Handle categories categories = component.get("categories") @@ -835,22 +938,6 @@ class CalendarClient: logger.error(f"Error parsing iCalendar event: {e}") return None - def _parse_all_ical_events(self, ical_text: str) -> list[dict[str, Any]]: - """Parse iCalendar text and extract ALL event occurrences. - - Used with server-side expansion where a single VCALENDAR contains - multiple VEVENT components (one per recurrence occurrence). - """ - results: list[dict[str, Any]] = [] - try: - cal = Calendar.from_ical(ical_text) - for component in cal.walk(): - if component.name == "VEVENT": - results.append(self._extract_vevent_data(component)) - except Exception as e: - logger.error(f"Error parsing iCalendar events: {e}") - return results - def _merge_ical_properties( self, raw_ical: str, event_data: dict[str, Any], event_uid: str ) -> str: @@ -921,6 +1008,8 @@ class CalendarClient: component.add_component(alarm) # Handle dates + tz_name = event_data.get("timezone", "") + used_timezones: set[ZoneInfo] = set() if "start_datetime" in event_data: start_str = event_data["start_datetime"] all_day = event_data.get("all_day", False) @@ -930,10 +1019,12 @@ class CalendarClient: ).date() component["DTSTART"] = start_date else: - start_dt = dt.datetime.fromisoformat( - start_str.replace("Z", "+00:00") + start_dt, zi = self._parse_event_datetime( + start_str, tz_name ) - component["DTSTART"] = start_dt + if zi is not None: + used_timezones.add(zi) + component["DTSTART"] = vDDDTypes(start_dt) if "end_datetime" in event_data: end_str = event_data["end_datetime"] @@ -944,16 +1035,26 @@ class CalendarClient: ).date() component["DTEND"] = end_date else: - end_dt = dt.datetime.fromisoformat( - end_str.replace("Z", "+00:00") - ) - component["DTEND"] = end_dt + end_dt, zi = self._parse_event_datetime(end_str, tz_name) + if zi is not None: + used_timezones.add(zi) + component["DTEND"] = vDDDTypes(end_dt) # Update timestamps now = dt.datetime.now(dt.UTC) component["LAST-MODIFIED"] = vDDDTypes(now) component["DTSTAMP"] = vDDDTypes(now) + # Ensure VTIMEZONE definitions exist for any TZID we just attached. + existing_tzids = { + str(sub.get("TZID", "")) + for sub in cal.subcomponents + if sub.name == "VTIMEZONE" + } + for zi in used_timezones: + if str(zi) not in existing_tzids: + cal.add_component(Timezone.from_tzinfo(zi)) + break return cal.to_ical().decode("utf-8") diff --git a/nextcloud_mcp_server/models/calendar.py b/nextcloud_mcp_server/models/calendar.py index 3884b501..1e903201 100644 --- a/nextcloud_mcp_server/models/calendar.py +++ b/nextcloud_mcp_server/models/calendar.py @@ -25,8 +25,30 @@ class CalendarEventSummary(BaseModel): uid: str = Field(description="Event UID") summary: str = Field(description="Event summary/title") - start: str = Field(description="Event start datetime (ISO format)") - end: Optional[str] = Field(None, description="Event end datetime (ISO format)") + start: str = Field( + description=( + "Event start datetime (ISO format). No suffix = RFC 5545 floating " + "local time; ``+00:00`` = UTC; an explicit offset (e.g. ``-04:00``) " + "= TZID-bound at that instant. The IANA TZID name is exposed " + "separately as ``start_tz``." + ) + ) + end: Optional[str] = Field( + None, + description=( + "Event end datetime (ISO format). Same encoding rules as ``start``." + ), + ) + start_tz: Optional[str] = Field( + None, + description=( + "IANA timezone name when DTSTART had a TZID parameter (e.g. " + "``America/New_York``). ``None`` for floating local or UTC." + ), + ) + end_tz: Optional[str] = Field( + None, description="IANA timezone name when DTEND had a TZID parameter." + ) all_day: bool = Field(default=False, description="Whether event is all-day") location: Optional[str] = Field(None, description="Event location") description: Optional[str] = Field(None, description="Event description") diff --git a/nextcloud_mcp_server/server/calendar.py b/nextcloud_mcp_server/server/calendar.py index 01bde47e..b955ebb9 100644 --- a/nextcloud_mcp_server/server/calendar.py +++ b/nextcloud_mcp_server/server/calendar.py @@ -38,6 +38,8 @@ def _event_dict_to_summary(event: dict) -> CalendarEventSummary: summary=event.get("title", ""), start=start, end=event.get("end_datetime"), + start_tz=event.get("start_tz"), + end_tz=event.get("end_tz"), all_day=event.get("all_day", False), location=event.get("location") or None, description=event.get("description") or None, @@ -92,13 +94,23 @@ def configure_calendar_tools(mcp: FastMCP): attendees: str = "", url: str = "", color: str = "", + timezone: str = "", ): - """Create a comprehensive calendar event with full feature support + """Create a comprehensive calendar event with full feature support. Args: calendar_name: Name of the calendar to create the event in title: Event title - start_datetime: ISO format: "2025-01-15T14:00:00" or "2025-01-15" for all-day + start_datetime: ISO format. Three modes: + - ``"2025-01-15T14:00:00Z"`` or ``"2025-01-15T14:00:00+00:00"`` + → stored as UTC. + - ``"2025-01-15T14:00:00"`` with ``timezone="America/New_York"`` + → stored as TZID-bound (server emits ``DTSTART;TZID=...:...`` + plus a VTIMEZONE component). + - ``"2025-01-15T14:00:00"`` alone → stored as RFC 5545 floating + local time (interpreted by viewers in their own zone). A + warning is logged so the choice is visible. + - ``"2025-01-15"`` for all-day events. ctx: MCP context end_datetime: ISO format end time, empty for all-day events all_day: Whether this is an all-day event @@ -116,6 +128,10 @@ def configure_calendar_tools(mcp: FastMCP): attendees: Comma-separated email addresses url: Related URL for the event color: Event color (hex or name) + timezone: Optional IANA timezone name (e.g. ``"America/New_York"``). + Applied only when ``start_datetime``/``end_datetime`` are naive + (no offset, no ``Z``). Ignored — with a warning — when the + inputs already carry an explicit offset. Returns: Dict with event creation result @@ -141,6 +157,7 @@ def configure_calendar_tools(mcp: FastMCP): "attendees": attendees, "url": url, "color": color, + "timezone": timezone, } return await client.calendar.create_event(calendar_name, event_data) @@ -312,9 +329,16 @@ def configure_calendar_tools(mcp: FastMCP): attendees: str | None = None, url: str | None = None, color: str | None = None, + timezone: str | None = None, etag: str = "", ): - """Update any aspect of an existing event""" + """Update any aspect of an existing event. + + Pass ``timezone`` (IANA name, e.g. ``"America/New_York"``) together + with a naive ``start_datetime`` / ``end_datetime`` to rewrite DTSTART + / DTEND as TZID-bound. See ``nc_calendar_create_event`` for the full + encoding rules. + """ client = await get_client(ctx) # Build update data with only non-None values @@ -353,6 +377,8 @@ def configure_calendar_tools(mcp: FastMCP): event_data["url"] = url if color is not None: event_data["color"] = color + if timezone is not None: + event_data["timezone"] = timezone return await client.calendar.update_event( calendar_name, event_uid, event_data, etag diff --git a/pyproject.toml b/pyproject.toml index 7052a89f..6d8dfc34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pydantic>=2.11.4", "click>=8.1.8", "caldav>=3.0.1,<4.0", + "recurring-ical-events>=3.8.0,<4.0", # Client-side recurrence expansion (preserves TZID/floating semantics) "pyjwt[crypto]>=2.8.0", "aiosqlite>=0.20.0", # Async SQLite for refresh token storage "alembic>=1.14.0", # Database migrations diff --git a/tests/client/calendar/test_timezone_roundtrip.py b/tests/client/calendar/test_timezone_roundtrip.py new file mode 100644 index 00000000..045fe1b0 --- /dev/null +++ b/tests/client/calendar/test_timezone_roundtrip.py @@ -0,0 +1,242 @@ +"""Unit tests for calendar timezone roundtrip (issue #782). + +These tests cover the three storage flavors that ``_extract_vevent_data`` and +``_create_ical_event`` must handle correctly: + +- **Floating local time** (no ``Z``, no offset, no TZID) — RFC 5545's neutral + wall-clock format. +- **UTC** (``Z`` or ``+00:00`` suffix). +- **TZID-bound** (``DTSTART;TZID=...:...`` with a paired ``VTIMEZONE``). + +Prior to issue #782 the read path silently coerced everything to UTC because +``_search_events_by_date`` requested server-side ````; these tests +pin the post-fix contract so the regression cannot return. +""" + +from __future__ import annotations + +import httpx +import pytest + +from nextcloud_mcp_server.client.calendar import CalendarClient + +pytestmark = pytest.mark.unit + + +def _make_client(mocker) -> CalendarClient: + """Build a CalendarClient without performing any network IO. + + The pure iCal helpers under test (``_create_ical_event`` / + ``_parse_ical_event``) don't touch the wire, so a stub AsyncClient is fine. + """ + client = CalendarClient.__new__(CalendarClient) + client._client = mocker.AsyncMock(spec=httpx.AsyncClient) + client._username = "tester" + return client + + +# ============= Read path: _parse_ical_event preserves DTSTART semantics ============= + + +def _wrap_vevent(vevent_body: str, vtimezone: str = "") -> str: + """Assemble a minimal VCALENDAR around a VEVENT body for parser tests.""" + return ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Test//EN\r\n" + f"{vtimezone}" + "BEGIN:VEVENT\r\n" + "UID:test-event\r\n" + "SUMMARY:Test\r\n" + f"{vevent_body}" + "DTSTAMP:20260510T000000Z\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + + +def test_parse_floating_event_has_no_offset_and_no_tzid(mocker): + """Floating-local DTSTART must round-trip as a naive ISO string with no spurious offset.""" + client = _make_client(mocker) + ical = _wrap_vevent("DTSTART:20260513T143000\r\nDTEND:20260513T154500\r\n") + + parsed = client._parse_ical_event(ical) + + assert parsed is not None + assert parsed["start_datetime"] == "2026-05-13T14:30:00" + assert parsed["end_datetime"] == "2026-05-13T15:45:00" + assert "start_tz" not in parsed + assert "end_tz" not in parsed + assert parsed["all_day"] is False + + +def test_parse_utc_event_keeps_explicit_zero_offset(mocker): + """``DTSTART:...Z`` must serialize back as ``+00:00`` so callers can recognize UTC.""" + client = _make_client(mocker) + ical = _wrap_vevent("DTSTART:20260512T143000Z\r\nDTEND:20260512T154500Z\r\n") + + parsed = client._parse_ical_event(ical) + + assert parsed is not None + assert parsed["start_datetime"] == "2026-05-12T14:30:00+00:00" + assert parsed["end_datetime"] == "2026-05-12T15:45:00+00:00" + assert "start_tz" not in parsed + + +def test_parse_tzid_event_exposes_iana_name_and_offset(mocker): + """TZID-bound events must expose both the resolved offset and the IANA name.""" + client = _make_client(mocker) + vtz = ( + "BEGIN:VTIMEZONE\r\n" + "TZID:America/New_York\r\n" + "BEGIN:STANDARD\r\n" + "DTSTART:20071104T020000\r\n" + "TZOFFSETFROM:-0400\r\n" + "TZOFFSETTO:-0500\r\n" + "RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU\r\n" + "END:STANDARD\r\n" + "BEGIN:DAYLIGHT\r\n" + "DTSTART:20070311T020000\r\n" + "TZOFFSETFROM:-0500\r\n" + "TZOFFSETTO:-0400\r\n" + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU\r\n" + "END:DAYLIGHT\r\n" + "END:VTIMEZONE\r\n" + ) + ical = _wrap_vevent( + "DTSTART;TZID=America/New_York:20260514T100000\r\n" + "DTEND;TZID=America/New_York:20260514T110000\r\n", + vtimezone=vtz, + ) + + parsed = client._parse_ical_event(ical) + + assert parsed is not None + # May is EDT (UTC-4) + assert parsed["start_datetime"] == "2026-05-14T10:00:00-04:00" + assert parsed["end_datetime"] == "2026-05-14T11:00:00-04:00" + assert parsed["start_tz"] == "America/New_York" + assert parsed["end_tz"] == "America/New_York" + + +def test_parse_all_day_event(mocker): + """All-day events serialize as plain dates with all_day=True.""" + client = _make_client(mocker) + ical = _wrap_vevent("DTSTART;VALUE=DATE:20260601\r\nDTEND;VALUE=DATE:20260602\r\n") + + parsed = client._parse_ical_event(ical) + + assert parsed is not None + assert parsed["all_day"] is True + assert parsed["start_datetime"] == "2026-06-01" + + +# ============= Write path: timezone parameter wires TZID + VTIMEZONE ============= + + +def test_create_ical_event_utc_input_stores_as_z_suffix(mocker): + """Offset-aware input continues to emit RFC 5545 UTC (``...Z``) on the wire.""" + client = _make_client(mocker) + event_data = { + "title": "UTC event", + "start_datetime": "2026-05-12T14:30:00+00:00", + "end_datetime": "2026-05-12T15:45:00+00:00", + } + + ical = client._create_ical_event(event_data, event_uid="utc-uid") + + assert "DTSTART:20260512T143000Z" in ical + assert "DTEND:20260512T154500Z" in ical + assert "VTIMEZONE" not in ical + + +def test_create_ical_event_naive_without_tz_stores_floating(mocker): + """Naive input + no ``timezone`` parameter must store as floating local time.""" + client = _make_client(mocker) + event_data = { + "title": "Floating event", + "start_datetime": "2026-05-13T14:30:00", + "end_datetime": "2026-05-13T15:45:00", + } + + ical = client._create_ical_event(event_data, event_uid="floating-uid") + + # No TZID, no Z suffix — RFC 5545 floating local time. + assert "DTSTART:20260513T143000" in ical + assert "DTSTART;TZID" not in ical + assert "20260513T143000Z" not in ical + assert "VTIMEZONE" not in ical + + +def test_create_ical_event_naive_with_timezone_emits_tzid_and_vtimezone(mocker): + """Naive input + ``timezone="America/New_York"`` produces TZID-bound DTSTART + VTIMEZONE.""" + client = _make_client(mocker) + event_data = { + "title": "TZID event", + "start_datetime": "2026-05-14T10:00:00", + "end_datetime": "2026-05-14T11:00:00", + "timezone": "America/New_York", + } + + ical = client._create_ical_event(event_data, event_uid="tzid-uid") + + assert "DTSTART;TZID=America/New_York:20260514T100000" in ical + assert "DTEND;TZID=America/New_York:20260514T110000" in ical + # VTIMEZONE component must be emitted so other CalDAV clients can interpret the TZID. + assert "BEGIN:VTIMEZONE" in ical + assert "TZID:America/New_York" in ical + + +def test_create_ical_event_offset_input_ignores_timezone_param(mocker): + """When the input already carries an offset, ``timezone`` is ignored (warning logged).""" + client = _make_client(mocker) + event_data = { + "title": "Mixed-signals event", + "start_datetime": "2026-05-12T14:30:00+00:00", + "end_datetime": "2026-05-12T15:45:00+00:00", + "timezone": "America/New_York", + } + + ical = client._create_ical_event(event_data, event_uid="mixed-uid") + + assert "DTSTART:20260512T143000Z" in ical + assert "DTSTART;TZID" not in ical + # No VTIMEZONE since we never attached a ZoneInfo. + assert "VTIMEZONE" not in ical + + +def test_create_ical_event_unknown_timezone_falls_back_to_floating(mocker, caplog): + """An unresolvable IANA name must not crash — fall back to floating local time.""" + client = _make_client(mocker) + event_data = { + "title": "Bogus TZ event", + "start_datetime": "2026-05-15T09:00:00", + "end_datetime": "2026-05-15T10:00:00", + "timezone": "Continent/Imaginary", + } + + ical = client._create_ical_event(event_data, event_uid="bogus-uid") + + assert "DTSTART:20260515T090000" in ical + assert "VTIMEZONE" not in ical + + +# ============= End-to-end: write → re-parse roundtrip preserves intent ============= + + +def test_roundtrip_tzid_event_preserves_iana_name(mocker): + """The TZID name placed on write must survive a re-parse on read.""" + client = _make_client(mocker) + event_data = { + "title": "Roundtrip TZID", + "start_datetime": "2026-05-14T10:00:00", + "end_datetime": "2026-05-14T11:00:00", + "timezone": "America/New_York", + } + + ical = client._create_ical_event(event_data, event_uid="roundtrip-uid") + parsed = client._parse_ical_event(ical) + + assert parsed is not None + assert parsed["start_tz"] == "America/New_York" + assert parsed["start_datetime"] == "2026-05-14T10:00:00-04:00" diff --git a/uv.lock b/uv.lock index 09c940ca..25a953ee 100644 --- a/uv.lock +++ b/uv.lock @@ -2158,6 +2158,7 @@ dependencies = [ { name = "python-json-logger" }, { name = "pythonvcard4" }, { name = "qdrant-client" }, + { name = "recurring-ical-events" }, { name = "starlette" }, ] @@ -2212,6 +2213,7 @@ requires-dist = [ { name = "python-json-logger", specifier = ">=3.2.0" }, { name = "pythonvcard4", specifier = ">=0.2.0" }, { name = "qdrant-client", specifier = ">=1.17.0" }, + { name = "recurring-ical-events", specifier = ">=3.8.0,<4.0" }, { name = "starlette", specifier = "<1.0" }, ]