From 66e32d4705f34db622973f3fb1f31ab571b8a1d5 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 8 Feb 2026 12:43:40 +0100 Subject: [PATCH] fix: expand recurring events in date-range queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #539 fixed date-range filtering so events outside the queried range are excluded. However, recurring events still returned the master event with its original DTSTART instead of expanded occurrences. Add element to CalDAV REPORT requests (RFC 4791 §9.6.5) when both date bounds are provided, so the server returns one VEVENT per occurrence with the correct DTSTART. Refactor VEVENT parsing into a shared helper and add _parse_all_ical_events() to handle multi-VEVENT responses from expanded results. Closes #538 Co-Authored-By: Claude Opus 4.6 --- nextcloud_mcp_server/client/calendar.py | 167 +++++++++++------- .../calendar/test_calendar_operations.py | 91 ++++++++++ 2 files changed, 191 insertions(+), 67 deletions(-) diff --git a/nextcloud_mcp_server/client/calendar.py b/nextcloud_mcp_server/client/calendar.py index a481993..dbc0b7a 100644 --- a/nextcloud_mcp_server/client/calendar.py +++ b/nextcloud_mcp_server/client/calendar.py @@ -260,19 +260,30 @@ class CalendarClient: 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) else: # No date filter — fetch all events events = await calendar.events() + expanded = False result = [] for event in events: await event.load(only_if_unloaded=True) if event.data: - 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 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 len(result) >= limit: break @@ -303,9 +314,14 @@ class CalendarClient: outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter filter_element = cdav.Filter() + outer_comp_filter - query = ( - cdav.CalendarQuery() + [dav.Prop() + cdav.CalendarData()] + filter_element - ) + # 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( query.xmlelement(), encoding="utf-8", xml_declaration=True @@ -685,75 +701,92 @@ class CalendarClient: 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(). + """ + event_data: Dict[str, Any] = { + "uid": str(component.get("uid", "")), + "title": str(component.get("summary", "")), + "description": str(component.get("description", "")), + "location": str(component.get("location", "")), + "status": str(component.get("status", "CONFIRMED")), + "priority": int(component.get("priority", 5)), + "privacy": str(component.get("class", "PUBLIC")), + "url": str(component.get("url", "")), + } + + # Handle dates + dtstart = component.get("dtstart") + if dtstart: + if 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 + + 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() + + # Handle categories + categories = component.get("categories") + if categories: + event_data["categories"] = self._extract_categories(categories) + + # Handle recurrence + rrule = component.get("rrule") + if rrule: + event_data["recurring"] = True + event_data["recurrence_rule"] = str(rrule) + + # Handle attendees + attendees = [] + for attendee in component.get("attendee", []): + if isinstance(attendee, list): + attendees.extend(str(a).replace("mailto:", "") for a in attendee) + else: + attendees.append(str(attendee).replace("mailto:", "")) + if attendees: + event_data["attendees"] = ",".join(attendees) + + return event_data + def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]: - """Parse iCalendar text and extract event data.""" + """Parse iCalendar text and extract the first event.""" try: cal = Calendar.from_ical(ical_text) for component in cal.walk(): if component.name == "VEVENT": - event_data = { - "uid": str(component.get("uid", "")), - "title": str(component.get("summary", "")), - "description": str(component.get("description", "")), - "location": str(component.get("location", "")), - "status": str(component.get("status", "CONFIRMED")), - "priority": int(component.get("priority", 5)), - "privacy": str(component.get("class", "PUBLIC")), - "url": str(component.get("url", "")), - } - - # Handle dates - dtstart = component.get("dtstart") - if dtstart: - if 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 - - 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() - - # Handle categories - categories = component.get("categories") - if categories: - event_data["categories"] = self._extract_categories(categories) - - # Handle recurrence - rrule = component.get("rrule") - if rrule: - event_data["recurring"] = True - event_data["recurrence_rule"] = str(rrule) - - # Handle attendees - attendees = [] - for attendee in component.get("attendee", []): - if isinstance(attendee, list): - attendees.extend( - str(a).replace("mailto:", "") for a in attendee - ) - else: - attendees.append(str(attendee).replace("mailto:", "")) - if attendees: - event_data["attendees"] = ",".join(attendees) - - return event_data - + return self._extract_vevent_data(component) return None - except Exception as e: 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: diff --git a/tests/client/calendar/test_calendar_operations.py b/tests/client/calendar/test_calendar_operations.py index 2ecc163..5a9df16 100644 --- a/tests/client/calendar/test_calendar_operations.py +++ b/tests/client/calendar/test_calendar_operations.py @@ -460,6 +460,97 @@ async def test_list_events_date_range_filtering( logger.warning(f"Cleanup failed for event {uid}: {e}") +async def test_recurring_event_date_range_expansion( + nc_client: NextcloudClient, temporary_calendar: str +): + """Test that recurring events are expanded into individual occurrences. + + When querying with a date range, a recurring event should return one + event dict per occurrence within the range, each with the correct + start_datetime for that occurrence (not the original master event date). + + This is a follow-up to GH-538: the time-range filter correctly selected + recurring events, but returned the master event with its original DTSTART + instead of expanding occurrences. + """ + calendar_name = temporary_calendar + event_uid = None + + try: + # Create a daily recurring event starting 7 days ago + start = datetime.now() - timedelta(days=7) + event_data = { + "title": f"Daily Recurrence {uuid.uuid4().hex[:8]}", + "start_datetime": start.strftime("%Y-%m-%dT09:00:00"), + "end_datetime": start.strftime("%Y-%m-%dT10:00:00"), + "description": "Daily recurring event for expansion test", + "recurring": True, + "recurrence_rule": "FREQ=DAILY", + } + result = await nc_client.calendar.create_event(calendar_name, event_data) + event_uid = result["uid"] + logger.info(f"Created daily recurring event: {event_uid}") + + # Query with date range: today → 3 days ahead + query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + query_end = query_start + timedelta(days=3) + + events = await nc_client.calendar.get_calendar_events( + calendar_name=calendar_name, + start_datetime=query_start, + end_datetime=query_end, + limit=50, + ) + + # Filter to only our recurring event (calendar may have others) + our_events = [e for e in events if e["uid"] == event_uid] + + # Should have multiple occurrences (one per day in the range) + assert len(our_events) >= 2, ( + f"Expected multiple expanded occurrences, got {len(our_events)}. " + f"Expansion may not be working." + ) + + # Each occurrence should have a different start_datetime + start_dates = [e["start_datetime"] for e in our_events] + assert len(set(start_dates)) == len(our_events), ( + f"Each occurrence should have a unique start_datetime, got: {start_dates}" + ) + + # No start_datetime should fall outside the queried range + for e in our_events: + event_start = datetime.fromisoformat(e["start_datetime"]) + # Remove timezone info for comparison if present + if event_start.tzinfo is not None: + event_start = event_start.replace(tzinfo=None) + assert event_start >= query_start - timedelta(hours=1), ( + f"Occurrence {e['start_datetime']} is before query start {query_start}" + ) + assert event_start < query_end + timedelta(hours=1), ( + f"Occurrence {e['start_datetime']} is after query end {query_end}" + ) + + # Expanded occurrences should NOT have recurrence rules + # (server strips RRULE when expanding) + for e in our_events: + assert not e.get("recurring"), ( + "Expanded occurrence should not have recurring=True, " + "RRULE should be stripped by server-side expansion" + ) + + logger.info( + f"Recurring event expansion works: {len(our_events)} occurrences " + f"returned with unique start dates" + ) + + finally: + if event_uid: + try: + await nc_client.calendar.delete_event(calendar_name, event_uid) + except Exception as e: + logger.warning(f"Cleanup failed for recurring event {event_uid}: {e}") + + async def test_calendar_operations_error_handling( nc_client: NextcloudClient, ):