From e3510a06e7d1be72fa9cfc412f81b8b8c2670ccb Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Thu, 30 Jul 2026 14:39:43 +0200 Subject: [PATCH 01/12] feat: add advanced calendar event search Signed-off-by: Dick Tump Assisted-by: Codex:gpt-5.6-sol --- .../lib/all_tools/calendar_advanced_search.py | 349 +++++++++++ ex_app/lib/all_tools/lib/calendar_search.py | 573 ++++++++++++++++++ 2 files changed, 922 insertions(+) create mode 100644 ex_app/lib/all_tools/calendar_advanced_search.py create mode 100644 ex_app/lib/all_tools/lib/calendar_search.py diff --git a/ex_app/lib/all_tools/calendar_advanced_search.py b/ex_app/lib/all_tools/calendar_advanced_search.py new file mode 100644 index 0000000..e63b116 --- /dev/null +++ b/ex_app/lib/all_tools/calendar_advanced_search.py @@ -0,0 +1,349 @@ +# SPDX-FileCopyrightText: 2026 Dick Tump +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Read-only, bounded calendar event search tools.""" + +from __future__ import annotations + +from urllib.parse import urlsplit + +from langchain_core.tools import tool +from nc_py_api import AsyncNextcloudApp + +from ex_app.lib.all_tools.lib.calendar_search import ( + MAX_CALENDARS, + CalendarCollection, + SearchBounds, + calendar_home_propfind_body, + calendar_query_body, + current_user_principal_propfind_body, + event_identity, + event_sort_key, + expand_and_filter_events, + parse_calendar_collections, + parse_calendar_data, + parse_calendar_home, + parse_current_user_principal, + principal_calendar_home_propfind_body, + validate_search, +) +from ex_app.lib.all_tools.lib.decorator import safe_tool + + +class CalendarRequestError(RuntimeError): + def __init__(self, status_code: int, request_stage: str): + super().__init__(f"Unexpected HTTP status {status_code}") + self.status_code = status_code + self.request_stage = request_stage + + +async def get_tools(nc: AsyncNextcloudApp): + @tool + @safe_tool + async def search_calendar_events( + range_start: str, + range_end: str, + calendar_names: list[str] | None = None, + text_term_groups: list[list[str]] | None = None, + limit: int = 50, + ): + """Search the current user's calendar events in a required, bounded time range. + + Use ISO 8601 date-times with a UTC offset or Z. range_end is exclusive. + Recurrences are expanded, moved exceptions replace their original occurrence, and cancellations are omitted. + Use text_term_groups to search summary, description, location and categories before events are returned. + Terms within one group are alternatives (OR), while every group must match (AND). + Supply likely synonyms or translations as alternatives when the user's wording and calendar language may differ. + An empty complete result proves no matching events. Never infer absence when complete is false. + :param range_start: Inclusive range start, for example 2026-10-01T00:00:00+02:00. + :param range_end: Exclusive range end, no more than 370 days after range_start. + :param calendar_names: Optional exact calendar display names. Searches every event calendar when omitted. + :param text_term_groups: Optional groups of case-insensitive substring alternatives. + :param limit: Maximum events returned, from 1 to 100. + :return: Matching event fields plus explicit completeness, truncation and failure metadata. + """ + return await _search_calendar_events( + nc, + range_start=range_start, + range_end=range_end, + calendar_names=calendar_names, + text_term_groups=text_term_groups, + limit=limit, + ) + + return [search_calendar_events] + + +async def _search_calendar_events( + nc: AsyncNextcloudApp, + *, + range_start: str, + range_end: str, + calendar_names: list[str] | None, + text_term_groups: list[list[str]] | None, + limit: int, +) -> dict: + bounds, requested_names, term_groups, result_limit = validate_search( + range_start, + range_end, + calendar_names, + text_term_groups, + limit, + ) + failures = [] + try: + calendars, failed_discovery_responses = await _list_event_calendars(nc) + except Exception as exception: + return _failed_result(bounds, _failure_entry("calendar_discovery", exception)) + if failed_discovery_responses: + failures.append( + { + "stage": "calendar_discovery", + "error": "Some calendar collections could not be inspected", + "count": failed_discovery_responses, + } + ) + + selected_calendars, missing_names = _select_calendars(calendars, requested_names) + if missing_names: + failures.append( + { + "stage": "calendar_selection", + "error": "Requested calendars were not found", + "calendars": missing_names, + } + ) + + selected_calendars, calendar_limit_failure = _apply_calendar_limit(selected_calendars) + if calendar_limit_failure: + failures.append(calendar_limit_failure) + + events, search_failures, resource_truncated = await _search_selected_calendars( + nc, + selected_calendars, + bounds, + term_groups, + ) + failures.extend(search_failures) + resource_truncated = resource_truncated or calendar_limit_failure is not None + + unique_events = {event_identity(event): event for event in events} + sorted_events = sorted( + unique_events.values(), + key=lambda event: event_sort_key(event, bounds.start.tzinfo), + ) + for event in sorted_events: + event.pop("_uid", None) + event.pop("_calendar_href", None) + result_truncated = len(sorted_events) > result_limit + truncated = resource_truncated or result_truncated + complete = not failures and not truncated + result = { + "range": { + "start": bounds.start.isoformat(), + "end": bounds.end.isoformat(), + "end_exclusive": True, + }, + "complete": complete, + "truncated": truncated, + "calendars_searched": [calendar.name for calendar in selected_calendars], + "matches_found": len(sorted_events), + "returned": min(len(sorted_events), result_limit), + "events": sorted_events[:result_limit], + "failures": failures, + } + if not complete: + result["completeness_warning"] = "The search was incomplete. Do not infer that an event is absent." + return result + + +def _apply_calendar_limit( + calendars: list[CalendarCollection], +) -> tuple[list[CalendarCollection], dict | None]: + if len(calendars) <= MAX_CALENDARS: + return calendars, None + return calendars[:MAX_CALENDARS], { + "stage": "calendar_limit", + "error": "Calendar processing limit reached", + "limit": MAX_CALENDARS, + } + + +async def _search_selected_calendars( + nc: AsyncNextcloudApp, + calendars: list[CalendarCollection], + bounds: SearchBounds, + term_groups: list[list[str]], +) -> tuple[list[dict], list[dict], bool]: + events = [] + failures = [] + resource_truncated = False + for calendar in calendars: + try: + xml_text = await _calendar_report(nc, calendar, calendar_query_body(bounds)) + resources, failed_resources, calendar_truncated = parse_calendar_data(xml_text) + except Exception as exception: + failure = _failure_entry("calendar_query", exception) + failure["calendar"] = calendar.name + failures.append(failure) + continue + if failed_resources: + failures.append( + { + "calendar": calendar.name, + "stage": "resource_read", + "error": "Some calendar resources could not be read", + "count": failed_resources, + } + ) + if calendar_truncated: + resource_truncated = True + failures.append( + { + "calendar": calendar.name, + "stage": "resource_limit", + "error": "Calendar resource processing limit reached", + } + ) + events.extend(_parse_calendar_resources(resources, calendar, bounds, term_groups, failures)) + return events, failures, resource_truncated + + +def _parse_calendar_resources( + resources: list[str], + calendar: CalendarCollection, + bounds: SearchBounds, + term_groups: list[list[str]], + failures: list[dict], +) -> list[dict]: + events = [] + parse_failures = 0 + for resource in resources: + try: + resource_events = expand_and_filter_events(resource, calendar.name, bounds, term_groups) + for event in resource_events: + event["_calendar_href"] = calendar.href + events.extend(resource_events) + except Exception: + parse_failures += 1 + if parse_failures: + failures.append( + { + "calendar": calendar.name, + "stage": "event_parsing", + "error": "Some calendar resources contained invalid or unsupported event data", + "count": parse_failures, + } + ) + return events + + +def get_category_name(): + return "Calendar: Advanced Search" + + +async def is_available(nc: AsyncNextcloudApp): + return True + + +async def _list_event_calendars(nc: AsyncNextcloudApp) -> tuple[list[CalendarCollection], int]: + principal_response = await nc._session.adapter_dav.request( + "PROPFIND", + "/", + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"}, + data=current_user_principal_propfind_body(), + ) + _require_success(principal_response, {207}, "current_user_principal") + principal_path = _same_origin_dav_path(nc, parse_current_user_principal(principal_response.text)) + + home_response = await nc._session.adapter_dav.request( + "PROPFIND", + principal_path, + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"}, + data=principal_calendar_home_propfind_body(), + ) + _require_success(home_response, {207}, "calendar_home") + home_path = _same_origin_dav_path(nc, parse_calendar_home(home_response.text)) + + calendars_response = await nc._session.adapter_dav.request( + "PROPFIND", + home_path, + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, + data=calendar_home_propfind_body(), + ) + _require_success(calendars_response, {207}, "calendar_collections") + return parse_calendar_collections(calendars_response.text) + + +async def _calendar_report(nc: AsyncNextcloudApp, calendar: CalendarCollection, body: str) -> str: + request_path = _same_origin_dav_path(nc, calendar.href) + response = await nc._session.adapter_dav.request( + "REPORT", + request_path, + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, + data=body, + ) + _require_success(response, {207}, "calendar_query") + return response.text + + +def _same_origin_dav_path(nc: AsyncNextcloudApp, href: str) -> str: + target = urlsplit(href) + endpoint = urlsplit(nc._session.cfg.endpoint) + if target.scheme and (target.scheme, target.netloc) != (endpoint.scheme, endpoint.netloc): + raise ValueError("Calendar collection URL does not belong to this Nextcloud server") + dav_path = urlsplit(nc._session.cfg.dav_endpoint).path.rstrip("/") + if target.path == dav_path: + relative_path = "/" + elif target.path.startswith(f"{dav_path}/"): + relative_path = target.path[len(dav_path) :] + else: + raise ValueError("Calendar collection URL is outside the Nextcloud DAV endpoint") + return relative_path + (f"?{target.query}" if target.query else "") + + +def _require_success(response, allowed_statuses: set[int], request_stage: str) -> None: + if response.status_code not in allowed_statuses: + raise CalendarRequestError(response.status_code, request_stage) + + +def _select_calendars( + calendars: list[CalendarCollection], + requested_names: list[str] | None, +) -> tuple[list[CalendarCollection], list[str]]: + if requested_names is None: + return calendars, [] + requested = {name.casefold(): name for name in requested_names} + selected = [calendar for calendar in calendars if calendar.name.casefold() in requested] + found = {calendar.name.casefold() for calendar in selected} + missing = [name for name in requested_names if name.casefold() not in found] + return selected, missing + + +def _failure_entry(stage: str, exception: Exception) -> dict: + failure = { + "stage": stage, + "error": f"{stage.replace('_', ' ').capitalize()} failed ({type(exception).__name__})", + } + if isinstance(exception, CalendarRequestError): + failure["http_status"] = exception.status_code + failure["request_stage"] = exception.request_stage + return failure + + +def _failed_result(bounds, failure: dict) -> dict: + return { + "range": { + "start": bounds.start.isoformat(), + "end": bounds.end.isoformat(), + "end_exclusive": True, + }, + "complete": False, + "truncated": False, + "calendars_searched": [], + "matches_found": 0, + "returned": 0, + "events": [], + "failures": [failure], + "completeness_warning": "The search was incomplete. Do not infer that an event is absent.", + } diff --git a/ex_app/lib/all_tools/lib/calendar_search.py b/ex_app/lib/all_tools/lib/calendar_search.py new file mode 100644 index 0000000..f123890 --- /dev/null +++ b/ex_app/lib/all_tools/lib/calendar_search.py @@ -0,0 +1,573 @@ +# SPDX-FileCopyrightText: 2026 Dick Tump +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Parsing, validation and recurrence helpers for calendar event search.""" + +from __future__ import annotations + +import unicodedata +from dataclasses import dataclass +from datetime import UTC, date, datetime, time, timedelta, tzinfo +from typing import Any + +import recurring_ical_events +from icalendar import Calendar +from lxml import etree as ET + +DAV_NAMESPACE = "DAV:" +CALDAV_NAMESPACE = "urn:ietf:params:xml:ns:caldav" +MAX_CALENDARS = 50 +MAX_CALENDAR_NAMES = 20 +MAX_GROUPS = 4 +MAX_TERMS_PER_GROUP = 8 +MAX_TERM_LENGTH = 64 +MAX_RANGE_DAYS = 370 +MAX_RESULT_LIMIT = 100 +MAX_RESOURCES_PER_CALENDAR = 2_000 +MAX_EXPANDED_OCCURRENCES_PER_RESOURCE = 5_000 +MAX_XML_BYTES = 10 * 1024 * 1024 +MAX_ICALENDAR_BYTES = 512 * 1024 +RECURRENCE_UNIT_SECONDS = { + "SECONDLY": 1, + "MINUTELY": 60, + "HOURLY": 60 * 60, + "DAILY": 24 * 60 * 60, + "WEEKLY": 7 * 24 * 60 * 60, + "MONTHLY": 28 * 24 * 60 * 60, + "YEARLY": 365 * 24 * 60 * 60, +} + +NAMESPACES = {"d": DAV_NAMESPACE, "c": CALDAV_NAMESPACE} + + +@dataclass(frozen=True) +class CalendarCollection: + name: str + href: str + + +@dataclass(frozen=True) +class SearchBounds: + start: datetime + end: datetime + + +def validate_search( + range_start: str, + range_end: str, + calendar_names: list[str] | None, + text_term_groups: list[list[str]] | None, + limit: int, +) -> tuple[SearchBounds, list[str] | None, list[list[str]], int]: + start = _parse_bound(range_start, "range_start") + end = _parse_bound(range_end, "range_end") + if start >= end: + raise ValueError("range_start must be before range_end") + if end - start > timedelta(days=MAX_RANGE_DAYS): + raise ValueError(f"Calendar searches may span at most {MAX_RANGE_DAYS} days") + + result_limit = _validate_result_limit(limit) + validated_names = _validate_calendar_names(calendar_names) + groups = _validate_text_term_groups(text_term_groups) + return SearchBounds(start=start, end=end), validated_names, groups, result_limit + + +def _validate_result_limit(limit: int) -> int: + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_RESULT_LIMIT: + raise ValueError(f"limit must be between 1 and {MAX_RESULT_LIMIT}") + return limit + + +def _validate_calendar_names(calendar_names: list[str] | None) -> list[str] | None: + if calendar_names is None: + return None + if not isinstance(calendar_names, list) or not calendar_names or len(calendar_names) > MAX_CALENDAR_NAMES: + raise ValueError(f"calendar_names must contain between 1 and {MAX_CALENDAR_NAMES} names") + validated_names = [] + for name in calendar_names: + if not isinstance(name, str) or not name.strip() or len(name.strip()) > 128: + raise ValueError("Each calendar name must be a non-empty string of at most 128 characters") + validated_names.append(name.strip()) + return validated_names + + +def _validate_text_term_groups(text_term_groups: list[list[str]] | None) -> list[list[str]]: + if text_term_groups is None: + return [] + if not isinstance(text_term_groups, list) or not text_term_groups or len(text_term_groups) > MAX_GROUPS: + raise ValueError(f"text_term_groups must contain between 1 and {MAX_GROUPS} groups") + return [_validate_text_term_group(group) for group in text_term_groups] + + +def _validate_text_term_group(group: list[str]) -> list[str]: + if not isinstance(group, list) or not group or len(group) > MAX_TERMS_PER_GROUP: + raise ValueError(f"Each text term group must contain between 1 and {MAX_TERMS_PER_GROUP} alternatives") + validated_group = [] + for term in group: + if not isinstance(term, str) or not term.strip() or len(term.strip()) > MAX_TERM_LENGTH: + raise ValueError(f"Each text term must be a non-empty string of at most {MAX_TERM_LENGTH} characters") + validated_group.append(term.strip()) + return validated_group + + +def parse_calendar_collections(xml_text: str) -> tuple[list[CalendarCollection], int]: + _check_xml_size(xml_text) + root = _parse_xml(xml_text) + calendars = [] + failed_responses = 0 + for response in root.findall("d:response", NAMESPACES): + href = response.findtext("d:href", default="", namespaces=NAMESPACES).strip() + response_succeeded = False + for propstat in response.findall("d:propstat", NAMESPACES): + status = propstat.findtext("d:status", default="", namespaces=NAMESPACES) + if " 200 " not in status: + continue + response_succeeded = True + prop = propstat.find("d:prop", NAMESPACES) + if prop is None: + continue + resource_type = prop.find("d:resourcetype", NAMESPACES) + if resource_type is None or resource_type.find("c:calendar", NAMESPACES) is None: + continue + component_set = prop.find("c:supported-calendar-component-set", NAMESPACES) + if component_set is not None: + component_names = { + component.attrib.get("name", "").upper() + for component in component_set.findall("c:comp", NAMESPACES) + } + if component_names and "VEVENT" not in component_names: + continue + name = prop.findtext("d:displayname", default="", namespaces=NAMESPACES).strip() + if href and name: + calendars.append(CalendarCollection(name=name, href=href)) + if not response_succeeded: + failed_responses += 1 + return calendars, failed_responses + + +def parse_current_user_principal(xml_text: str) -> str: + return _parse_href_property(xml_text, "d:current-user-principal") + + +def parse_calendar_home(xml_text: str) -> str: + return _parse_href_property(xml_text, "c:calendar-home-set") + + +def parse_calendar_data(xml_text: str) -> tuple[list[str], int, bool]: + _check_xml_size(xml_text) + root = _parse_xml(xml_text) + resources = [] + failed_resources = 0 + truncated = False + for response in root.findall("d:response", NAMESPACES): + calendar_data = None + for propstat in response.findall("d:propstat", NAMESPACES): + status = propstat.findtext("d:status", default="", namespaces=NAMESPACES) + if " 200 " not in status: + continue + prop = propstat.find("d:prop", NAMESPACES) + if prop is None: + continue + data_element = prop.find("c:calendar-data", NAMESPACES) + if data_element is not None and data_element.text: + calendar_data = data_element.text + if calendar_data is None: + failed_resources += 1 + continue + if len(calendar_data.encode("utf-8")) > MAX_ICALENDAR_BYTES: + failed_resources += 1 + continue + if len(resources) >= MAX_RESOURCES_PER_CALENDAR: + truncated = True + continue + resources.append(calendar_data) + return resources, failed_resources, truncated + + +def expand_and_filter_events( + icalendar_text: str, + calendar_name: str, + bounds: SearchBounds, + text_term_groups: list[list[str]], +) -> list[dict[str, Any]]: + calendar = Calendar.from_ical(icalendar_text) + _validate_expansion_limits(calendar, bounds) + recurrence_by_uid = _recurrence_metadata(calendar) + occurrences = recurring_ical_events.of(calendar, components=["VEVENT"]).between(bounds.start, bounds.end) + results = [] + for component in occurrences: + event = _event_from_component(component, calendar_name, recurrence_by_uid, text_term_groups) + if event is not None: + results.append(event) + return results + + +def _validate_expansion_limits(calendar: Calendar, bounds: SearchBounds) -> None: + estimated_occurrences = 0 + for component in calendar.walk("VEVENT"): + rrule = component.get("RRULE") + expansion_margin = timedelta(0) + if rrule is not None or component.get("RECURRENCE-ID") is not None: + expansion_margin = _validate_recurrence_duration_and_shift(component, bounds) + if rrule is None: + estimated_occurrences += 1 + else: + estimated_occurrences += _estimate_rrule_occurrences(rrule, bounds, expansion_margin) + estimated_occurrences += _rdate_count(component.get("RDATE")) + if estimated_occurrences > MAX_EXPANDED_OCCURRENCES_PER_RESOURCE: + raise ValueError("Calendar resource recurrence expansion exceeded the processing limit") + + +def _estimate_rrule_occurrences(rrule: Any, bounds: SearchBounds, expansion_margin: timedelta) -> int: + frequency = str(_first_recurrence_value(rrule.get("FREQ")) or "").upper() + unit_seconds = RECURRENCE_UNIT_SECONDS.get(frequency) + if unit_seconds is None: + raise ValueError(f"Unsupported recurrence frequency: {frequency or 'unknown'}") + interval = int(_first_recurrence_value(rrule.get("INTERVAL")) or 1) + expanded_seconds = (bounds.end - bounds.start + expansion_margin).total_seconds() + base_occurrences = int(expanded_seconds // (unit_seconds * interval)) + 2 + estimated = base_occurrences * _recurrence_date_multiplier(rrule, frequency) + estimated *= _recurrence_time_multiplier(rrule, frequency) + count = _first_recurrence_value(rrule.get("COUNT")) + return min(estimated, int(count)) if count is not None else estimated + + +def _recurrence_date_multiplier(rrule: Any, frequency: str) -> int: + if frequency == "WEEKLY": + return _recurrence_value_count(rrule.get("BYDAY")) + if frequency == "MONTHLY": + return max( + 1, + _recurrence_value_count(rrule.get("BYMONTHDAY"), default=0), + _recurrence_value_count(rrule.get("BYDAY"), default=0) * 5, + ) + if frequency == "YEARLY": + months_for_month_days = _recurrence_value_count( + rrule.get("BYMONTH"), + default=12 if rrule.get("BYMONTHDAY") is not None else 1, + ) + return max( + 1, + _recurrence_value_count(rrule.get("BYYEARDAY"), default=0), + _recurrence_value_count(rrule.get("BYWEEKNO"), default=0) * 7, + _recurrence_value_count(rrule.get("BYMONTHDAY"), default=0) * months_for_month_days, + _recurrence_value_count(rrule.get("BYDAY"), default=0) * 53, + _recurrence_value_count(rrule.get("BYMONTH")), + ) + return 1 + + +def _recurrence_time_multiplier(rrule: Any, frequency: str) -> int: + multiplier = 1 + if frequency in {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"}: + multiplier *= _recurrence_value_count(rrule.get("BYHOUR")) + if frequency in {"HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"}: + multiplier *= _recurrence_value_count(rrule.get("BYMINUTE")) + if frequency != "SECONDLY": + multiplier *= _recurrence_value_count(rrule.get("BYSECOND")) + return multiplier + + +def _validate_recurrence_duration_and_shift(component: Any, bounds: SearchBounds) -> timedelta: + start = _decoded_datetime(component, "DTSTART") + if start is None: + return timedelta(0) + start_datetime = _temporal_to_datetime(start, bounds) + end_datetime = _temporal_to_datetime(_event_end(component, start), bounds) + duration = end_datetime - start_datetime + if duration > timedelta(days=MAX_RANGE_DAYS): + raise ValueError("Recurring event duration exceeded the processing limit") + + recurrence_id = _decoded_datetime(component, "RECURRENCE-ID") + if recurrence_id is None: + return max(duration, timedelta(0)) + recurrence_datetime = _temporal_to_datetime(recurrence_id, bounds) + shift = abs(start_datetime - recurrence_datetime) + if shift > timedelta(days=MAX_RANGE_DAYS): + raise ValueError("Recurring event exception shift exceeded the processing limit") + return max(duration, timedelta(0)) + shift + + +def _temporal_to_datetime(value: date | datetime, bounds: SearchBounds) -> datetime: + if isinstance(value, datetime): + return value if value.tzinfo is not None else value.replace(tzinfo=bounds.start.tzinfo) + return datetime.combine(value, time.min, bounds.start.tzinfo) + + +def _recurrence_value_count(value: Any, *, default: int = 1) -> int: + if value is None: + return default + return max(1, len(value)) if isinstance(value, list) else 1 + + +def _rdate_count(value: Any) -> int: + if value is None: + return 0 + values = value if isinstance(value, list) else [value] + return sum(len(item.dts) if hasattr(item, "dts") else 1 for item in values) + + +def _event_from_component( + component: Any, + calendar_name: str, + recurrence_by_uid: dict[str, dict[str, Any]], + text_term_groups: list[list[str]], +) -> dict[str, Any] | None: + status = _property_text(component, "STATUS").upper() + if status == "CANCELLED": + return None + + text_fields = { + "summary": _property_text(component, "SUMMARY"), + "description": _property_text(component, "DESCRIPTION"), + "location": _property_text(component, "LOCATION"), + "categories": _categories_text(component), + } + match = _match_text_groups(text_fields, text_term_groups) + start = _decoded_datetime(component, "DTSTART") + if match is None or start is None: + return None + + uid = _property_text(component, "UID") + event = { + "_uid": uid, + "calendar": calendar_name, + "summary": text_fields["summary"], + "start": _format_temporal(start), + "end": _format_temporal(_event_end(component, start)), + "all_day": isinstance(start, date) and not isinstance(start, datetime), + } + if event["all_day"]: + event["end_exclusive"] = True + timezone_name = _timezone_name(component, start) + if timezone_name: + event["timezone"] = timezone_name + if text_fields["location"]: + event["location"] = text_fields["location"] + if status: + event["status"] = status + recurrence = recurrence_by_uid.get(uid) + if recurrence: + event["recurrence"] = recurrence + if text_term_groups: + event["matched_terms"] = match["terms"] + event["matched_fields"] = match["fields"] + return event + + +def event_sort_key(event: dict[str, Any], floating_timezone: tzinfo = UTC) -> tuple[datetime, str, str]: + start = event["start"] + if event["all_day"]: + instant = datetime.combine(date.fromisoformat(start), time.min, UTC) + else: + instant = datetime.fromisoformat(start) + if instant.tzinfo is None: + instant = instant.replace(tzinfo=floating_timezone) + instant = instant.astimezone(UTC) + return instant, event.get("calendar", "").casefold(), event.get("summary", "").casefold() + + +def event_identity(event: dict[str, Any]) -> tuple[Any, ...]: + return ( + event.get("_calendar_href") or event.get("calendar"), + event.get("_uid") or event.get("summary"), + event.get("start"), + ) + + +def _parse_bound(value: str, field_name: str) -> datetime: + if not isinstance(value, str): + raise ValueError(f"{field_name} must be an ISO 8601 date-time string") + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError as exception: + raise ValueError(f"{field_name} must be an ISO 8601 date-time string") from exception + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError(f"{field_name} must include a UTC offset or Z") + return parsed + + +def _check_xml_size(xml_text: str) -> None: + if len(xml_text.encode("utf-8")) > MAX_XML_BYTES: + raise ValueError("Calendar response exceeded the processing size limit") + + +def _parse_xml(xml_text: str): + parser = ET.XMLParser(resolve_entities=False, no_network=True) + # Entity resolution and network access are disabled explicitly above. + return ET.fromstring(xml_text.encode("utf-8"), parser) # noqa: S320 + + +def _parse_href_property(xml_text: str, property_name: str) -> str: + _check_xml_size(xml_text) + root = _parse_xml(xml_text) + for propstat in root.findall("d:response/d:propstat", NAMESPACES): + status = propstat.findtext("d:status", default="", namespaces=NAMESPACES) + if " 200 " not in status: + continue + prop = propstat.find("d:prop", NAMESPACES) + if prop is None: + continue + value = prop.find(property_name, NAMESPACES) + if value is None: + continue + href = value.findtext("d:href", default="", namespaces=NAMESPACES).strip() + if href: + return href + raise ValueError(f"CalDAV discovery response did not contain {property_name}") + + +def _recurrence_metadata(calendar: Calendar) -> dict[str, dict[str, Any]]: + recurrences = {} + for component in calendar.walk("VEVENT"): + uid = _property_text(component, "UID") + rrule = component.get("RRULE") + rdates = component.get("RDATE") + if not uid or (rrule is None and rdates is None): + continue + metadata: dict[str, Any] = {"recurring": True} + if rrule is not None: + frequency = _first_recurrence_value(rrule.get("FREQ")) + interval = _first_recurrence_value(rrule.get("INTERVAL")) + if frequency: + metadata["frequency"] = str(frequency).lower() + if interval and int(interval) != 1: + metadata["interval"] = int(interval) + recurrences[uid] = metadata + return recurrences + + +def _first_recurrence_value(value: Any) -> Any: + if isinstance(value, list): + return value[0] if value else None + return value + + +def _property_text(component: Any, name: str) -> str: + value = component.get(name) + return "" if value is None else str(value) + + +def _categories_text(component: Any) -> str: + categories = [] + values = component.get("CATEGORIES") + if values is None: + return "" + if not isinstance(values, list): + values = [values] + for value in values: + if hasattr(value, "cats"): + categories.extend(str(category) for category in value.cats) + else: + categories.append(str(value)) + return ", ".join(categories) + + +def _match_text_groups(fields: dict[str, str], groups: list[list[str]]) -> dict[str, list[str]] | None: + if not groups: + return {"terms": [], "fields": []} + normalized_fields = {name: _normalize_text(value) for name, value in fields.items()} + matched_terms = [] + matched_fields = set() + for group in groups: + group_matches = [] + for term in group: + normalized_term = _normalize_text(term) + fields_for_term = [name for name, value in normalized_fields.items() if normalized_term in value] + if fields_for_term: + group_matches.append(term) + matched_fields.update(fields_for_term) + if not group_matches: + return None + matched_terms.extend(group_matches) + return {"terms": matched_terms, "fields": sorted(matched_fields)} + + +def _normalize_text(value: str) -> str: + return unicodedata.normalize("NFKC", value).casefold() + + +def _decoded_datetime(component: Any, name: str) -> date | datetime | None: + value = component.get(name) + return None if value is None else value.dt + + +def _event_end(component: Any, start: date | datetime) -> date | datetime: + end = _decoded_datetime(component, "DTEND") + if end is not None: + return end + duration = component.get("DURATION") + if duration is not None: + return start + duration.dt + if isinstance(start, datetime): + return start + return start + timedelta(days=1) + + +def _format_temporal(value: date | datetime) -> str: + if isinstance(value, datetime): + return value.isoformat() + return value.isoformat() + + +def _timezone_name(component: Any, start: date | datetime) -> str | None: + if not isinstance(start, datetime): + return None + tzid = component["DTSTART"].params.get("TZID") + if tzid: + return str(tzid) + if start.tzinfo is None: + return "floating" + if start.utcoffset() == timedelta(0): + return "UTC" + return str(start.tzinfo) + + +def utc_caldav_timestamp(value: datetime) -> str: + return value.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ") + + +def calendar_home_propfind_body() -> str: + return f""" + + + + + + +""" + + +def current_user_principal_propfind_body() -> str: + return f""" + + + + +""" + + +def principal_calendar_home_propfind_body() -> str: + return f""" + + + + +""" + + +def calendar_query_body(bounds: SearchBounds) -> str: + return f""" + + + + + + + + + + + + +""" From aa45a016b7f1ea2cd9ed40a042e031cb766ea0b9 Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Mon, 3 Aug 2026 17:00:30 +0200 Subject: [PATCH 02/12] build: declare calendar search dependencies Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Dick Tump --- poetry.lock | 2 +- pyproject.toml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 556b7e1..b77c870 100644 --- a/poetry.lock +++ b/poetry.lock @@ -5228,4 +5228,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.11,<4" -content-hash = "502d5c71ea7ae502bf0948d4c718049e9bb3171c48f2016b73e1f6afd815b0e8" +content-hash = "b1cc6f5b69e74ae9ac62858de42e889630efb60512697e8741962d8bec2cd45e" diff --git a/pyproject.toml b/pyproject.toml index 2d5b4d2..8dbd861 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ nc-py-api = {extras = ["calendar"], version = "^0.24.2"} langgraph = "1.*" langchain = "^0.3.25" ics = "^0.7.2" +icalendar = "^7.1.2" +lxml = "^6.1.1" +recurring-ical-events = "^3.8.2" pytz = "^2025.2" langchain-community = "^0.3.23" vobject = "^0.9.9" From e961c340ecaba42af6fdaf35c3776b18ee098035 Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Mon, 3 Aug 2026 17:08:25 +0200 Subject: [PATCH 03/12] fix(calendar): avoid opaque timezone labels Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Dick Tump --- ex_app/lib/all_tools/lib/calendar_search.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ex_app/lib/all_tools/lib/calendar_search.py b/ex_app/lib/all_tools/lib/calendar_search.py index f123890..5a80f81 100644 --- a/ex_app/lib/all_tools/lib/calendar_search.py +++ b/ex_app/lib/all_tools/lib/calendar_search.py @@ -520,7 +520,8 @@ def _timezone_name(component: Any, start: date | datetime) -> str | None: return "floating" if start.utcoffset() == timedelta(0): return "UTC" - return str(start.tzinfo) + timezone_key = getattr(start.tzinfo, "key", None) + return timezone_key if isinstance(timezone_key, str) and timezone_key else None def utc_caldav_timestamp(value: datetime) -> str: From b77d15beb27e9bfcd0cb49abb5990d9a953fed1f Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Mon, 3 Aug 2026 17:37:40 +0200 Subject: [PATCH 04/12] fix(calendar): honor recurrence until bounds Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Dick Tump --- ex_app/lib/all_tools/lib/calendar_search.py | 70 ++++++++++++++++++--- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/ex_app/lib/all_tools/lib/calendar_search.py b/ex_app/lib/all_tools/lib/calendar_search.py index 5a80f81..aff6763 100644 --- a/ex_app/lib/all_tools/lib/calendar_search.py +++ b/ex_app/lib/all_tools/lib/calendar_search.py @@ -212,24 +212,80 @@ def _validate_expansion_limits(calendar: Calendar, bounds: SearchBounds) -> None if rrule is None: estimated_occurrences += 1 else: - estimated_occurrences += _estimate_rrule_occurrences(rrule, bounds, expansion_margin) + estimated_occurrences += _estimate_rrule_occurrences(component, rrule, bounds, expansion_margin) estimated_occurrences += _rdate_count(component.get("RDATE")) if estimated_occurrences > MAX_EXPANDED_OCCURRENCES_PER_RESOURCE: raise ValueError("Calendar resource recurrence expansion exceeded the processing limit") -def _estimate_rrule_occurrences(rrule: Any, bounds: SearchBounds, expansion_margin: timedelta) -> int: +def _estimate_rrule_occurrences( + component: Any, + rrule: Any, + bounds: SearchBounds, + expansion_margin: timedelta, +) -> int: + """Estimate recurrence instances processed from DTSTART through the query stop. + + The recurrence library iterates from DTSTART rather than fast-forwarding to + the query start. A finite series therefore stops at the earlier of UNTIL and + the expanded query end, while still accounting for an expired active span. + """ frequency = str(_first_recurrence_value(rrule.get("FREQ")) or "").upper() unit_seconds = RECURRENCE_UNIT_SECONDS.get(frequency) if unit_seconds is None: raise ValueError(f"Unsupported recurrence frequency: {frequency or 'unknown'}") interval = int(_first_recurrence_value(rrule.get("INTERVAL")) or 1) - expanded_seconds = (bounds.end - bounds.start + expansion_margin).total_seconds() - base_occurrences = int(expanded_seconds // (unit_seconds * interval)) + 2 - estimated = base_occurrences * _recurrence_date_multiplier(rrule, frequency) - estimated *= _recurrence_time_multiplier(rrule, frequency) + if interval < 1: + raise ValueError("Recurrence interval must be positive") count = _first_recurrence_value(rrule.get("COUNT")) - return min(estimated, int(count)) if count is not None else estimated + count_limit = int(count) if count is not None else None + if count_limit is not None and count_limit < 1: + raise ValueError("Recurrence count must be positive") + + start = _decoded_datetime(component, "DTSTART") + if start is None: + raise ValueError("Recurring event is missing DTSTART") + start_datetime = _temporal_to_datetime(start, bounds) + until_datetime = _normalize_recurrence_until(start, rrule.get("UNTIL"), bounds) + if until_datetime is not None and until_datetime < start_datetime: + raise ValueError("Recurrence UNTIL is before DTSTART") + processing_end = bounds.end + expansion_margin + if until_datetime is not None: + processing_end = min(processing_end, until_datetime) + + processed_seconds = max(0, (processing_end - start_datetime).total_seconds()) + estimated = int(processed_seconds // (unit_seconds * interval)) + 2 + estimated *= _recurrence_date_multiplier(rrule, frequency) + estimated *= _recurrence_time_multiplier(rrule, frequency) + return min(estimated, count_limit) if count_limit is not None else estimated + + +def _normalize_recurrence_until( + start: date | datetime, + until_value: Any, + bounds: SearchBounds, +) -> datetime | None: + if until_value is None: + return None + if isinstance(until_value, list): + if len(until_value) != 1: + raise ValueError("Recurrence UNTIL must contain exactly one value") + until = until_value[0] + else: + until = until_value + if not isinstance(until, date): + raise ValueError("Recurrence UNTIL must be a date or datetime") + + start_is_datetime = isinstance(start, datetime) + until_is_datetime = isinstance(until, datetime) + if start_is_datetime != until_is_datetime: + raise ValueError("Recurrence UNTIL type must match DTSTART") + if start_is_datetime and until_is_datetime: + start_is_aware = start.tzinfo is not None + until_is_aware = until.tzinfo is not None + if start_is_aware != until_is_aware: + raise ValueError("Recurrence UNTIL timezone form must match DTSTART") + return _temporal_to_datetime(until, bounds) def _recurrence_date_multiplier(rrule: Any, frequency: str) -> int: From f5ae5d7c34c3ee41608f8f95dfe57c7a168e4408 Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Wed, 5 Aug 2026 16:08:18 +0200 Subject: [PATCH 05/12] perf(calendar): offload event response processing Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Dick Tump --- .../lib/all_tools/calendar_advanced_search.py | 60 +++++++++++-------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/ex_app/lib/all_tools/calendar_advanced_search.py b/ex_app/lib/all_tools/calendar_advanced_search.py index e63b116..893036e 100644 --- a/ex_app/lib/all_tools/calendar_advanced_search.py +++ b/ex_app/lib/all_tools/calendar_advanced_search.py @@ -5,6 +5,7 @@ from __future__ import annotations +import asyncio from urllib.parse import urlsplit from langchain_core.tools import tool @@ -181,41 +182,50 @@ async def _search_selected_calendars( for calendar in calendars: try: xml_text = await _calendar_report(nc, calendar, calendar_query_body(bounds)) - resources, failed_resources, calendar_truncated = parse_calendar_data(xml_text) + calendar_events, calendar_failures, calendar_truncated = await asyncio.to_thread( + _process_calendar_response, + xml_text, + calendar, + bounds, + term_groups, + ) except Exception as exception: failure = _failure_entry("calendar_query", exception) failure["calendar"] = calendar.name failures.append(failure) continue - if failed_resources: - failures.append( - { - "calendar": calendar.name, - "stage": "resource_read", - "error": "Some calendar resources could not be read", - "count": failed_resources, - } - ) - if calendar_truncated: - resource_truncated = True - failures.append( - { - "calendar": calendar.name, - "stage": "resource_limit", - "error": "Calendar resource processing limit reached", - } - ) - events.extend(_parse_calendar_resources(resources, calendar, bounds, term_groups, failures)) + events.extend(calendar_events) + failures.extend(calendar_failures) + resource_truncated = resource_truncated or calendar_truncated return events, failures, resource_truncated -def _parse_calendar_resources( - resources: list[str], +def _process_calendar_response( + xml_text: str, calendar: CalendarCollection, bounds: SearchBounds, term_groups: list[list[str]], - failures: list[dict], -) -> list[dict]: +) -> tuple[list[dict], list[dict], bool]: + resources, failed_resources, resource_truncated = parse_calendar_data(xml_text) + failures = [] + if failed_resources: + failures.append( + { + "calendar": calendar.name, + "stage": "resource_read", + "error": "Some calendar resources could not be read", + "count": failed_resources, + } + ) + if resource_truncated: + failures.append( + { + "calendar": calendar.name, + "stage": "resource_limit", + "error": "Calendar resource processing limit reached", + } + ) + events = [] parse_failures = 0 for resource in resources: @@ -235,7 +245,7 @@ def _parse_calendar_resources( "count": parse_failures, } ) - return events + return events, failures, resource_truncated def get_category_name(): From c2158eb1cb9e69b390ad8b97019abfdc40e0fa35 Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Wed, 5 Aug 2026 16:08:36 +0200 Subject: [PATCH 06/12] perf(calendar): bound parallel calendar queries Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Dick Tump --- .../lib/all_tools/calendar_advanced_search.py | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/ex_app/lib/all_tools/calendar_advanced_search.py b/ex_app/lib/all_tools/calendar_advanced_search.py index 893036e..bf66272 100644 --- a/ex_app/lib/all_tools/calendar_advanced_search.py +++ b/ex_app/lib/all_tools/calendar_advanced_search.py @@ -30,6 +30,8 @@ ) from ex_app.lib.all_tools.lib.decorator import safe_tool +MAX_CONCURRENT_CALENDAR_QUERIES = 4 + class CalendarRequestError(RuntimeError): def __init__(self, status_code: int, request_stage: str): @@ -179,10 +181,40 @@ async def _search_selected_calendars( events = [] failures = [] resource_truncated = False - for calendar in calendars: + semaphore = asyncio.Semaphore(MAX_CONCURRENT_CALENDAR_QUERIES) + query_body = calendar_query_body(bounds) + calendar_results = await asyncio.gather( + *( + _search_calendar( + nc, + calendar, + bounds, + term_groups, + query_body, + semaphore, + ) + for calendar in calendars + ) + ) + for calendar_events, calendar_failures, calendar_truncated in calendar_results: + events.extend(calendar_events) + failures.extend(calendar_failures) + resource_truncated = resource_truncated or calendar_truncated + return events, failures, resource_truncated + + +async def _search_calendar( + nc: AsyncNextcloudApp, + calendar: CalendarCollection, + bounds: SearchBounds, + term_groups: list[list[str]], + query_body: str, + semaphore: asyncio.Semaphore, +) -> tuple[list[dict], list[dict], bool]: + async with semaphore: try: - xml_text = await _calendar_report(nc, calendar, calendar_query_body(bounds)) - calendar_events, calendar_failures, calendar_truncated = await asyncio.to_thread( + xml_text = await _calendar_report(nc, calendar, query_body) + return await asyncio.to_thread( _process_calendar_response, xml_text, calendar, @@ -192,12 +224,7 @@ async def _search_selected_calendars( except Exception as exception: failure = _failure_entry("calendar_query", exception) failure["calendar"] = calendar.name - failures.append(failure) - continue - events.extend(calendar_events) - failures.extend(calendar_failures) - resource_truncated = resource_truncated or calendar_truncated - return events, failures, resource_truncated + return [], [failure], False def _process_calendar_response( From 8127eab6a4d7860db436a0283625012951963d15 Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Wed, 5 Aug 2026 19:29:00 +0200 Subject: [PATCH 07/12] fix(calendar): include subscribed calendars Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Dick Tump --- .../lib/all_tools/calendar_advanced_search.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ex_app/lib/all_tools/calendar_advanced_search.py b/ex_app/lib/all_tools/calendar_advanced_search.py index bf66272..ab26ee0 100644 --- a/ex_app/lib/all_tools/calendar_advanced_search.py +++ b/ex_app/lib/all_tools/calendar_advanced_search.py @@ -31,6 +31,8 @@ from ex_app.lib.all_tools.lib.decorator import safe_tool MAX_CONCURRENT_CALENDAR_QUERIES = 4 +# Nextcloud exposes cached WebCal subscriptions as calendars only when this request header is present. +WEBCAL_CACHING_HEADERS = {"X-NC-CalDAV-Webcal-Caching": "On"} class CalendarRequestError(RuntimeError): @@ -287,7 +289,7 @@ async def _list_event_calendars(nc: AsyncNextcloudApp) -> tuple[list[CalendarCol principal_response = await nc._session.adapter_dav.request( "PROPFIND", "/", - headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"}, + headers=_dav_headers("0"), data=current_user_principal_propfind_body(), ) _require_success(principal_response, {207}, "current_user_principal") @@ -296,7 +298,7 @@ async def _list_event_calendars(nc: AsyncNextcloudApp) -> tuple[list[CalendarCol home_response = await nc._session.adapter_dav.request( "PROPFIND", principal_path, - headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"}, + headers=_dav_headers("0"), data=principal_calendar_home_propfind_body(), ) _require_success(home_response, {207}, "calendar_home") @@ -305,7 +307,7 @@ async def _list_event_calendars(nc: AsyncNextcloudApp) -> tuple[list[CalendarCol calendars_response = await nc._session.adapter_dav.request( "PROPFIND", home_path, - headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, + headers=_dav_headers("1"), data=calendar_home_propfind_body(), ) _require_success(calendars_response, {207}, "calendar_collections") @@ -317,13 +319,21 @@ async def _calendar_report(nc: AsyncNextcloudApp, calendar: CalendarCollection, response = await nc._session.adapter_dav.request( "REPORT", request_path, - headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, + headers=_dav_headers("1"), data=body, ) _require_success(response, {207}, "calendar_query") return response.text +def _dav_headers(depth: str) -> dict[str, str]: + return { + "Content-Type": "application/xml; charset=utf-8", + "Depth": depth, + **WEBCAL_CACHING_HEADERS, + } + + def _same_origin_dav_path(nc: AsyncNextcloudApp, href: str) -> str: target = urlsplit(href) endpoint = urlsplit(nc._session.cfg.endpoint) From 7355638c70dae4c7c076d0e0da4e9aae3398af6c Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Wed, 5 Aug 2026 19:53:36 +0200 Subject: [PATCH 08/12] fix(calendar): return input validation failures Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Dick Tump --- .../lib/all_tools/calendar_advanced_search.py | 53 +++++++++++++------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/ex_app/lib/all_tools/calendar_advanced_search.py b/ex_app/lib/all_tools/calendar_advanced_search.py index ab26ee0..82f5005 100644 --- a/ex_app/lib/all_tools/calendar_advanced_search.py +++ b/ex_app/lib/all_tools/calendar_advanced_search.py @@ -67,13 +67,22 @@ async def search_calendar_events( :param limit: Maximum events returned, from 1 to 100. :return: Matching event fields plus explicit completeness, truncation and failure metadata. """ + try: + bounds, requested_names, term_groups, result_limit = validate_search( + range_start, + range_end, + calendar_names, + text_term_groups, + limit, + ) + except ValueError as exception: + return _input_validation_result(exception) return await _search_calendar_events( nc, - range_start=range_start, - range_end=range_end, - calendar_names=calendar_names, - text_term_groups=text_term_groups, - limit=limit, + bounds=bounds, + requested_names=requested_names, + term_groups=term_groups, + result_limit=result_limit, ) return [search_calendar_events] @@ -82,19 +91,11 @@ async def search_calendar_events( async def _search_calendar_events( nc: AsyncNextcloudApp, *, - range_start: str, - range_end: str, - calendar_names: list[str] | None, - text_term_groups: list[list[str]] | None, - limit: int, + bounds: SearchBounds, + requested_names: list[str] | None, + term_groups: list[list[str]], + result_limit: int, ) -> dict: - bounds, requested_names, term_groups, result_limit = validate_search( - range_start, - range_end, - calendar_names, - text_term_groups, - limit, - ) failures = [] try: calendars, failed_discovery_responses = await _list_event_calendars(nc) @@ -394,3 +395,21 @@ def _failed_result(bounds, failure: dict) -> dict: "failures": [failure], "completeness_warning": "The search was incomplete. Do not infer that an event is absent.", } + + +def _input_validation_result(exception: ValueError) -> dict: + return { + "complete": False, + "truncated": False, + "calendars_searched": [], + "matches_found": 0, + "returned": 0, + "events": [], + "failures": [ + { + "stage": "input_validation", + "error": str(exception), + } + ], + "completeness_warning": "The search did not run because its input was invalid.", + } From c4ebd0b5cafedbcd36cfa15fe1285145e7dc651f Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Sat, 8 Aug 2026 15:48:39 +0200 Subject: [PATCH 09/12] docs(calendar): explain advanced search flow Assisted-by: Codex:gpt-5.6-terra Signed-off-by: Dick Tump --- ex_app/lib/all_tools/calendar_advanced_search.py | 9 +++++++++ ex_app/lib/all_tools/lib/calendar_search.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/ex_app/lib/all_tools/calendar_advanced_search.py b/ex_app/lib/all_tools/calendar_advanced_search.py index 82f5005..6556688 100644 --- a/ex_app/lib/all_tools/calendar_advanced_search.py +++ b/ex_app/lib/all_tools/calendar_advanced_search.py @@ -76,6 +76,7 @@ async def search_calendar_events( limit, ) except ValueError as exception: + # Model-generated tool arguments are reported as a structured result, not a tool exception. return _input_validation_result(exception) return await _search_calendar_events( nc, @@ -96,6 +97,7 @@ async def _search_calendar_events( term_groups: list[list[str]], result_limit: int, ) -> dict: + """Discover, select, query and aggregate calendars while preserving partial failures.""" failures = [] try: calendars, failed_discovery_responses = await _list_event_calendars(nc) @@ -142,6 +144,7 @@ async def _search_calendar_events( event.pop("_uid", None) event.pop("_calendar_href", None) result_truncated = len(sorted_events) > result_limit + # Discovery, selection, query or processing limits make absence unreliable. truncated = resource_truncated or result_truncated complete = not failures and not truncated result = { @@ -184,6 +187,7 @@ async def _search_selected_calendars( events = [] failures = [] resource_truncated = False + # Bound the full request and processing lifetime to cap concurrent DAV work and parsed response data. semaphore = asyncio.Semaphore(MAX_CONCURRENT_CALENDAR_QUERIES) query_body = calendar_query_body(bounds) calendar_results = await asyncio.gather( @@ -217,6 +221,7 @@ async def _search_calendar( async with semaphore: try: xml_text = await _calendar_report(nc, calendar, query_body) + # XML parsing and recurrence expansion are synchronous and may be expensive. return await asyncio.to_thread( _process_calendar_response, xml_text, @@ -236,6 +241,7 @@ def _process_calendar_response( bounds: SearchBounds, term_groups: list[list[str]], ) -> tuple[list[dict], list[dict], bool]: + """Process one calendar response without letting a bad resource discard its other events.""" resources, failed_resources, resource_truncated = parse_calendar_data(xml_text) failures = [] if failed_resources: @@ -265,6 +271,7 @@ def _process_calendar_response( event["_calendar_href"] = calendar.href events.extend(resource_events) except Exception: + # One malformed or unsupported resource must not make the calendar's successful matches disappear. parse_failures += 1 if parse_failures: failures.append( @@ -287,6 +294,7 @@ async def is_available(nc: AsyncNextcloudApp): async def _list_event_calendars(nc: AsyncNextcloudApp) -> tuple[list[CalendarCollection], int]: + """Follow CalDAV principal discovery to the current user's event calendars.""" principal_response = await nc._session.adapter_dav.request( "PROPFIND", "/", @@ -336,6 +344,7 @@ def _dav_headers(depth: str) -> dict[str, str]: def _same_origin_dav_path(nc: AsyncNextcloudApp, href: str) -> str: + # WebCal subscriptions are read from Nextcloud's cached DAV collection, never from their external URL. target = urlsplit(href) endpoint = urlsplit(nc._session.cfg.endpoint) if target.scheme and (target.scheme, target.netloc) != (endpoint.scheme, endpoint.netloc): diff --git a/ex_app/lib/all_tools/lib/calendar_search.py b/ex_app/lib/all_tools/lib/calendar_search.py index aff6763..98c2de8 100644 --- a/ex_app/lib/all_tools/lib/calendar_search.py +++ b/ex_app/lib/all_tools/lib/calendar_search.py @@ -59,6 +59,7 @@ def validate_search( text_term_groups: list[list[str]] | None, limit: int, ) -> tuple[SearchBounds, list[str] | None, list[list[str]], int]: + """Validate and bound model-supplied arguments before any CalDAV request is made.""" start = _parse_bound(range_start, "range_start") end = _parse_bound(range_end, "range_end") if start >= end: @@ -111,6 +112,7 @@ def _validate_text_term_group(group: list[str]) -> list[str]: def parse_calendar_collections(xml_text: str) -> tuple[list[CalendarCollection], int]: + """Keep event-capable calendar collections, ignoring those explicitly limited to non-VEVENT components.""" _check_xml_size(xml_text) root = _parse_xml(xml_text) calendars = [] @@ -154,6 +156,7 @@ def parse_calendar_home(xml_text: str) -> str: def parse_calendar_data(xml_text: str) -> tuple[list[str], int, bool]: + """Extract bounded iCalendar resources and retain whether the server response was only partly processed.""" _check_xml_size(xml_text) root = _parse_xml(xml_text) resources = [] @@ -190,6 +193,7 @@ def expand_and_filter_events( bounds: SearchBounds, text_term_groups: list[list[str]], ) -> list[dict[str, Any]]: + """Expand one resource's recurrences, then apply local text filtering to its occurrences.""" calendar = Calendar.from_ical(icalendar_text) _validate_expansion_limits(calendar, bounds) recurrence_by_uid = _recurrence_metadata(calendar) @@ -203,6 +207,7 @@ def expand_and_filter_events( def _validate_expansion_limits(calendar: Calendar, bounds: SearchBounds) -> None: + """Limit recurrence work before expansion, rather than only limiting returned search matches.""" estimated_occurrences = 0 for component in calendar.walk("VEVENT"): rrule = component.get("RRULE") From 6a5c3ddbe6a37549a2360e88b0f82fd448c281fb Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Sun, 30 Aug 2026 08:15:06 +0200 Subject: [PATCH 10/12] fix(calendar): address validated review findings Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Dick Tump --- ex_app/lib/all_tools/calendar_advanced_search.py | 1 + ex_app/lib/all_tools/lib/calendar_search.py | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ex_app/lib/all_tools/calendar_advanced_search.py b/ex_app/lib/all_tools/calendar_advanced_search.py index 6556688..5495852 100644 --- a/ex_app/lib/all_tools/calendar_advanced_search.py +++ b/ex_app/lib/all_tools/calendar_advanced_search.py @@ -408,6 +408,7 @@ def _failed_result(bounds, failure: dict) -> dict: def _input_validation_result(exception: ValueError) -> dict: return { + "range": None, "complete": False, "truncated": False, "calendars_searched": [], diff --git a/ex_app/lib/all_tools/lib/calendar_search.py b/ex_app/lib/all_tools/lib/calendar_search.py index 98c2de8..883d494 100644 --- a/ex_app/lib/all_tools/lib/calendar_search.py +++ b/ex_app/lib/all_tools/lib/calendar_search.py @@ -419,7 +419,7 @@ def _event_from_component( def event_sort_key(event: dict[str, Any], floating_timezone: tzinfo = UTC) -> tuple[datetime, str, str]: start = event["start"] if event["all_day"]: - instant = datetime.combine(date.fromisoformat(start), time.min, UTC) + instant = datetime.combine(date.fromisoformat(start), time.min, floating_timezone).astimezone(UTC) else: instant = datetime.fromisoformat(start) if instant.tzinfo is None: @@ -566,8 +566,6 @@ def _event_end(component: Any, start: date | datetime) -> date | datetime: def _format_temporal(value: date | datetime) -> str: - if isinstance(value, datetime): - return value.isoformat() return value.isoformat() From 681ab92c8a84e66a03281686dc5426f3abdb5d95 Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Tue, 1 Sep 2026 09:10:41 +0200 Subject: [PATCH 11/12] fix(calendar): bound aggregate occurrence processing Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Dick Tump --- .../lib/all_tools/calendar_advanced_search.py | 94 ++++++++++++++++--- ex_app/lib/all_tools/lib/calendar_search.py | 34 ++++++- 2 files changed, 110 insertions(+), 18 deletions(-) diff --git a/ex_app/lib/all_tools/calendar_advanced_search.py b/ex_app/lib/all_tools/calendar_advanced_search.py index 5495852..ec5d007 100644 --- a/ex_app/lib/all_tools/calendar_advanced_search.py +++ b/ex_app/lib/all_tools/calendar_advanced_search.py @@ -20,7 +20,7 @@ current_user_principal_propfind_body, event_identity, event_sort_key, - expand_and_filter_events, + expand_and_filter_events_bounded, parse_calendar_collections, parse_calendar_data, parse_calendar_home, @@ -31,6 +31,7 @@ from ex_app.lib.all_tools.lib.decorator import safe_tool MAX_CONCURRENT_CALENDAR_QUERIES = 4 +MAX_PROCESSED_OCCURRENCES_PER_SEARCH = 250_000 # Nextcloud exposes cached WebCal subscriptions as calendars only when this request header is present. WEBCAL_CACHING_HEADERS = {"X-NC-CalDAV-Webcal-Caching": "On"} @@ -60,6 +61,7 @@ async def search_calendar_events( Terms within one group are alternatives (OR), while every group must match (AND). Supply likely synonyms or translations as alternatives when the user's wording and calendar language may differ. An empty complete result proves no matching events. Never infer absence when complete is false. + If a result is incomplete, narrow the date range, calendars or search terms before retrying. :param range_start: Inclusive range start, for example 2026-10-01T00:00:00+02:00. :param range_end: Exclusive range end, no more than 370 days after range_start. :param calendar_names: Optional exact calendar display names. Searches every event calendar when omitted. @@ -126,11 +128,12 @@ async def _search_calendar_events( if calendar_limit_failure: failures.append(calendar_limit_failure) - events, search_failures, resource_truncated = await _search_selected_calendars( + events, matches_found, search_failures, resource_truncated = await _search_selected_calendars( nc, selected_calendars, bounds, term_groups, + result_limit, ) failures.extend(search_failures) resource_truncated = resource_truncated or calendar_limit_failure is not None @@ -143,7 +146,7 @@ async def _search_calendar_events( for event in sorted_events: event.pop("_uid", None) event.pop("_calendar_href", None) - result_truncated = len(sorted_events) > result_limit + result_truncated = matches_found > result_limit # Discovery, selection, query or processing limits make absence unreliable. truncated = resource_truncated or result_truncated complete = not failures and not truncated @@ -156,7 +159,7 @@ async def _search_calendar_events( "complete": complete, "truncated": truncated, "calendars_searched": [calendar.name for calendar in selected_calendars], - "matches_found": len(sorted_events), + "matches_found": matches_found, "returned": min(len(sorted_events), result_limit), "events": sorted_events[:result_limit], "failures": failures, @@ -183,13 +186,16 @@ async def _search_selected_calendars( calendars: list[CalendarCollection], bounds: SearchBounds, term_groups: list[list[str]], -) -> tuple[list[dict], list[dict], bool]: + result_limit: int, +) -> tuple[list[dict], int, list[dict], bool]: events = [] + matches_found = 0 failures = [] resource_truncated = False # Bound the full request and processing lifetime to cap concurrent DAV work and parsed response data. semaphore = asyncio.Semaphore(MAX_CONCURRENT_CALENDAR_QUERIES) query_body = calendar_query_body(bounds) + occurrence_limit = MAX_PROCESSED_OCCURRENCES_PER_SEARCH // max(1, len(calendars)) calendar_results = await asyncio.gather( *( _search_calendar( @@ -199,15 +205,18 @@ async def _search_selected_calendars( term_groups, query_body, semaphore, + result_limit, + occurrence_limit, ) for calendar in calendars ) ) - for calendar_events, calendar_failures, calendar_truncated in calendar_results: + for calendar_events, calendar_matches, calendar_failures, calendar_truncated in calendar_results: events.extend(calendar_events) + matches_found += calendar_matches failures.extend(calendar_failures) resource_truncated = resource_truncated or calendar_truncated - return events, failures, resource_truncated + return events, matches_found, failures, resource_truncated async def _search_calendar( @@ -217,7 +226,9 @@ async def _search_calendar( term_groups: list[list[str]], query_body: str, semaphore: asyncio.Semaphore, -) -> tuple[list[dict], list[dict], bool]: + result_limit: int, + occurrence_limit: int, +) -> tuple[list[dict], int, list[dict], bool]: async with semaphore: try: xml_text = await _calendar_report(nc, calendar, query_body) @@ -228,11 +239,13 @@ async def _search_calendar( calendar, bounds, term_groups, + result_limit, + occurrence_limit, ) except Exception as exception: failure = _failure_entry("calendar_query", exception) failure["calendar"] = calendar.name - return [], [failure], False + return [], 0, [failure], False def _process_calendar_response( @@ -240,7 +253,9 @@ def _process_calendar_response( calendar: CalendarCollection, bounds: SearchBounds, term_groups: list[list[str]], -) -> tuple[list[dict], list[dict], bool]: + result_limit: int, + occurrence_limit: int, +) -> tuple[list[dict], int, list[dict], bool]: """Process one calendar response without letting a bad resource discard its other events.""" resources, failed_resources, resource_truncated = parse_calendar_data(xml_text) failures = [] @@ -262,14 +277,29 @@ def _process_calendar_response( } ) - events = [] + retained_events: dict[tuple, dict] = {} + matched_identities: set[tuple] = set() + remaining_occurrences = occurrence_limit + occurrence_truncated = False parse_failures = 0 for resource in resources: try: - resource_events = expand_and_filter_events(resource, calendar.name, bounds, term_groups) - for event in resource_events: + expansion = expand_and_filter_events_bounded( + resource, + calendar.name, + bounds, + term_groups, + processing_limit=remaining_occurrences, + ) + if expansion.truncated: + occurrence_truncated = True + continue + remaining_occurrences -= expansion.processing_cost + for event in expansion.events: event["_calendar_href"] = calendar.href - events.extend(resource_events) + identity = event_identity(event) + matched_identities.add(identity) + _retain_earliest_event(retained_events, identity, event, result_limit, bounds) except Exception: # One malformed or unsupported resource must not make the calendar's successful matches disappear. parse_failures += 1 @@ -282,7 +312,41 @@ def _process_calendar_response( "count": parse_failures, } ) - return events, failures, resource_truncated + if occurrence_truncated: + failures.append( + { + "calendar": calendar.name, + "stage": "occurrence_limit", + "error": "Calendar occurrence processing limit reached", + "limit": occurrence_limit, + } + ) + events = sorted(retained_events.values(), key=lambda event: event_sort_key(event, bounds.start.tzinfo)) + return events, len(matched_identities), failures, resource_truncated or occurrence_truncated + + +def _retain_earliest_event( + retained_events: dict, + identity: tuple, + event: dict, + result_limit: int, + bounds: SearchBounds, +) -> None: + if identity in retained_events: + retained_events[identity] = event + return + if len(retained_events) < result_limit: + retained_events[identity] = event + return + latest_identity = max( + retained_events, + key=lambda retained_identity: event_sort_key(retained_events[retained_identity], bounds.start.tzinfo), + ) + event_key = event_sort_key(event, bounds.start.tzinfo) + latest_key = event_sort_key(retained_events[latest_identity], bounds.start.tzinfo) + if event_key < latest_key: + retained_events.pop(latest_identity) + retained_events[identity] = event def get_category_name(): diff --git a/ex_app/lib/all_tools/lib/calendar_search.py b/ex_app/lib/all_tools/lib/calendar_search.py index 883d494..6ca1517 100644 --- a/ex_app/lib/all_tools/lib/calendar_search.py +++ b/ex_app/lib/all_tools/lib/calendar_search.py @@ -52,6 +52,13 @@ class SearchBounds: end: datetime +@dataclass(frozen=True) +class EventExpansion: + events: list[dict[str, Any]] + processing_cost: int + truncated: bool + + def validate_search( range_start: str, range_end: str, @@ -194,8 +201,28 @@ def expand_and_filter_events( text_term_groups: list[list[str]], ) -> list[dict[str, Any]]: """Expand one resource's recurrences, then apply local text filtering to its occurrences.""" + return expand_and_filter_events_bounded( + icalendar_text, + calendar_name, + bounds, + text_term_groups, + processing_limit=None, + ).events + + +def expand_and_filter_events_bounded( + icalendar_text: str, + calendar_name: str, + bounds: SearchBounds, + text_term_groups: list[list[str]], + *, + processing_limit: int | None, +) -> EventExpansion: + """Expand one resource only when its estimated work fits the remaining search budget.""" calendar = Calendar.from_ical(icalendar_text) - _validate_expansion_limits(calendar, bounds) + processing_cost = _validate_expansion_limits(calendar, bounds) + if processing_limit is not None and processing_cost > processing_limit: + return EventExpansion(events=[], processing_cost=0, truncated=True) recurrence_by_uid = _recurrence_metadata(calendar) occurrences = recurring_ical_events.of(calendar, components=["VEVENT"]).between(bounds.start, bounds.end) results = [] @@ -203,10 +230,10 @@ def expand_and_filter_events( event = _event_from_component(component, calendar_name, recurrence_by_uid, text_term_groups) if event is not None: results.append(event) - return results + return EventExpansion(events=results, processing_cost=processing_cost, truncated=False) -def _validate_expansion_limits(calendar: Calendar, bounds: SearchBounds) -> None: +def _validate_expansion_limits(calendar: Calendar, bounds: SearchBounds) -> int: """Limit recurrence work before expansion, rather than only limiting returned search matches.""" estimated_occurrences = 0 for component in calendar.walk("VEVENT"): @@ -221,6 +248,7 @@ def _validate_expansion_limits(calendar: Calendar, bounds: SearchBounds) -> None estimated_occurrences += _rdate_count(component.get("RDATE")) if estimated_occurrences > MAX_EXPANDED_OCCURRENCES_PER_RESOURCE: raise ValueError("Calendar resource recurrence expansion exceeded the processing limit") + return estimated_occurrences def _estimate_rrule_occurrences( From 4d6e80827fb4e76a2fced0751ed4efd048695605 Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Tue, 1 Sep 2026 09:10:53 +0200 Subject: [PATCH 12/12] fix(calendar): normalize DAV origin comparison Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Dick Tump --- .../lib/all_tools/calendar_advanced_search.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/ex_app/lib/all_tools/calendar_advanced_search.py b/ex_app/lib/all_tools/calendar_advanced_search.py index ec5d007..0d3f1e7 100644 --- a/ex_app/lib/all_tools/calendar_advanced_search.py +++ b/ex_app/lib/all_tools/calendar_advanced_search.py @@ -6,7 +6,7 @@ from __future__ import annotations import asyncio -from urllib.parse import urlsplit +from urllib.parse import SplitResult, urlsplit from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp @@ -34,6 +34,7 @@ MAX_PROCESSED_OCCURRENCES_PER_SEARCH = 250_000 # Nextcloud exposes cached WebCal subscriptions as calendars only when this request header is present. WEBCAL_CACHING_HEADERS = {"X-NC-CalDAV-Webcal-Caching": "On"} +DEFAULT_ORIGIN_PORTS = {"http": 80, "https": 443} class CalendarRequestError(RuntimeError): @@ -411,8 +412,11 @@ def _same_origin_dav_path(nc: AsyncNextcloudApp, href: str) -> str: # WebCal subscriptions are read from Nextcloud's cached DAV collection, never from their external URL. target = urlsplit(href) endpoint = urlsplit(nc._session.cfg.endpoint) - if target.scheme and (target.scheme, target.netloc) != (endpoint.scheme, endpoint.netloc): - raise ValueError("Calendar collection URL does not belong to this Nextcloud server") + if target.scheme or target.netloc: + target_origin = _normalized_origin(target, fallback_scheme=endpoint.scheme) + endpoint_origin = _normalized_origin(endpoint) + if target_origin is None or target_origin != endpoint_origin: + raise ValueError("Calendar collection URL does not belong to this Nextcloud server") dav_path = urlsplit(nc._session.cfg.dav_endpoint).path.rstrip("/") if target.path == dav_path: relative_path = "/" @@ -423,6 +427,18 @@ def _same_origin_dav_path(nc: AsyncNextcloudApp, href: str) -> str: return relative_path + (f"?{target.query}" if target.query else "") +def _normalized_origin(url: SplitResult, fallback_scheme: str | None = None) -> tuple[str, str, int | None] | None: + scheme = (url.scheme or fallback_scheme or "").casefold() + hostname = url.hostname + if not scheme or hostname is None: + return None + try: + explicit_port = url.port + except ValueError: + return None + return scheme, hostname.casefold(), explicit_port if explicit_port is not None else DEFAULT_ORIGIN_PORTS.get(scheme) + + def _require_success(response, allowed_statuses: set[int], request_stage: str) -> None: if response.status_code not in allowed_statuses: raise CalendarRequestError(response.status_code, request_stage)