Skip to content

Commit e3510a0

Browse files
committed
feat: add advanced calendar event search
Signed-off-by: Dick Tump <dick@tump.me> Assisted-by: Codex:gpt-5.6-sol
1 parent d3d58b2 commit e3510a0

2 files changed

Lines changed: 922 additions & 0 deletions

File tree

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
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

Comments
 (0)