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, ):