Skip to content

Commit cb055ac

Browse files
committed
compatibility fixing
for servers not supporting combined search (plus radicale), we should ask for all tasks and do client-side filtering. Also found a weird bug in the test code
1 parent 24ceab5 commit cb055ac

3 files changed

Lines changed: 82 additions & 35 deletions

File tree

caldav/collection.py

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -887,32 +887,74 @@ def search(
887887

888888
## special compatibility-case when searching for pending todos
889889
if todo and not include_completed:
890-
matches1 = self.search(
891-
todo=True,
892-
ignore_completed1=True,
893-
include_completed=True,
894-
**kwargs,
895-
)
896-
matches2 = self.search(
897-
todo=True,
898-
ignore_completed2=True,
899-
include_completed=True,
900-
**kwargs,
901-
)
902-
matches3 = self.search(
903-
todo=True,
904-
ignore_completed3=True,
905-
include_completed=True,
906-
**kwargs,
907-
)
890+
## There are two ways to get the pending tasks - we can
891+
## ask the server to filter them out, or we can do it
892+
## client side.
893+
894+
## If the server does not support combined searches, then it's
895+
## safest to do it client-side.
896+
897+
## There is a special case (observed with radicale as of
898+
## 2025-11) where future recurrences of a task does not
899+
## match when doing a server-side filtering, so for this
900+
## case we also do client-side filtering (but the
901+
## "feature"
902+
## search.recurrences.includes-implicit.todo.pending will
903+
## not be supported if the feature
904+
## "search.recurrences.includes-implicit.todo" is not
905+
## supported ... hence the weird or below)
906+
907+
## To be completely sure to get all pending tasks, for all
908+
## server implementations and for all valid icalendar
909+
## objects, we send three different searches to the
910+
## server. This is probably bloated, and may in many
911+
## cases be more expensive than to ask for all tasks. At
912+
## the other hand, for a well-used and well-handled old
913+
## todo-list, there may be a small set of pending tasks
914+
## and heaps of done tasks.
915+
916+
## TODO: consider if not ignore_completed3 is sufficient,
917+
## then the recursive part of the query here is moot, and
918+
## we wouldn't waste so much time on repeated queries
919+
920+
if self.client.features.is_supported("search.combined-is-logical-and") and (
921+
not self.client.features.is_supported(
922+
"search.recurrences.includes-implicit.todo"
923+
)
924+
or self.client.features.is_supported(
925+
"search.recurrences.includes-implicit.todo.pending"
926+
)
927+
):
928+
matches = (
929+
self.search(
930+
todo=True,
931+
ignore_completed1=True,
932+
include_completed=True,
933+
**kwargs,
934+
)
935+
+ self.search(
936+
todo=True,
937+
ignore_completed2=True,
938+
include_completed=True,
939+
**kwargs,
940+
)
941+
+ self.search(
942+
todo=True,
943+
ignore_completed3=True,
944+
include_completed=True,
945+
**kwargs,
946+
)
947+
)
948+
else:
949+
matches = self.search(todo=True, include_completed=True, **kwargs)
908950
objects = []
909951
match_set = set()
910-
for item in matches1 + matches2 + matches3:
952+
for item in matches:
911953
if item.url not in match_set:
912954
match_set.add(item.url)
913-
## and still, Zimbra seems to deliver too many TODOs in the
914-
## matches2 ... let's do some post-filtering in case the
915-
## server fails in filtering things the right way
955+
## Client-side filtering is probably cheap, so we'll do it
956+
## even when it shouldn't be needed.
957+
## (can we assert all tasks have a valid STATUS field?)
916958
if any(
917959
x.get("STATUS") not in ("COMPLETED", "CANCELLED")
918960
for x in item.icalendar_instance.subcomponents

caldav/compatibility_hints.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,19 @@ class FeatureSet:
112112
"search.recurrences.includes-implicit.todo": {
113113
"description": "tasks can also be recurring"
114114
},
115+
"search.recurrences.includes-implicit.todo.pending": {
116+
"description": "a future recurrence of a pending task should always be pending and appear in searches for pending tasks"
117+
},
115118
"search.recurrences.includes-implicit.event": {
116119
"description": "support for events"
117120
},
118121
"search.recurrences.includes-implicit.infinite-scope": {
119122
"description": "Needless to say, search on any future date range, no matter how far out in the future, should yield the recurring object"
120123
},
124+
"search.combined-is-logical-and": {
125+
"description": "Multiple search filters should yield only those that passes all filters"
126+
## For "unsupported", we could also add a "behaviour" (returns everything, returns nothing, returns logical OR, etc).
127+
},
121128
"search.recurrences.expanded": {
122129
"description": "According to RFC 4791, the server MUST expand recurrence objects if asked for it - but many server doesn't do that. Some servers don't do expand at all, others deliver broken data, typically missing RECURRENCE-ID. The python caldav client library (from 2.0) does the expand-operation client-side no matter if it's supported or not",
123130
"links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-9.6.5"],
@@ -540,9 +547,6 @@ def dotted_feature_set_list(self, compact=False):
540547
'text_search_is_exact_match_sometimes':
541548
"""Some servers are doing an exact match on summary field but substring match on category or vice versa""",
542549

543-
'combined_search_not_working':
544-
"""When querying for a text match and a date range in the same report, weird things happen""",
545-
546550
'text_search_not_working':
547551
"""Text search is generally broken""",
548552

@@ -626,8 +630,8 @@ def dotted_feature_set_list(self, compact=False):
626630
## so I'm expecting this list to shrink a lot soon.
627631
radicale = {
628632
"search.category.fullstring": {"support": "unsupported"},
633+
"search.recurrences.includes-implicit.todo.pending": {"support": "unsupported"},
629634
"search.recurrences.expanded.todo": {"support": "unsupported"},
630-
"search.recurrences.includes-implicit.todo": {"support": "unsupported"},
631635
"search.recurrences.expanded.exception": {"support": "unsupported"},
632636
'old_flags': [
633637
## calendar listings and calendar creation works a bit
@@ -640,9 +644,9 @@ def dotted_feature_set_list(self, compact=False):
640644
"no-principal-search-self", ## this may be because we haven't set up any users or authentication - so the display name of the current user principal is None
641645

642646
'no_scheduling',
647+
'no_search_openended',
643648

644649
'text_search_is_case_insensitive',
645-
'combined_search_not_working',
646650
#'text_search_is_exact_match_sometimes',
647651

648652
## extra features not specified in RFC5545
@@ -668,14 +672,15 @@ def dotted_feature_set_list(self, compact=False):
668672
'search.comp-type-optional': {
669673
'support': 'ungraceful',
670674
},
675+
"search.combined-is-logical-and": {"support": "unsupported"},
671676
'search.recurrences.includes-implicit.todo': {'support': 'unsupported'},
672677
## TODO: this applies only to test runs, not to ordinary usage
673678
'rate-limit': {
674679
'enable': True,
675680
'interval': 10,
676681
'count': 1,
677682
'description': "It's needed to manually empty trashbin frequently when running tests. Since this oepration takes some time and/or there are some caches, it's needed to run tests slowly, even when hammering the 'empty thrashbin' frequently"},
678-
'old_flags': ['no-principal-search-all', 'no-principal-search-self', 'unique_calendar_ids', 'combined_search_not_working'],
683+
'old_flags': ['no-principal-search-all', 'no-principal-search-self', 'unique_calendar_ids'],
679684
}
680685

681686
## ZIMBRA IS THE MOST SILLY, AND THERE ARE REGRESSIONS FOR EVERY RELEASE!
@@ -741,11 +746,11 @@ def dotted_feature_set_list(self, compact=False):
741746
'search.recurrences.expanded.todo': {'support': 'unsupported'},
742747
'search.recurrences.expanded.exception': {'support': 'unsupported'},
743748
'search.recurrences.includes-implicit.todo': {'support': 'unsupported'},
749+
"search.combined-is-logical-and": {"support": "unsupported"},
744750
'old_flags': [
745751
## date search on todos does not seem to work
746752
## (TODO: do some research on this)
747753
'sync_breaks_on_delete',
748-
'combined_search_not_working',
749754
'text_search_is_exact_match_sometimes',
750755
## extra features not specified in RFC5545
751756
"calendar_order",
@@ -871,12 +876,12 @@ def dotted_feature_set_list(self, compact=False):
871876
'search.recurrences.expanded.todo': {'support': 'unsupported'},
872877
'search.recurrences.expanded.exception': {'support': 'unsupported'},
873878
'search.recurrences.includes-implicit.todo': {'support': 'unsupported'},
879+
"search.combined-is-logical-and": {"support": "unsupported"},
874880
'old_flags': [
875881
'no_scheduling',
876882
'no_journal',
877883
#'no_recurring_todo', ## todo
878884
'no_sync_token',
879-
'combined_search_not_working',
880885
'no_alarmsearch',
881886
"no-principal-search-self"
882887
]

tests/test_caldav.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,8 +1767,8 @@ def testSearchEvent(self):
17671767
start=datetime(2006, 7, 13, 13, 0),
17681768
end=datetime(2006, 7, 15, 13, 0),
17691769
)
1770-
if (self.is_supported("search.category")) and not self.check_compatibility_flag(
1771-
"combined_search_not_working"
1770+
if self.is_supported("search.category") and self.is_supported(
1771+
"search.combined-is-logical-and"
17721772
):
17731773
assert len(no_events) == 0
17741774
some_events = c.search(
@@ -1777,8 +1777,8 @@ def testSearchEvent(self):
17771777
start=datetime(1997, 11, 1, 13, 0),
17781778
end=datetime(1997, 11, 3, 13, 0),
17791779
)
1780-
if self.is_supported("search.category") and not self.check_compatibility_flag(
1781-
"combined_search_not_working"
1780+
if self.is_supported("search.category") and self.is_supported(
1781+
"search.combined-is-logical-and"
17821782
):
17831783
assert len(some_events) == 1
17841784

@@ -2590,7 +2590,7 @@ def testTodoDatesearch(self):
25902590

25912591
assert isinstance(todos1[0], Todo)
25922592
assert isinstance(todos2[0], Todo)
2593-
if not self.check_compatibility_flag("combined_search_not_working"):
2593+
if not self.check_compatibility_flag("no_search_openended"):
25942594
assert isinstance(todos3[0], Todo)
25952595

25962596
## * t6 should be returned, as it's a yearly task spanning over 2025

0 commit comments

Comments
 (0)