fix: use CalDAV time-range filter for calendar date range queries

get_calendar_events() accepted start/end datetime parameters but called
calendar.events() which fetches all events, silently discarding the
date filters. This caused nc_calendar_list_events and
nc_calendar_get_upcoming_events to return the entire calendar history.

Add _search_events_by_date() helper that builds a CalDAV REPORT query
with a <time-range> filter (RFC 4791 §9.9) for server-side filtering.
Falls back to calendar.events() when no dates are given.

Closes #538

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-02-07 17:35:33 +01:00
parent 334d62825c
commit b79ac29a9d
2 changed files with 134 additions and 2 deletions
+54 -2
View File
@@ -255,8 +255,14 @@ class CalendarClient:
"""List events in a calendar within date range."""
calendar = self._get_calendar(calendar_name)
# Get all events using caldav library (now with proper filter)
events = await calendar.events()
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
)
else:
# No date filter — fetch all events
events = await calendar.events()
result = []
for event in events:
@@ -274,6 +280,52 @@ class CalendarClient:
logger.debug(f"Found {len(result)} events")
return result
async def _search_events_by_date(
self,
calendar: AsyncCalendar,
start_datetime: Optional[dt.datetime] = None,
end_datetime: Optional[dt.datetime] = None,
) -> list:
"""Execute a CalDAV REPORT with time-range filter."""
from caldav.async_collection import AsyncEvent
from caldav.elements import cdav, dav
from lxml import etree # type: ignore[import-untyped]
# Ensure naive datetimes are treated as UTC
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:
end_datetime = end_datetime.replace(tzinfo=dt.UTC)
# Build comp-filter with time-range (mirrors sync Calendar.build_search_xml_query)
inner_comp_filter = cdav.CompFilter(name="VEVENT")
inner_comp_filter += cdav.TimeRange(start_datetime, end_datetime)
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
)
body = etree.tostring(
query.xmlelement(), encoding="utf-8", xml_declaration=True
)
assert calendar.client is not None
response = await calendar.client.report(str(calendar.url), body, depth=1)
# Parse response (same pattern as AsyncCalendar.search)
objects = []
response_data = response.expand_simple_props([cdav.CalendarData()])
for href, props in response_data.items():
if href == str(calendar.url):
continue
cal_data = props.get(cdav.CalendarData.tag)
if cal_data:
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
objects.append(obj)
return objects
async def create_event(
self, calendar_name: str, event_data: Dict[str, Any]
) -> Dict[str, Any]: