From e3510a06e7d1be72fa9cfc412f81b8b8c2670ccb Mon Sep 17 00:00:00 2001 From: Dick Tump Date: Thu, 30 Jul 2026 14:39:43 +0200 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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: