fix(calendar): preserve floating/TZID semantics across CalDAV roundtrip (#782)
The CalDAV REPORT in `_search_events_by_date` unconditionally requested server-side `<C:expand>`. 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 `<C:expand>` 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
728a9ae252
commit
9d5ac01f24
@@ -5,12 +5,14 @@ import inspect
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
|
import recurring_ical_events
|
||||||
from caldav.aio import AsyncCalendar, AsyncDAVClient, AsyncEvent
|
from caldav.aio import AsyncCalendar, AsyncDAVClient, AsyncEvent
|
||||||
from caldav.elements import cdav, dav
|
from caldav.elements import cdav, dav
|
||||||
from caldav.lib import error as caldav_error
|
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 Event as ICalEvent
|
||||||
from icalendar import Todo as ICalTodo
|
from icalendar import Todo as ICalTodo
|
||||||
from lxml import etree # type: ignore[import-untyped]
|
from lxml import etree # type: ignore[import-untyped]
|
||||||
@@ -320,49 +322,101 @@ class CalendarClient:
|
|||||||
calendar = self._get_calendar(calendar_name)
|
calendar = self._get_calendar(calendar_name)
|
||||||
|
|
||||||
if start_datetime or end_datetime:
|
if start_datetime or end_datetime:
|
||||||
# Build CalDAV REPORT with time-range filter for server-side filtering
|
|
||||||
events = await self._search_events_by_date(
|
events = await self._search_events_by_date(
|
||||||
calendar, start_datetime, end_datetime
|
calendar, start_datetime, end_datetime
|
||||||
)
|
)
|
||||||
# Expand is only used when both bounds are provided
|
# Client-side recurrence expansion preserves DTSTART format
|
||||||
expanded = bool(start_datetime and end_datetime)
|
# (floating / TZID / UTC). RFC 4791 <C:expand> would normalize
|
||||||
|
# everything to UTC and erase the original timezone context.
|
||||||
|
do_expand = bool(start_datetime and end_datetime)
|
||||||
else:
|
else:
|
||||||
# No date filter — fetch all events
|
|
||||||
events = await calendar.events() # type: ignore[misc] # dual-mode
|
events = await calendar.events() # type: ignore[misc] # dual-mode
|
||||||
expanded = False
|
do_expand = False
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for event in events:
|
for event in events:
|
||||||
await _maybe_await(event.load(only_if_unloaded=True))
|
await _maybe_await(event.load(only_if_unloaded=True))
|
||||||
if event.data:
|
if not event.data:
|
||||||
if expanded:
|
continue
|
||||||
# Server-side expansion: each response resource may contain
|
|
||||||
# multiple VEVENTs (one per recurrence occurrence)
|
try:
|
||||||
for event_dict in self._parse_all_ical_events(event.data):
|
cal = Calendar.from_ical(event.data)
|
||||||
event_dict["href"] = str(event.url)
|
except Exception as e:
|
||||||
event_dict["etag"] = ""
|
logger.error("Error parsing iCalendar event: %s", e)
|
||||||
result.append(event_dict)
|
continue
|
||||||
else:
|
|
||||||
event_dict = self._parse_ical_event(event.data)
|
href = str(event.url)
|
||||||
if event_dict:
|
event_dicts = self._expand_event_occurrences(
|
||||||
event_dict["href"] = str(event.url)
|
cal, start_datetime, end_datetime, do_expand
|
||||||
event_dict["etag"] = ""
|
)
|
||||||
result.append(event_dict)
|
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:
|
if len(result) >= limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.debug(f"Found {len(result)} events")
|
logger.debug("Found %d events", len(result))
|
||||||
return 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 ``<C:expand>`` 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(
|
async def _search_events_by_date(
|
||||||
self,
|
self,
|
||||||
calendar: AsyncCalendar,
|
calendar: AsyncCalendar,
|
||||||
start_datetime: dt.datetime | None = None,
|
start_datetime: dt.datetime | None = None,
|
||||||
end_datetime: dt.datetime | None = None,
|
end_datetime: dt.datetime | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Execute a CalDAV REPORT with time-range filter."""
|
"""Execute a CalDAV REPORT with time-range filter.
|
||||||
# Ensure naive datetimes are treated as UTC
|
|
||||||
|
Returns raw VEVENT resources (no server-side ``<C:expand>``). 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:
|
if start_datetime and start_datetime.tzinfo is None:
|
||||||
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
||||||
if end_datetime and end_datetime.tzinfo is None:
|
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
|
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
||||||
filter_element = cdav.Filter() + outer_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()
|
data = cdav.CalendarData()
|
||||||
if start_datetime and end_datetime:
|
|
||||||
data += cdav.Expand(start_datetime, end_datetime)
|
|
||||||
|
|
||||||
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
|
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
|
||||||
|
|
||||||
body = etree.tostring(
|
body = etree.tostring(
|
||||||
@@ -681,6 +729,51 @@ class CalendarClient:
|
|||||||
|
|
||||||
# ============= Helper Methods - Event iCalendar =============
|
# ============= 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:
|
def _create_ical_event(self, event_data: dict[str, Any], event_uid: str) -> str:
|
||||||
"""Create iCalendar content from event data."""
|
"""Create iCalendar content from event data."""
|
||||||
cal = Calendar()
|
cal = Calendar()
|
||||||
@@ -697,6 +790,8 @@ class CalendarClient:
|
|||||||
start_str = event_data.get("start_datetime", "")
|
start_str = event_data.get("start_datetime", "")
|
||||||
end_str = event_data.get("end_datetime", "")
|
end_str = event_data.get("end_datetime", "")
|
||||||
all_day = event_data.get("all_day", False)
|
all_day = event_data.get("all_day", False)
|
||||||
|
tz_name = event_data.get("timezone", "")
|
||||||
|
used_timezones: set[ZoneInfo] = set()
|
||||||
|
|
||||||
if start_str:
|
if start_str:
|
||||||
if all_day:
|
if all_day:
|
||||||
@@ -706,10 +801,14 @@ class CalendarClient:
|
|||||||
end_date = dt.datetime.fromisoformat(end_str.split("T")[0]).date()
|
end_date = dt.datetime.fromisoformat(end_str.split("T")[0]).date()
|
||||||
event.add("dtend", end_date)
|
event.add("dtend", end_date)
|
||||||
else:
|
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)
|
event.add("dtstart", start_dt)
|
||||||
if end_str:
|
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)
|
event.add("dtend", end_dt)
|
||||||
|
|
||||||
# Add categories
|
# Add categories
|
||||||
@@ -762,14 +861,14 @@ class CalendarClient:
|
|||||||
event.add("dtstamp", now)
|
event.add("dtstamp", now)
|
||||||
event.add("last-modified", 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)
|
cal.add_component(event)
|
||||||
return cal.to_ical().decode("utf-8")
|
return cal.to_ical().decode("utf-8")
|
||||||
|
|
||||||
def _extract_vevent_data(self, component) -> dict[str, Any]:
|
def _extract_vevent_data(self, component) -> dict[str, Any]:
|
||||||
"""Extract event data from a single VEVENT component.
|
"""Extract event data from a single VEVENT component."""
|
||||||
|
|
||||||
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
|
|
||||||
"""
|
|
||||||
event_data: dict[str, Any] = {
|
event_data: dict[str, Any] = {
|
||||||
"uid": str(component.get("uid", "")),
|
"uid": str(component.get("uid", "")),
|
||||||
"title": str(component.get("summary", "")),
|
"title": str(component.get("summary", "")),
|
||||||
@@ -781,24 +880,28 @@ class CalendarClient:
|
|||||||
"url": str(component.get("url", "")),
|
"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")
|
dtstart = component.get("dtstart")
|
||||||
if 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
|
dtstart.dt, dt.datetime
|
||||||
):
|
)
|
||||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
tzid = dtstart.params.get("TZID") if dtstart.params else None
|
||||||
event_data["all_day"] = True
|
if tzid:
|
||||||
else:
|
event_data["start_tz"] = str(tzid)
|
||||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
|
||||||
event_data["all_day"] = False
|
|
||||||
|
|
||||||
dtend = component.get("dtend")
|
dtend = component.get("dtend")
|
||||||
if dtend:
|
if dtend:
|
||||||
if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime):
|
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
tzid = dtend.params.get("TZID") if dtend.params else None
|
||||||
else:
|
if tzid:
|
||||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
event_data["end_tz"] = str(tzid)
|
||||||
|
|
||||||
# Handle categories
|
# Handle categories
|
||||||
categories = component.get("categories")
|
categories = component.get("categories")
|
||||||
@@ -835,22 +938,6 @@ class CalendarClient:
|
|||||||
logger.error(f"Error parsing iCalendar event: {e}")
|
logger.error(f"Error parsing iCalendar event: {e}")
|
||||||
return None
|
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(
|
def _merge_ical_properties(
|
||||||
self, raw_ical: str, event_data: dict[str, Any], event_uid: str
|
self, raw_ical: str, event_data: dict[str, Any], event_uid: str
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -921,6 +1008,8 @@ class CalendarClient:
|
|||||||
component.add_component(alarm)
|
component.add_component(alarm)
|
||||||
|
|
||||||
# Handle dates
|
# Handle dates
|
||||||
|
tz_name = event_data.get("timezone", "")
|
||||||
|
used_timezones: set[ZoneInfo] = set()
|
||||||
if "start_datetime" in event_data:
|
if "start_datetime" in event_data:
|
||||||
start_str = event_data["start_datetime"]
|
start_str = event_data["start_datetime"]
|
||||||
all_day = event_data.get("all_day", False)
|
all_day = event_data.get("all_day", False)
|
||||||
@@ -930,10 +1019,12 @@ class CalendarClient:
|
|||||||
).date()
|
).date()
|
||||||
component["DTSTART"] = start_date
|
component["DTSTART"] = start_date
|
||||||
else:
|
else:
|
||||||
start_dt = dt.datetime.fromisoformat(
|
start_dt, zi = self._parse_event_datetime(
|
||||||
start_str.replace("Z", "+00:00")
|
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:
|
if "end_datetime" in event_data:
|
||||||
end_str = event_data["end_datetime"]
|
end_str = event_data["end_datetime"]
|
||||||
@@ -944,16 +1035,26 @@ class CalendarClient:
|
|||||||
).date()
|
).date()
|
||||||
component["DTEND"] = end_date
|
component["DTEND"] = end_date
|
||||||
else:
|
else:
|
||||||
end_dt = dt.datetime.fromisoformat(
|
end_dt, zi = self._parse_event_datetime(end_str, tz_name)
|
||||||
end_str.replace("Z", "+00:00")
|
if zi is not None:
|
||||||
)
|
used_timezones.add(zi)
|
||||||
component["DTEND"] = end_dt
|
component["DTEND"] = vDDDTypes(end_dt)
|
||||||
|
|
||||||
# Update timestamps
|
# Update timestamps
|
||||||
now = dt.datetime.now(dt.UTC)
|
now = dt.datetime.now(dt.UTC)
|
||||||
component["LAST-MODIFIED"] = vDDDTypes(now)
|
component["LAST-MODIFIED"] = vDDDTypes(now)
|
||||||
component["DTSTAMP"] = 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
|
break
|
||||||
|
|
||||||
return cal.to_ical().decode("utf-8")
|
return cal.to_ical().decode("utf-8")
|
||||||
|
|||||||
@@ -25,8 +25,30 @@ class CalendarEventSummary(BaseModel):
|
|||||||
|
|
||||||
uid: str = Field(description="Event UID")
|
uid: str = Field(description="Event UID")
|
||||||
summary: str = Field(description="Event summary/title")
|
summary: str = Field(description="Event summary/title")
|
||||||
start: str = Field(description="Event start datetime (ISO format)")
|
start: str = Field(
|
||||||
end: Optional[str] = Field(None, description="Event end datetime (ISO format)")
|
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")
|
all_day: bool = Field(default=False, description="Whether event is all-day")
|
||||||
location: Optional[str] = Field(None, description="Event location")
|
location: Optional[str] = Field(None, description="Event location")
|
||||||
description: Optional[str] = Field(None, description="Event description")
|
description: Optional[str] = Field(None, description="Event description")
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
|
|||||||
summary=event.get("title", ""),
|
summary=event.get("title", ""),
|
||||||
start=start,
|
start=start,
|
||||||
end=event.get("end_datetime"),
|
end=event.get("end_datetime"),
|
||||||
|
start_tz=event.get("start_tz"),
|
||||||
|
end_tz=event.get("end_tz"),
|
||||||
all_day=event.get("all_day", False),
|
all_day=event.get("all_day", False),
|
||||||
location=event.get("location") or None,
|
location=event.get("location") or None,
|
||||||
description=event.get("description") or None,
|
description=event.get("description") or None,
|
||||||
@@ -92,13 +94,23 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
attendees: str = "",
|
attendees: str = "",
|
||||||
url: str = "",
|
url: str = "",
|
||||||
color: str = "",
|
color: str = "",
|
||||||
|
timezone: str = "",
|
||||||
):
|
):
|
||||||
"""Create a comprehensive calendar event with full feature support
|
"""Create a comprehensive calendar event with full feature support.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
calendar_name: Name of the calendar to create the event in
|
calendar_name: Name of the calendar to create the event in
|
||||||
title: Event title
|
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
|
ctx: MCP context
|
||||||
end_datetime: ISO format end time, empty for all-day events
|
end_datetime: ISO format end time, empty for all-day events
|
||||||
all_day: Whether this is an all-day event
|
all_day: Whether this is an all-day event
|
||||||
@@ -116,6 +128,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
attendees: Comma-separated email addresses
|
attendees: Comma-separated email addresses
|
||||||
url: Related URL for the event
|
url: Related URL for the event
|
||||||
color: Event color (hex or name)
|
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:
|
Returns:
|
||||||
Dict with event creation result
|
Dict with event creation result
|
||||||
@@ -141,6 +157,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
"attendees": attendees,
|
"attendees": attendees,
|
||||||
"url": url,
|
"url": url,
|
||||||
"color": color,
|
"color": color,
|
||||||
|
"timezone": timezone,
|
||||||
}
|
}
|
||||||
|
|
||||||
return await client.calendar.create_event(calendar_name, event_data)
|
return await client.calendar.create_event(calendar_name, event_data)
|
||||||
@@ -312,9 +329,16 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
attendees: str | None = None,
|
attendees: str | None = None,
|
||||||
url: str | None = None,
|
url: str | None = None,
|
||||||
color: str | None = None,
|
color: str | None = None,
|
||||||
|
timezone: str | None = None,
|
||||||
etag: str = "",
|
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)
|
client = await get_client(ctx)
|
||||||
|
|
||||||
# Build update data with only non-None values
|
# Build update data with only non-None values
|
||||||
@@ -353,6 +377,8 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
event_data["url"] = url
|
event_data["url"] = url
|
||||||
if color is not None:
|
if color is not None:
|
||||||
event_data["color"] = color
|
event_data["color"] = color
|
||||||
|
if timezone is not None:
|
||||||
|
event_data["timezone"] = timezone
|
||||||
|
|
||||||
return await client.calendar.update_event(
|
return await client.calendar.update_event(
|
||||||
calendar_name, event_uid, event_data, etag
|
calendar_name, event_uid, event_data, etag
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
"pydantic>=2.11.4",
|
"pydantic>=2.11.4",
|
||||||
"click>=8.1.8",
|
"click>=8.1.8",
|
||||||
"caldav>=3.0.1,<4.0",
|
"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",
|
"pyjwt[crypto]>=2.8.0",
|
||||||
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||||
"alembic>=1.14.0", # Database migrations
|
"alembic>=1.14.0", # Database migrations
|
||||||
|
|||||||
@@ -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 ``<C:expand>``; 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"
|
||||||
@@ -2158,6 +2158,7 @@ dependencies = [
|
|||||||
{ name = "python-json-logger" },
|
{ name = "python-json-logger" },
|
||||||
{ name = "pythonvcard4" },
|
{ name = "pythonvcard4" },
|
||||||
{ name = "qdrant-client" },
|
{ name = "qdrant-client" },
|
||||||
|
{ name = "recurring-ical-events" },
|
||||||
{ name = "starlette" },
|
{ name = "starlette" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2212,6 +2213,7 @@ requires-dist = [
|
|||||||
{ name = "python-json-logger", specifier = ">=3.2.0" },
|
{ name = "python-json-logger", specifier = ">=3.2.0" },
|
||||||
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
||||||
{ name = "qdrant-client", specifier = ">=1.17.0" },
|
{ name = "qdrant-client", specifier = ">=1.17.0" },
|
||||||
|
{ name = "recurring-ical-events", specifier = ">=3.8.0,<4.0" },
|
||||||
{ name = "starlette", specifier = "<1.0" },
|
{ name = "starlette", specifier = "<1.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user