Skip to content

Commit d04f1c4

Browse files
tobixenclaude
andcommitted
Improve search and operations layer; add is-not-defined filter workarounds
search.py: - Add search.is-not-defined.category and search.is-not-defined.dtend sub-features with client-side workarounds for servers that do not support the CALDAV:is-not-defined filter natively - Fix get_object_by_uid() to route through search() so that server-specific delays and retry logic are honoured operations/search_ops.py: - Gracefully handle invalid recurrence data that some servers return operations/calendarset_ops.py: - Minor fixes discovered during multi-server testing Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 71f3066 commit d04f1c4

3 files changed

Lines changed: 136 additions & 10 deletions

File tree

caldav/operations/calendarset_ops.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import logging
1212
from dataclasses import dataclass
1313
from typing import Any
14-
from urllib.parse import quote
14+
from urllib.parse import quote, unquote, urlparse, urlunparse
1515

1616
log = logging.getLogger("caldav")
1717

@@ -183,6 +183,25 @@ def _find_calendar_by_id(
183183
return None
184184

185185

186+
def _quote_url_path(url: str) -> str:
187+
"""
188+
Quote the path component of a URL to handle spaces and special characters.
189+
190+
Some servers (e.g., Zimbra) return URLs with unencoded spaces in the path.
191+
This function ensures the path is properly percent-encoded.
192+
193+
Args:
194+
url: URL string that may contain unencoded characters in path
195+
196+
Returns:
197+
URL with properly encoded path
198+
"""
199+
parsed = urlparse(url)
200+
# quote the path, but unquote first to avoid double-encoding
201+
quoted_path = quote(unquote(parsed.path), safe="/@")
202+
return urlunparse(parsed._replace(path=quoted_path))
203+
204+
186205
def _extract_calendars_from_propfind_results(
187206
results: list[Any] | None,
188207
) -> list[CalendarInfo]:
@@ -206,8 +225,8 @@ def _extract_calendars_from_propfind_results(
206225
if not is_calendar_resource(result.properties):
207226
continue
208227

209-
# Extract calendar info
210-
url = result.href
228+
# Extract calendar info - quote URL path to handle spaces
229+
url = _quote_url_path(result.href)
211230
name = result.properties.get("{DAV:}displayname")
212231
cal_id = _extract_calendar_id_from_url(url)
213232

caldav/operations/search_ops.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,15 @@ def _filter_search_results(
240240
result = []
241241
for o in objects:
242242
if searcher.expand or post_filter:
243-
filtered = searcher.check_component(o, expand_only=not post_filter)
243+
try:
244+
filtered = searcher.check_component(o, expand_only=not post_filter)
245+
except ValueError:
246+
## Server returned data with invalid recurrence structure
247+
## (e.g. after compatibility hacks stripped DURATION).
248+
## Include the object unfiltered rather than crashing.
249+
filtered = [
250+
x for x in o.icalendar_instance.subcomponents if not isinstance(x, Timezone)
251+
]
244252
if not filtered:
245253
continue
246254
else:
@@ -392,9 +400,10 @@ def _build_search_xml_query(
392400
raise error.ConsistencyError(f"unsupported comp class {comp_class} for search")
393401

394402
# Special hack for bedework - no comp_filter, do client-side filtering
403+
# Keep comp_class so the caller knows what type to filter for client-side
404+
# and to prevent _search_with_comptypes from being triggered again
395405
if _hacks == "no_comp_filter":
396406
comp_filter = None
397-
comp_class = None
398407

399408
# Add property filters
400409
for property in searcher._property_operator:

caldav/search.py

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@
2929
_collation_to_caldav = collation_to_caldav
3030

3131

32+
def _is_not_defined_supported(features: Any, prop: str) -> bool:
33+
"""Check if is-not-defined search is supported for a specific property.
34+
35+
Checks the property-specific sub-feature (e.g. search.is-not-defined.category
36+
or search.is-not-defined.dtend) if one is defined, otherwise falls back to
37+
the parent search.is-not-defined feature.
38+
39+
The ``categories`` → ``category`` mapping exists because the feature is named
40+
after the singular form while the iCalendar property is plural.
41+
"""
42+
from .compatibility_hints import FeatureSet
43+
44+
feature_prop = "category" if prop == "categories" else prop
45+
sub_feature = f"search.is-not-defined.{feature_prop}"
46+
if sub_feature in FeatureSet.FEATURES:
47+
return features.is_supported(sub_feature)
48+
return features.is_supported("search.is-not-defined")
49+
50+
3251
# Property filter attribute names used for cloning searchers with modified filters
3352
_PROPERTY_FILTER_ATTRS = (
3453
"_property_filters",
@@ -229,13 +248,14 @@ def _search_impl(
229248

230249
## Handle servers with broken component-type filtering (e.g., Bedework)
231250
comp_type_support = calendar.client.features.is_supported("search.comp-type", str)
232-
if (
251+
no_comp_filter = (
233252
(self.comp_class or self.todo or self.event or self.journal)
234253
and comp_type_support == "broken"
235-
and not _hacks
236254
and post_filter is not False
237-
):
238-
_hacks = "no_comp_filter"
255+
)
256+
if no_comp_filter:
257+
if not _hacks:
258+
_hacks = "no_comp_filter"
239259
post_filter = True
240260

241261
## Setting default value for post_filter
@@ -257,6 +277,29 @@ def _search_impl(
257277
if not self.start or not self.end:
258278
raise error.ReportError("can't expand without a date range")
259279

280+
## special compatibility-case for servers that do not support text search at all
281+
## (e.g. purelymail where both i;octet and i;ascii-casemap collations are unsupported).
282+
## Remove all text-value filters and rely on client-side post_filter instead.
283+
if (
284+
not calendar.client.features.is_supported("search.text")
285+
and self._property_filters
286+
and post_filter is not False
287+
):
288+
text_filter_props = [
289+
prop for prop, op in self._property_operator.items() if op != "undef"
290+
]
291+
if text_filter_props:
292+
clone = self._clone_without_filters(text_filter_props)
293+
objects = yield (
294+
SearchAction.RECURSIVE_SEARCH,
295+
(clone, calendar, server_expand, split_expanded, props, xml, None, None),
296+
)
297+
yield (
298+
SearchAction.RETURN,
299+
self.filter(objects, post_filter, split_expanded, server_expand),
300+
)
301+
return
302+
260303
## special compatbility-case for servers that does not
261304
## support category search properly
262305
if (
@@ -275,6 +318,31 @@ def _search_impl(
275318
)
276319
return
277320

321+
## special compatibility-case for servers that do not support is-not-defined
322+
## for specific properties (e.g. search.is-not-defined.category or .dtend)
323+
if post_filter is not False:
324+
undef_props_without_support = [
325+
prop
326+
for prop, op in self._property_operator.items()
327+
if op == "undef" and not _is_not_defined_supported(calendar.client.features, prop)
328+
]
329+
if undef_props_without_support:
330+
clone = self._clone_without_filters(undef_props_without_support)
331+
objects = yield (
332+
SearchAction.RECURSIVE_SEARCH,
333+
(clone, calendar, server_expand, split_expanded, props, xml, None, None),
334+
)
335+
yield (
336+
SearchAction.RETURN,
337+
self.filter(
338+
objects,
339+
post_filter=True,
340+
split_expanded=split_expanded,
341+
server_expand=server_expand,
342+
),
343+
)
344+
return
345+
278346
## special compatibility-case for servers that do not support substring search
279347
if (
280348
not calendar.client.features.is_supported("search.text.substring")
@@ -353,7 +421,8 @@ def _search_impl(
353421
clone.expand = False
354422

355423
if (
356-
calendar.client.features.is_supported("search.text")
424+
not no_comp_filter
425+
and calendar.client.features.is_supported("search.text")
357426
and calendar.client.features.is_supported("search.combined-is-logical-and")
358427
and (
359428
not calendar.client.features.is_supported(
@@ -440,6 +509,35 @@ def _search_impl(
440509
yield (SearchAction.RETURN, result)
441510
return
442511

512+
## If _hacks=="insist" and still no results despite having text property
513+
## filters, the server may not support text search (e.g. purelymail,
514+
## CCS with i;octet collation). Retry without the text filters and rely
515+
## on client-side post_filter (which is guaranteed True in get_object_by_uid).
516+
if not objects and _hacks == "insist" and self._property_filters:
517+
non_undef_filters = [
518+
prop for prop, op in self._property_operator.items() if op != "undef"
519+
]
520+
if non_undef_filters:
521+
clone = self._clone_without_filters(non_undef_filters)
522+
result = yield (
523+
SearchAction.RECURSIVE_SEARCH,
524+
(
525+
clone,
526+
calendar,
527+
server_expand,
528+
split_expanded,
529+
props,
530+
orig_xml,
531+
None,
532+
None,
533+
),
534+
)
535+
yield (
536+
SearchAction.RETURN,
537+
self.filter(result, post_filter, split_expanded, server_expand),
538+
)
539+
return
540+
443541
# Post-process: load objects
444542
obj2 = []
445543
for o in objects:

0 commit comments

Comments
 (0)