|
| 1 | +# SPDX-FileCopyrightText: 2026 Dick Tump |
| 2 | +# SPDX-License-Identifier: AGPL-3.0-or-later |
| 3 | + |
| 4 | +"""Read-only, bounded calendar event search tools.""" |
| 5 | + |
| 6 | +from __future__ import annotations |
| 7 | + |
| 8 | +from urllib.parse import urlsplit |
| 9 | + |
| 10 | +from langchain_core.tools import tool |
| 11 | +from nc_py_api import AsyncNextcloudApp |
| 12 | + |
| 13 | +from ex_app.lib.all_tools.lib.calendar_search import ( |
| 14 | + MAX_CALENDARS, |
| 15 | + CalendarCollection, |
| 16 | + SearchBounds, |
| 17 | + calendar_home_propfind_body, |
| 18 | + calendar_query_body, |
| 19 | + current_user_principal_propfind_body, |
| 20 | + event_identity, |
| 21 | + event_sort_key, |
| 22 | + expand_and_filter_events, |
| 23 | + parse_calendar_collections, |
| 24 | + parse_calendar_data, |
| 25 | + parse_calendar_home, |
| 26 | + parse_current_user_principal, |
| 27 | + principal_calendar_home_propfind_body, |
| 28 | + validate_search, |
| 29 | +) |
| 30 | +from ex_app.lib.all_tools.lib.decorator import safe_tool |
| 31 | + |
| 32 | + |
| 33 | +class CalendarRequestError(RuntimeError): |
| 34 | + def __init__(self, status_code: int, request_stage: str): |
| 35 | + super().__init__(f"Unexpected HTTP status {status_code}") |
| 36 | + self.status_code = status_code |
| 37 | + self.request_stage = request_stage |
| 38 | + |
| 39 | + |
| 40 | +async def get_tools(nc: AsyncNextcloudApp): |
| 41 | + @tool |
| 42 | + @safe_tool |
| 43 | + async def search_calendar_events( |
| 44 | + range_start: str, |
| 45 | + range_end: str, |
| 46 | + calendar_names: list[str] | None = None, |
| 47 | + text_term_groups: list[list[str]] | None = None, |
| 48 | + limit: int = 50, |
| 49 | + ): |
| 50 | + """Search the current user's calendar events in a required, bounded time range. |
| 51 | +
|
| 52 | + Use ISO 8601 date-times with a UTC offset or Z. range_end is exclusive. |
| 53 | + Recurrences are expanded, moved exceptions replace their original occurrence, and cancellations are omitted. |
| 54 | + Use text_term_groups to search summary, description, location and categories before events are returned. |
| 55 | + Terms within one group are alternatives (OR), while every group must match (AND). |
| 56 | + Supply likely synonyms or translations as alternatives when the user's wording and calendar language may differ. |
| 57 | + An empty complete result proves no matching events. Never infer absence when complete is false. |
| 58 | + :param range_start: Inclusive range start, for example 2026-10-01T00:00:00+02:00. |
| 59 | + :param range_end: Exclusive range end, no more than 370 days after range_start. |
| 60 | + :param calendar_names: Optional exact calendar display names. Searches every event calendar when omitted. |
| 61 | + :param text_term_groups: Optional groups of case-insensitive substring alternatives. |
| 62 | + :param limit: Maximum events returned, from 1 to 100. |
| 63 | + :return: Matching event fields plus explicit completeness, truncation and failure metadata. |
| 64 | + """ |
| 65 | + return await _search_calendar_events( |
| 66 | + nc, |
| 67 | + range_start=range_start, |
| 68 | + range_end=range_end, |
| 69 | + calendar_names=calendar_names, |
| 70 | + text_term_groups=text_term_groups, |
| 71 | + limit=limit, |
| 72 | + ) |
| 73 | + |
| 74 | + return [search_calendar_events] |
| 75 | + |
| 76 | + |
| 77 | +async def _search_calendar_events( |
| 78 | + nc: AsyncNextcloudApp, |
| 79 | + *, |
| 80 | + range_start: str, |
| 81 | + range_end: str, |
| 82 | + calendar_names: list[str] | None, |
| 83 | + text_term_groups: list[list[str]] | None, |
| 84 | + limit: int, |
| 85 | +) -> dict: |
| 86 | + bounds, requested_names, term_groups, result_limit = validate_search( |
| 87 | + range_start, |
| 88 | + range_end, |
| 89 | + calendar_names, |
| 90 | + text_term_groups, |
| 91 | + limit, |
| 92 | + ) |
| 93 | + failures = [] |
| 94 | + try: |
| 95 | + calendars, failed_discovery_responses = await _list_event_calendars(nc) |
| 96 | + except Exception as exception: |
| 97 | + return _failed_result(bounds, _failure_entry("calendar_discovery", exception)) |
| 98 | + if failed_discovery_responses: |
| 99 | + failures.append( |
| 100 | + { |
| 101 | + "stage": "calendar_discovery", |
| 102 | + "error": "Some calendar collections could not be inspected", |
| 103 | + "count": failed_discovery_responses, |
| 104 | + } |
| 105 | + ) |
| 106 | + |
| 107 | + selected_calendars, missing_names = _select_calendars(calendars, requested_names) |
| 108 | + if missing_names: |
| 109 | + failures.append( |
| 110 | + { |
| 111 | + "stage": "calendar_selection", |
| 112 | + "error": "Requested calendars were not found", |
| 113 | + "calendars": missing_names, |
| 114 | + } |
| 115 | + ) |
| 116 | + |
| 117 | + selected_calendars, calendar_limit_failure = _apply_calendar_limit(selected_calendars) |
| 118 | + if calendar_limit_failure: |
| 119 | + failures.append(calendar_limit_failure) |
| 120 | + |
| 121 | + events, search_failures, resource_truncated = await _search_selected_calendars( |
| 122 | + nc, |
| 123 | + selected_calendars, |
| 124 | + bounds, |
| 125 | + term_groups, |
| 126 | + ) |
| 127 | + failures.extend(search_failures) |
| 128 | + resource_truncated = resource_truncated or calendar_limit_failure is not None |
| 129 | + |
| 130 | + unique_events = {event_identity(event): event for event in events} |
| 131 | + sorted_events = sorted( |
| 132 | + unique_events.values(), |
| 133 | + key=lambda event: event_sort_key(event, bounds.start.tzinfo), |
| 134 | + ) |
| 135 | + for event in sorted_events: |
| 136 | + event.pop("_uid", None) |
| 137 | + event.pop("_calendar_href", None) |
| 138 | + result_truncated = len(sorted_events) > result_limit |
| 139 | + truncated = resource_truncated or result_truncated |
| 140 | + complete = not failures and not truncated |
| 141 | + result = { |
| 142 | + "range": { |
| 143 | + "start": bounds.start.isoformat(), |
| 144 | + "end": bounds.end.isoformat(), |
| 145 | + "end_exclusive": True, |
| 146 | + }, |
| 147 | + "complete": complete, |
| 148 | + "truncated": truncated, |
| 149 | + "calendars_searched": [calendar.name for calendar in selected_calendars], |
| 150 | + "matches_found": len(sorted_events), |
| 151 | + "returned": min(len(sorted_events), result_limit), |
| 152 | + "events": sorted_events[:result_limit], |
| 153 | + "failures": failures, |
| 154 | + } |
| 155 | + if not complete: |
| 156 | + result["completeness_warning"] = "The search was incomplete. Do not infer that an event is absent." |
| 157 | + return result |
| 158 | + |
| 159 | + |
| 160 | +def _apply_calendar_limit( |
| 161 | + calendars: list[CalendarCollection], |
| 162 | +) -> tuple[list[CalendarCollection], dict | None]: |
| 163 | + if len(calendars) <= MAX_CALENDARS: |
| 164 | + return calendars, None |
| 165 | + return calendars[:MAX_CALENDARS], { |
| 166 | + "stage": "calendar_limit", |
| 167 | + "error": "Calendar processing limit reached", |
| 168 | + "limit": MAX_CALENDARS, |
| 169 | + } |
| 170 | + |
| 171 | + |
| 172 | +async def _search_selected_calendars( |
| 173 | + nc: AsyncNextcloudApp, |
| 174 | + calendars: list[CalendarCollection], |
| 175 | + bounds: SearchBounds, |
| 176 | + term_groups: list[list[str]], |
| 177 | +) -> tuple[list[dict], list[dict], bool]: |
| 178 | + events = [] |
| 179 | + failures = [] |
| 180 | + resource_truncated = False |
| 181 | + for calendar in calendars: |
| 182 | + try: |
| 183 | + xml_text = await _calendar_report(nc, calendar, calendar_query_body(bounds)) |
| 184 | + resources, failed_resources, calendar_truncated = parse_calendar_data(xml_text) |
| 185 | + except Exception as exception: |
| 186 | + failure = _failure_entry("calendar_query", exception) |
| 187 | + failure["calendar"] = calendar.name |
| 188 | + failures.append(failure) |
| 189 | + continue |
| 190 | + if failed_resources: |
| 191 | + failures.append( |
| 192 | + { |
| 193 | + "calendar": calendar.name, |
| 194 | + "stage": "resource_read", |
| 195 | + "error": "Some calendar resources could not be read", |
| 196 | + "count": failed_resources, |
| 197 | + } |
| 198 | + ) |
| 199 | + if calendar_truncated: |
| 200 | + resource_truncated = True |
| 201 | + failures.append( |
| 202 | + { |
| 203 | + "calendar": calendar.name, |
| 204 | + "stage": "resource_limit", |
| 205 | + "error": "Calendar resource processing limit reached", |
| 206 | + } |
| 207 | + ) |
| 208 | + events.extend(_parse_calendar_resources(resources, calendar, bounds, term_groups, failures)) |
| 209 | + return events, failures, resource_truncated |
| 210 | + |
| 211 | + |
| 212 | +def _parse_calendar_resources( |
| 213 | + resources: list[str], |
| 214 | + calendar: CalendarCollection, |
| 215 | + bounds: SearchBounds, |
| 216 | + term_groups: list[list[str]], |
| 217 | + failures: list[dict], |
| 218 | +) -> list[dict]: |
| 219 | + events = [] |
| 220 | + parse_failures = 0 |
| 221 | + for resource in resources: |
| 222 | + try: |
| 223 | + resource_events = expand_and_filter_events(resource, calendar.name, bounds, term_groups) |
| 224 | + for event in resource_events: |
| 225 | + event["_calendar_href"] = calendar.href |
| 226 | + events.extend(resource_events) |
| 227 | + except Exception: |
| 228 | + parse_failures += 1 |
| 229 | + if parse_failures: |
| 230 | + failures.append( |
| 231 | + { |
| 232 | + "calendar": calendar.name, |
| 233 | + "stage": "event_parsing", |
| 234 | + "error": "Some calendar resources contained invalid or unsupported event data", |
| 235 | + "count": parse_failures, |
| 236 | + } |
| 237 | + ) |
| 238 | + return events |
| 239 | + |
| 240 | + |
| 241 | +def get_category_name(): |
| 242 | + return "Calendar: Advanced Search" |
| 243 | + |
| 244 | + |
| 245 | +async def is_available(nc: AsyncNextcloudApp): |
| 246 | + return True |
| 247 | + |
| 248 | + |
| 249 | +async def _list_event_calendars(nc: AsyncNextcloudApp) -> tuple[list[CalendarCollection], int]: |
| 250 | + principal_response = await nc._session.adapter_dav.request( |
| 251 | + "PROPFIND", |
| 252 | + "/", |
| 253 | + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"}, |
| 254 | + data=current_user_principal_propfind_body(), |
| 255 | + ) |
| 256 | + _require_success(principal_response, {207}, "current_user_principal") |
| 257 | + principal_path = _same_origin_dav_path(nc, parse_current_user_principal(principal_response.text)) |
| 258 | + |
| 259 | + home_response = await nc._session.adapter_dav.request( |
| 260 | + "PROPFIND", |
| 261 | + principal_path, |
| 262 | + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"}, |
| 263 | + data=principal_calendar_home_propfind_body(), |
| 264 | + ) |
| 265 | + _require_success(home_response, {207}, "calendar_home") |
| 266 | + home_path = _same_origin_dav_path(nc, parse_calendar_home(home_response.text)) |
| 267 | + |
| 268 | + calendars_response = await nc._session.adapter_dav.request( |
| 269 | + "PROPFIND", |
| 270 | + home_path, |
| 271 | + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, |
| 272 | + data=calendar_home_propfind_body(), |
| 273 | + ) |
| 274 | + _require_success(calendars_response, {207}, "calendar_collections") |
| 275 | + return parse_calendar_collections(calendars_response.text) |
| 276 | + |
| 277 | + |
| 278 | +async def _calendar_report(nc: AsyncNextcloudApp, calendar: CalendarCollection, body: str) -> str: |
| 279 | + request_path = _same_origin_dav_path(nc, calendar.href) |
| 280 | + response = await nc._session.adapter_dav.request( |
| 281 | + "REPORT", |
| 282 | + request_path, |
| 283 | + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, |
| 284 | + data=body, |
| 285 | + ) |
| 286 | + _require_success(response, {207}, "calendar_query") |
| 287 | + return response.text |
| 288 | + |
| 289 | + |
| 290 | +def _same_origin_dav_path(nc: AsyncNextcloudApp, href: str) -> str: |
| 291 | + target = urlsplit(href) |
| 292 | + endpoint = urlsplit(nc._session.cfg.endpoint) |
| 293 | + if target.scheme and (target.scheme, target.netloc) != (endpoint.scheme, endpoint.netloc): |
| 294 | + raise ValueError("Calendar collection URL does not belong to this Nextcloud server") |
| 295 | + dav_path = urlsplit(nc._session.cfg.dav_endpoint).path.rstrip("/") |
| 296 | + if target.path == dav_path: |
| 297 | + relative_path = "/" |
| 298 | + elif target.path.startswith(f"{dav_path}/"): |
| 299 | + relative_path = target.path[len(dav_path) :] |
| 300 | + else: |
| 301 | + raise ValueError("Calendar collection URL is outside the Nextcloud DAV endpoint") |
| 302 | + return relative_path + (f"?{target.query}" if target.query else "") |
| 303 | + |
| 304 | + |
| 305 | +def _require_success(response, allowed_statuses: set[int], request_stage: str) -> None: |
| 306 | + if response.status_code not in allowed_statuses: |
| 307 | + raise CalendarRequestError(response.status_code, request_stage) |
| 308 | + |
| 309 | + |
| 310 | +def _select_calendars( |
| 311 | + calendars: list[CalendarCollection], |
| 312 | + requested_names: list[str] | None, |
| 313 | +) -> tuple[list[CalendarCollection], list[str]]: |
| 314 | + if requested_names is None: |
| 315 | + return calendars, [] |
| 316 | + requested = {name.casefold(): name for name in requested_names} |
| 317 | + selected = [calendar for calendar in calendars if calendar.name.casefold() in requested] |
| 318 | + found = {calendar.name.casefold() for calendar in selected} |
| 319 | + missing = [name for name in requested_names if name.casefold() not in found] |
| 320 | + return selected, missing |
| 321 | + |
| 322 | + |
| 323 | +def _failure_entry(stage: str, exception: Exception) -> dict: |
| 324 | + failure = { |
| 325 | + "stage": stage, |
| 326 | + "error": f"{stage.replace('_', ' ').capitalize()} failed ({type(exception).__name__})", |
| 327 | + } |
| 328 | + if isinstance(exception, CalendarRequestError): |
| 329 | + failure["http_status"] = exception.status_code |
| 330 | + failure["request_stage"] = exception.request_stage |
| 331 | + return failure |
| 332 | + |
| 333 | + |
| 334 | +def _failed_result(bounds, failure: dict) -> dict: |
| 335 | + return { |
| 336 | + "range": { |
| 337 | + "start": bounds.start.isoformat(), |
| 338 | + "end": bounds.end.isoformat(), |
| 339 | + "end_exclusive": True, |
| 340 | + }, |
| 341 | + "complete": False, |
| 342 | + "truncated": False, |
| 343 | + "calendars_searched": [], |
| 344 | + "matches_found": 0, |
| 345 | + "returned": 0, |
| 346 | + "events": [], |
| 347 | + "failures": [failure], |
| 348 | + "completeness_warning": "The search was incomplete. Do not infer that an event is absent.", |
| 349 | + } |
0 commit comments