Files
syn-chat-bot/.venv/lib/python3.9/site-packages/caldav/search.py
Hyungi Ahn c2257d3a86 fix: 포트 충돌 회피 — note_bridge 8098, intent_service 8099
Jellyfin(8096), OrbStack(8097) 포트 충돌으로 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:53:55 +09:00

784 lines
33 KiB
Python

import logging
from copy import deepcopy
from dataclasses import dataclass
from dataclasses import field
from dataclasses import replace
from datetime import datetime
from typing import Any
from typing import List
from typing import Optional
from icalendar import Timezone
from icalendar.prop import TypesFactory
from icalendar_searcher import Searcher
from icalendar_searcher.collation import Collation
from lxml import etree
from .calendarobjectresource import CalendarObjectResource
from .calendarobjectresource import Event
from .calendarobjectresource import Journal
from .calendarobjectresource import Todo
from .collection import Calendar
from .elements import cdav
from .elements import dav
from .elements.base import BaseElement
from .lib import error
TypesFactory = TypesFactory()
def _collation_to_caldav(collation: Collation, case_sensitive: bool = True) -> str:
"""Map icalendar-searcher Collation enum to CalDAV collation identifier.
CalDAV supports collation identifiers from RFC 4790. The default is "i;ascii-casemap"
and servers must support at least "i;ascii-casemap" and "i;octet".
:param collation: icalendar-searcher Collation enum value
:param case_sensitive: Whether the collation should be case-sensitive
:return: CalDAV collation identifier string
"""
if collation == Collation.SIMPLE:
# SIMPLE collation maps to CalDAV's basic collations
if case_sensitive:
return "i;octet"
else:
return "i;ascii-casemap"
elif collation == Collation.UNICODE:
# Unicode Collation Algorithm - not all servers support this
# Note: "i;unicode-casemap" is case-insensitive by definition
# For case-sensitive Unicode, we fall back to i;octet (binary)
if case_sensitive:
return "i;octet"
else:
return "i;unicode-casemap"
elif collation == Collation.LOCALE:
# Locale-specific collation - not widely supported in CalDAV
# Fallback to i;ascii-casemap as most servers don't support locale-specific collations
return "i;ascii-casemap"
else:
# Default to binary/octet for unknown collations
return "i;octet"
@dataclass
class CalDAVSearcher(Searcher):
"""The baseclass (which is generic, and not CalDAV-specific)
allows building up a search query search logic.
The base class also allows for simple client-side filtering (and
at some point in the future, more complex client-side filtering).
The CalDAV protocol is difficult, ambigiuous and does not offer
all kind of searches. Client-side filtering may be needed to
smoothen over differences in how the different servers handle
search queries, as well as allowing for more complex searches.
A search may be performed by first setting up a CalDAVSearcher,
populate it with filter options, and then initiate the search from
he CalDAVSearcher. Something like this (see the doc in the base
class):
``ComponentSearchFilter(from=..., to=...).search(calendar)``
However, for simple searches, the old way to
do it will always work:
``calendar.search(from=..., to=..., ...)``
The ``todo``, ``event`` and ``journal`` parameters are booleans
for filtering the component type. It's currently recommended to
set one and only one of them to True, as of 2025-11 there is no
guarantees for correct behaviour if setting two of them. Also, if
none is given (the default), all objects should be returned -
however, all examples in the CalDAV RFC filters things by
component, and the different servers do different things when
confronted with a search missing component type. With the correct
``compatibility_hints`` (``davclient.features``) configured for
the caldav server, the algorithms will ensure correct behaviour.
Both the iCalendar standard and the (Cal)DAV standard defines
"properties". Make sure not to confuse those. iCalendar
properties used for filtering can be passed using
``searcher.add_property_filter``.
"""
comp_class: Optional["CalendarObjectResource"] = None
_explicit_operators: set = field(default_factory=set)
def add_property_filter(
self,
key: str,
value: Any,
operator: str = None,
case_sensitive: bool = True,
collation: Optional[Collation] = None,
locale: Optional[str] = None,
) -> None:
"""Adds a filter for some specific iCalendar property.
Examples of valid iCalendar properties: SUMMARY,
LOCATION, DESCRIPTION, DTSTART, STATUS, CLASS, etc
:param key: iCalendar property name (e.g., SUMMARY).
Special virtual property "category" (singular) is also supported
for substring matching within category names
:param value: Filter value, should adhere to the type defined in the RFC
:param operator: Comparison operator ("contains", "==", "undef"). If not
specified, the server decides the matching behavior (usually
substring search per RFC). If explicitly set to "contains"
and the server doesn't support substring search, client-side
filtering is used (may transfer more data from server).
:param case_sensitive: If False, text comparisons are case-insensitive.
Note: CalDAV standard case-insensitivity only applies
to ASCII characters.
:param collation: Advanced collation strategy for text comparison.
May not work on all servers.
:param locale: Locale string (e.g., "de_DE") for locale-aware collation.
Only used with collation=Collation.LOCALE. May not work on
all servers.
**Supported operators:**
* **contains** - substring match (e.g., "rain" matches "Training session"
and "Singing in the rain")
* **==** - exact match required, enforced client-side
* **undef** - matches if property is not defined (value parameter ignored)
**Special handling for categories:**
- **"categories"** (plural): Exact category name matching
- "contains": subset check (all filter categories must be in component)
- "==": exact set equality (same categories, order doesn't matter)
- Commas in filter values split into multiple categories
- **"category"** (singular): Substring matching within category names
- "contains": substring match (e.g., "out" matches "outdoor")
- "==": exact match to at least one category name
- Commas in filter values treated as literal characters
Examples:
# Case-insensitive search
searcher.add_property_filter("SUMMARY", "meeting", case_sensitive=False)
# Explicit substring search (guaranteed via client-side if needed)
searcher.add_property_filter("LOCATION", "room", operator="contains")
# Exact match
searcher.add_property_filter("STATUS", "CONFIRMED", operator="==")
"""
if operator is not None:
# Base class lowercases the key, so we need to as well
self._explicit_operators.add(key.lower())
super().add_property_filter(
key, value, operator, case_sensitive, collation, locale
)
else:
# operator not specified - don't pass it, let base class use default
# Don't track as explicit
super().add_property_filter(
key,
value,
case_sensitive=case_sensitive,
collation=collation,
locale=locale,
)
def _search_with_comptypes(
self,
calendar: Calendar,
server_expand: bool = False,
split_expanded: bool = True,
props: Optional[List[cdav.CalendarData]] = None,
xml: str = None,
_hacks: str = None,
post_filter: bool = None,
) -> List[CalendarObjectResource]:
"""
Internal method - does three searches, one for each comp class (event, journal, todo).
"""
if xml and (isinstance(xml, str) or "calendar-query" in xml.tag):
raise NotImplementedError(
"full xml given, and it has to be patched to include comp_type"
)
objects = []
assert self.event is None and self.todo is None and self.journal is None
for comp_class in (Event, Todo, Journal):
clone = replace(self)
clone.comp_class = comp_class
objects += clone.search(
calendar, server_expand, split_expanded, props, xml, post_filter, _hacks
)
return self.sort(objects)
## TODO: refactor, split more logic out in smaller methods
def search(
self,
calendar: Calendar,
server_expand: bool = False,
split_expanded: bool = True,
props: Optional[List[cdav.CalendarData]] = None,
xml: str = None,
post_filter=None,
_hacks: str = None,
) -> List[CalendarObjectResource]:
"""Do the search on a CalDAV calendar.
Only CalDAV-specific parameters goes to this method. Those
parameters are pretty obscure - mostly for power users and
internal usage. Unless you have some very special needs, the
recommendation is to not pass anything but the calendar.
:param calendar: Calendar to be searched
:param server_expand: Ask the CalDAV server to expand recurrences
:param split_expanded: Don't collect a recurrence set in one ical calendar
:param props: CalDAV properties to send in the query
:param xml: XML query to be sent to the server (string or elements)
:param post_filter: Do client-side filtering after querying the server
:param _hacks: Please don't ask!
Make sure not to confuse he CalDAV properties with iCalendar properties.
If ``xml`` is given, any other filtering will not be sent to the server.
They may still be applied through client-side filtering. (TODO: work in progress)
``post_filter`` takes three values, ``True`` will always
filter the results, ``False`` will never filter the results,
and the default ``None`` will cause automagics to happen (not
implemented yet). Or perhaps I'll just set it to True as
default. TODO - make a decision here
In the CalDAV protocol, a VCALENDAR object returned from the
server may contain only one event/task/journal - but if the
object is recurrent, it may contain several recurrences.
``split_expanded`` will split the recurrences into several
objects. If you don't know what you're doing, then leave this
flag on.
Use ``searcher.search(calendar)`` to apply the search on a caldav server.
"""
## Handle servers with broken component-type filtering (e.g., Bedework)
## Such servers may misclassify component types in responses
comp_type_support = calendar.client.features.is_supported(
"search.comp-type", str
)
if (
(self.comp_class or self.todo or self.event or self.journal)
and comp_type_support == "broken"
and not _hacks
and post_filter is not False
):
_hacks = "no_comp_filter"
post_filter = True
## Setting default value for post_filter
if post_filter is None and (
(self.todo and not self.include_completed)
or self.expand
or "categories" in self._property_filters
or "category" in self._property_filters
or not calendar.client.features.is_supported("search.text.case-sensitive")
or not calendar.client.features.is_supported("search.time-range.accurate")
):
post_filter = True
## split_expanded should only take effect on expanded data
if not self.expand and not server_expand:
split_expanded = False
if self.expand or server_expand:
if not self.start or not self.end:
raise error.ReportError("can't expand without a date range")
## special compatbility-case for servers that does not
## support category search properly
things = ("filters", "operator", "locale", "collation")
things = [f"_property_{thing}" for thing in things]
if (
not calendar.client.features.is_supported("search.text.category")
and (
"categories" in self._property_filters
or "category" in self._property_filters
)
and post_filter is not False
):
replacements = {}
for thing in things:
replacements[thing] = getattr(self, thing).copy()
replacements[thing].pop("categories", None)
replacements[thing].pop("category", None)
clone = replace(self, **replacements)
objects = clone.search(calendar, server_expand, split_expanded, props, xml)
return self.filter(objects, post_filter, split_expanded, server_expand)
## special compatibility-case for servers that do not support substring search
## Only applies when user explicitly requested substring search with operator="contains"
if (
not calendar.client.features.is_supported("search.text.substring")
and post_filter is not False
):
# Check if any property has explicitly specified operator="contains"
explicit_contains = [
prop
for prop in self._property_operator
if prop in self._explicit_operators
and self._property_operator[prop] == "contains"
]
if explicit_contains:
# Remove explicit substring filters from server query,
# will be applied client-side instead
replacements = {}
for thing in things:
replacements[thing] = getattr(self, thing).copy()
for prop in explicit_contains:
replacements[thing].pop(prop, None)
# Also need to preserve the _explicit_operators set but remove these properties
clone = replace(self, **replacements)
clone._explicit_operators = self._explicit_operators - set(
explicit_contains
)
objects = clone.search(
calendar, server_expand, split_expanded, props, xml
)
return self.filter(
objects,
post_filter=True,
split_expanded=split_expanded,
server_expand=server_expand,
)
## special compatibility-case for servers that does not
## support combined searches very well
if not calendar.client.features.is_supported("search.combined-is-logical-and"):
if self.start or self.end:
if self._property_filters:
replacements = {}
for thing in things:
replacements[thing] = {}
clone = replace(self, **replacements)
objects = clone.search(
calendar, server_expand, split_expanded, props, xml
)
return self.filter(
objects, post_filter, split_expanded, server_expand
)
## special compatibility-case when searching for pending todos
if self.todo and not self.include_completed:
## There are two ways to get the pending tasks - we can
## ask the server to filter them out, or we can do it
## client side.
## If the server does not support combined searches, then it's
## safest to do it client-side.
## There is a special case (observed with radicale as of
## 2025-11) where future recurrences of a task does not
## match when doing a server-side filtering, so for this
## case we also do client-side filtering (but the
## "feature"
## search.recurrences.includes-implicit.todo.pending will
## not be supported if the feature
## "search.recurrences.includes-implicit.todo" is not
## supported ... hence the weird or below)
## To be completely sure to get all pending tasks, for all
## server implementations and for all valid icalendar
## objects, we send three different searches to the
## server. This is probably bloated, and may in many
## cases be more expensive than to ask for all tasks. At
## the other hand, for a well-used and well-handled old
## todo-list, there may be a small set of pending tasks
## and heaps of done tasks.
## TODO: consider if not ignore_completed3 is sufficient,
## then the recursive part of the query here is moot, and
## we wouldn't waste so much time on repeated queries
clone = replace(self, include_completed=True)
clone.include_completed = True
## No point with expanding in the subqueries - the expand logic will be handled
## further down. We leave server_expand as it is, though.
clone.expand = False
if (
calendar.client.features.is_supported("search.text")
and calendar.client.features.is_supported(
"search.combined-is-logical-and"
)
and (
not calendar.client.features.is_supported(
"search.recurrences.includes-implicit.todo"
)
or calendar.client.features.is_supported(
"search.recurrences.includes-implicit.todo.pending"
)
)
):
matches = []
for hacks in (
"ignore_completed1",
"ignore_completed2",
"ignore_completed3",
):
## The algorithm below does not handle recurrence split gently
matches.extend(
clone.search(
calendar,
server_expand,
split_expanded=False,
props=props,
xml=xml,
_hacks=hacks,
)
)
else:
## The algorithm below does not handle recurrence split gently
matches = clone.search(
calendar,
server_expand,
split_expanded=False,
props=props,
xml=xml,
_hacks=_hacks,
)
objects = []
match_set = set()
for item in matches:
if item.url not in match_set:
match_set.add(item.url)
objects.append(item)
else:
orig_xml = xml
## Now the xml variable may be either a full query or a filter
## and it may be either a string or an object.
if not xml or (
not isinstance(xml, str) and not xml.tag.endswith("calendar-query")
):
(xml, self.comp_class) = self.build_search_xml_query(
server_expand, props=props, filters=xml, _hacks=_hacks
)
if not self.comp_class and not calendar.client.features.is_supported(
"search.comp-type-optional"
):
if self.include_completed is None:
self.include_completed = True
return self._search_with_comptypes(
calendar,
server_expand,
split_expanded,
props,
orig_xml,
post_filter,
_hacks,
)
try:
(response, objects) = calendar._request_report_build_resultlist(
xml, self.comp_class, props=props
)
except error.ReportError as err:
## This is only for backward compatibility.
## Partial fix https://github.com/python-caldav/caldav/issues/401
if (
calendar.client.features.backward_compatibility_mode
and not self.comp_class
and not "400" in err.reason
):
return self._search_with_comptypes(
calendar,
server_expand,
split_expanded,
props,
orig_xml,
post_filter,
_hacks,
)
raise
## Some things, like `calendar.object_by_uid`, should always work, no matter if `davclient.compatibility_hints` is correctly configured or not
if not objects and not self.comp_class and _hacks == "insist":
return self._search_with_comptypes(
calendar,
server_expand,
split_expanded,
props,
orig_xml,
post_filter,
_hacks,
)
obj2 = []
for o in objects:
## This would not be needed if the servers would follow the standard ...
## TODO: use calendar.calendar_multiget - see https://github.com/python-caldav/caldav/issues/487
try:
o.load(only_if_unloaded=True)
obj2.append(o)
except:
logging.error(
"Server does not want to reveal details about the calendar object",
exc_info=True,
)
pass
objects = obj2
## Google sometimes returns empty objects
objects = [o for o in objects if o.has_component()]
objects = self.filter(objects, post_filter, split_expanded, server_expand)
## partial workaround for https://github.com/python-caldav/caldav/issues/201
for obj in objects:
try:
obj.load(only_if_unloaded=True)
except:
pass
return self.sort(objects)
def filter(
self,
objects: List[CalendarObjectResource],
post_filter: Optional[bool] = None,
split_expanded: bool = True,
server_expand: bool = False,
) -> List[CalendarObjectResource]:
"""Apply client-side filtering and handle recurrence expansion/splitting.
This method performs client-side filtering of calendar objects, handles
recurrence expansion, and splits expanded recurrences into separate objects
when requested.
:param objects: List of Event/Todo/Journal objects to filter
:param post_filter: Whether to apply the searcher's filter logic.
- True: Always apply filters (check_component)
- False: Never apply filters, only handle splitting
- None: Use default behavior (depends on self.expand and other flags)
:param split_expanded: Whether to split recurrence sets into multiple
separate CalendarObjectResource objects. If False, a recurrence set
will be contained in a single object with multiple subcomponents.
:param server_expand: Indicates that the server was supposed to expand
recurrences. If True and split_expanded is True, splitting will be
performed even without self.expand being set.
:return: Filtered and/or split list of CalendarObjectResource objects
The method handles:
- Client-side filtering when server returns too many results
- Exact match filtering (== operator)
- Recurrence expansion via self.check_component
- Splitting expanded recurrences into separate objects
- Preserving VTIMEZONE components when splitting
"""
if post_filter or self.expand or (split_expanded and server_expand):
objects_ = objects
objects = []
for o in objects_:
if self.expand or post_filter:
filtered = self.check_component(o, expand_only=not post_filter)
if not filtered:
continue
else:
filtered = [
x
for x in o.icalendar_instance.subcomponents
if not isinstance(x, Timezone)
]
i = o.icalendar_instance
tz_ = [x for x in i.subcomponents if isinstance(x, Timezone)]
i.subcomponents = tz_
for comp in filtered:
if isinstance(comp, Timezone):
continue
if split_expanded:
new_obj = o.copy(keep_uid=True)
new_i = new_obj.icalendar_instance
new_i.subcomponents = []
for tz in tz_:
new_i.add_component(tz)
objects.append(new_obj)
else:
new_i = i
new_i.add_component(comp)
if not (split_expanded):
objects.append(o)
return objects
def build_search_xml_query(
self, server_expand=False, props=None, filters=None, _hacks=None
):
"""This method will produce a caldav search query as an etree object.
It is primarily to be used from the search method. See the
documentation for the search method for more information.
"""
# those xml elements are weird. (a+b)+c != a+(b+c). First makes b and c as list members of a, second makes c an element in b which is an element of a.
# First objective is to let this take over all xml search query building and see that the current tests pass.
# ref https://www.ietf.org/rfc/rfc4791.txt, section 7.8.9 for how to build a todo-query
# We'll play with it and don't mind it's getting ugly and don't mind that the test coverage is lacking.
# we'll refactor and create some unit tests later, as well as ftests for complicated queries.
# build the request
data = cdav.CalendarData()
if server_expand:
if not self.start or not self.end:
raise error.ReportError("can't expand without a date range")
data += cdav.Expand(self.start, self.end)
if props is None:
props_ = [data]
else:
props_ = [data] + props
prop = dav.Prop() + props_
vcalendar = cdav.CompFilter("VCALENDAR")
comp_filter = None
if filters:
## It's disgraceful - `somexml = xml + [ more_elements ]` will alter xml,
## and there exists no `xml.copy`
## Hence, we need to import the deepcopy tool ...
filters = deepcopy(filters)
if filters.tag == cdav.CompFilter.tag:
comp_filter = filters
filters = []
else:
filters = []
vNotCompleted = cdav.TextMatch("COMPLETED", negate=True)
vNotCancelled = cdav.TextMatch("CANCELLED", negate=True)
vNeedsAction = cdav.TextMatch("NEEDS-ACTION")
vStatusNotCompleted = cdav.PropFilter("STATUS") + vNotCompleted
vStatusNotCancelled = cdav.PropFilter("STATUS") + vNotCancelled
vStatusNeedsAction = cdav.PropFilter("STATUS") + vNeedsAction
vStatusNotDefined = cdav.PropFilter("STATUS") + cdav.NotDefined()
vNoCompleteDate = cdav.PropFilter("COMPLETED") + cdav.NotDefined()
if _hacks == "ignore_completed1":
## This query is quite much in line with https://tools.ietf.org/html/rfc4791#section-7.8.9
filters.extend([vNoCompleteDate, vStatusNotCompleted, vStatusNotCancelled])
elif _hacks == "ignore_completed2":
## some server implementations (i.e. NextCloud
## and Baikal) will yield "false" on a negated TextMatch
## if the field is not defined. Hence, for those
## implementations we need to turn back and ask again
## ... do you have any VTODOs for us where the STATUS
## field is not defined? (ref
## https://github.com/python-caldav/caldav/issues/14)
filters.extend([vNoCompleteDate, vStatusNotDefined])
elif _hacks == "ignore_completed3":
## ... and considering recurring tasks we really need to
## look a third time as well, this time for any task with
## the NEEDS-ACTION status set (do we need the first go?
## NEEDS-ACTION or no status set should cover them all?)
filters.extend([vStatusNeedsAction])
if self.start or self.end:
filters.append(cdav.TimeRange(self.start, self.end))
if self.alarm_start or self.alarm_end:
filters.append(
cdav.CompFilter("VALARM")
+ cdav.TimeRange(self.alarm_start, self.alarm_end)
)
## I've designed this badly, at different places the caller
## may pass the component type either as boolean flags:
## `search(event=True, ...)`
## as a component class:
## `search(comp_class=caldav.calendarobjectresource.Event)`
## or as a component filter:
## `search(filters=cdav.CompFilter('VEVENT'), ...)`
## The only thing I don't support is the component name ('VEVENT').
## Anyway, this code section ensures both comp_filter and comp_class
## is given. Or at least, it tries to ensure it.
for flag, comp_name, comp_class_ in (
("event", "VEVENT", Event),
("todo", "VTODO", Todo),
("journal", "VJOURNAL", Journal),
):
flagged = getattr(self, flag)
if flagged:
## event/journal/todo is set, we adjust comp_class accordingly
if self.comp_class is not None and self.comp_class is not comp_class_:
raise error.ConsistencyError(
f"inconsistent search parameters - comp_class = {self.comp_class}, want {comp_class_}"
)
self.comp_class = comp_class_
if comp_filter and comp_filter.attributes["name"] == comp_name:
self.comp_class = comp_class_
if flag == "todo" and not self.todo and self.include_completed is None:
self.include_completed = True
setattr(self, flag, True)
if self.comp_class == comp_class_:
if comp_filter:
assert comp_filter.attributes["name"] == comp_name
else:
comp_filter = cdav.CompFilter(comp_name)
setattr(self, flag, True)
if self.comp_class and not comp_filter:
raise error.ConsistencyError(
f"unsupported comp class {self.comp_class} for search"
)
## Special hack for bedework.
## If asked for todos, we should NOT give any comp_filter to the server,
## we should rather ask for everything, and then do client-side filtering
if _hacks == "no_comp_filter":
comp_filter = None
self.comp_class = None
for property in self._property_operator:
if self._property_operator[property] == "undef":
match = cdav.NotDefined()
filters.append(cdav.PropFilter(property.upper()) + match)
else:
value = self._property_filters[property]
property_ = property.upper()
if property.lower() == "category":
property_ = "CATEGORIES"
if property.lower() == "categories":
values = value.cats
else:
values = [value]
for value in values:
if hasattr(value, "to_ical"):
value = value.to_ical()
# Get collation setting for this property if available
collation_str = "i;octet" # Default to binary
if (
hasattr(self, "_property_collation")
and property in self._property_collation
):
case_sensitive = self._property_case_sensitive.get(
property, True
)
collation_str = _collation_to_caldav(
self._property_collation[property], case_sensitive
)
match = cdav.TextMatch(value, collation=collation_str)
filters.append(cdav.PropFilter(property_) + match)
if comp_filter and filters:
comp_filter += filters
vcalendar += comp_filter
elif comp_filter:
vcalendar += comp_filter
elif filters:
vcalendar += filters
filter = cdav.Filter() + vcalendar
root = cdav.CalendarQuery() + [prop, filter]
return (root, self.comp_class)