filter.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. # This file is part of Radicale - CalDAV and CardDAV server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2008-2015 Guillaume Ayoub
  5. # Copyright © 2017-2021 Unrud <unrud@outlook.com>
  6. # Copyright © 2023-2024 Ray <ray@react0r.com>
  7. # Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
  8. #
  9. # This library is free software: you can redistribute it and/or modify
  10. # it under the terms of the GNU General Public License as published by
  11. # the Free Software Foundation, either version 3 of the License, or
  12. # (at your option) any later version.
  13. #
  14. # This library is distributed in the hope that it will be useful,
  15. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. # GNU General Public License for more details.
  18. #
  19. # You should have received a copy of the GNU General Public License
  20. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  21. import math
  22. import sys
  23. import xml.etree.ElementTree as ET
  24. from datetime import date, datetime, timedelta, timezone
  25. from itertools import chain
  26. from typing import (Callable, Iterable, Iterator, List, Optional, Sequence,
  27. Tuple, Union)
  28. import vobject
  29. from radicale import item, xmlutils
  30. from radicale.log import logger
  31. from radicale.utils import format_ut
  32. DAY: timedelta = timedelta(days=1)
  33. SECOND: timedelta = timedelta(seconds=1)
  34. DATETIME_MIN: datetime = datetime.min.replace(tzinfo=timezone.utc)
  35. DATETIME_MAX: datetime = datetime.max.replace(tzinfo=timezone.utc)
  36. TIMESTAMP_MIN: int = math.floor(DATETIME_MIN.timestamp())
  37. TIMESTAMP_MAX: int = math.ceil(DATETIME_MAX.timestamp())
  38. if sys.version_info < (3, 10):
  39. TRIGGER = Union[datetime, None]
  40. else:
  41. TRIGGER = datetime | None
  42. def date_to_datetime(d: date, tzinfo=vobject.icalendar.utc) -> datetime:
  43. """Transform any date to a UTC datetime.
  44. If ``d`` is a datetime without timezone, return as UTC datetime. If ``d``
  45. is already a datetime with timezone, return as is.
  46. """
  47. if not isinstance(d, datetime):
  48. d = datetime.combine(d, datetime.min.time())
  49. if not d.tzinfo:
  50. # NOTE: using vobject's UTC as it wasn't playing well with datetime's.
  51. d = d.replace(tzinfo=tzinfo)
  52. return d
  53. def parse_time_range(time_filter: ET.Element) -> Tuple[datetime, datetime]:
  54. start_text = time_filter.get("start")
  55. end_text = time_filter.get("end")
  56. if start_text:
  57. start = datetime.strptime(
  58. start_text, "%Y%m%dT%H%M%SZ").replace(
  59. tzinfo=timezone.utc)
  60. else:
  61. start = DATETIME_MIN
  62. if end_text:
  63. end = datetime.strptime(
  64. end_text, "%Y%m%dT%H%M%SZ").replace(
  65. tzinfo=timezone.utc)
  66. else:
  67. end = DATETIME_MAX
  68. return start, end
  69. def time_range_timestamps(time_filter: ET.Element) -> Tuple[int, int]:
  70. start, end = parse_time_range(time_filter)
  71. return (math.floor(start.timestamp()), math.ceil(end.timestamp()))
  72. def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool:
  73. """Check whether the ``item`` matches the comp ``filter_``.
  74. If ``level`` is ``0``, the filter is applied on the
  75. item's collection. Otherwise, it's applied on the item.
  76. See rfc4791-9.7.1.
  77. """
  78. # TODO: Filtering VFREEBUSY is not implemented
  79. # HACK: the filters are tested separately against all components
  80. name = filter_.get("name", "").upper()
  81. logger.debug("TRACE/ITEM/FILTER/comp_match: name=%s level=%d", name, level)
  82. if level == 0:
  83. tag = item.name
  84. elif level == 1:
  85. tag = item.component_name
  86. elif level == 2:
  87. tag = item.component_name
  88. else:
  89. logger.warning(
  90. "Filters with %d levels of comp-filter are not supported", level)
  91. return True
  92. if not tag:
  93. return False
  94. if len(filter_) == 0:
  95. # Point #1 of rfc4791-9.7.1
  96. return name == tag
  97. if len(filter_) == 1:
  98. if filter_[0].tag == xmlutils.make_clark("C:is-not-defined"):
  99. # Point #2 of rfc4791-9.7.1
  100. return name != tag
  101. if (level < 2) and (name != tag):
  102. return False
  103. if ((level == 0 and name != "VCALENDAR") or
  104. (level == 1 and name not in ("VTODO", "VEVENT", "VJOURNAL")) or
  105. (level == 2 and name not in ("VALARM"))):
  106. logger.warning("Filtering %s is not supported", name)
  107. return True
  108. # Point #3 and #4 of rfc4791-9.7.1
  109. trigger = None
  110. if level == 0:
  111. components = [item.vobject_item]
  112. elif level == 1:
  113. components = list(getattr(item.vobject_item, "%s_list" % tag.lower()))
  114. elif level == 2:
  115. components = list(getattr(item.vobject_item, "%s_list" % tag.lower()))
  116. for comp in components:
  117. subcomp = getattr(comp, name.lower(), None)
  118. if not subcomp:
  119. return False
  120. if hasattr(subcomp, "trigger"):
  121. # rfc4791-7.8.5:
  122. trigger = subcomp.trigger.value
  123. for child in filter_:
  124. if child.tag == xmlutils.make_clark("C:prop-filter"):
  125. logger.debug("TRACE/ITEM/FILTER/comp_match: prop-filter level=%d", level)
  126. if not any(prop_match(comp, child, "C")
  127. for comp in components):
  128. return False
  129. elif child.tag == xmlutils.make_clark("C:time-range"):
  130. logger.debug("TRACE/ITEM/FILTER/comp_match: time-range level=%d tag=%s", level, tag)
  131. if (level == 0) and (name == "VCALENDAR"):
  132. for name_try in ("VTODO", "VEVENT", "VJOURNAL"):
  133. try:
  134. if time_range_match(item.vobject_item, filter_[0], name_try, trigger):
  135. return True
  136. except Exception:
  137. continue
  138. return False
  139. if not time_range_match(item.vobject_item, filter_[0], tag, trigger):
  140. return False
  141. elif child.tag == xmlutils.make_clark("C:comp-filter"):
  142. logger.debug("TRACE/ITEM/FILTER/comp_match: comp-filter level=%d", level)
  143. if not comp_match(item, child, level=level + 1):
  144. return False
  145. else:
  146. raise ValueError("Unexpected %r in comp-filter" % child.tag)
  147. return True
  148. def prop_match(vobject_item: vobject.base.Component,
  149. filter_: ET.Element, ns: str) -> bool:
  150. """Check whether the ``item`` matches the prop ``filter_``.
  151. See rfc4791-9.7.2 and rfc6352-10.5.1.
  152. """
  153. name = filter_.get("name", "").lower()
  154. if len(filter_) == 0:
  155. # Point #1 of rfc4791-9.7.2
  156. return name in vobject_item.contents
  157. if len(filter_) == 1:
  158. if filter_[0].tag == xmlutils.make_clark("%s:is-not-defined" % ns):
  159. # Point #2 of rfc4791-9.7.2
  160. return name not in vobject_item.contents
  161. if name not in vobject_item.contents:
  162. return False
  163. # Point #3 and #4 of rfc4791-9.7.2
  164. for child in filter_:
  165. if ns == "C" and child.tag == xmlutils.make_clark("C:time-range"):
  166. if not time_range_match(vobject_item, child, name, None):
  167. return False
  168. elif child.tag == xmlutils.make_clark("%s:text-match" % ns):
  169. if not text_match(vobject_item, child, name, ns):
  170. return False
  171. elif child.tag == xmlutils.make_clark("%s:param-filter" % ns):
  172. if not param_filter_match(vobject_item, child, name, ns):
  173. return False
  174. else:
  175. raise ValueError("Unexpected %r in prop-filter" % child.tag)
  176. return True
  177. def time_range_match(vobject_item: vobject.base.Component,
  178. filter_: ET.Element, child_name: str, trigger: TRIGGER) -> bool:
  179. """Check whether the component/property ``child_name`` of
  180. ``vobject_item`` matches the time-range ``filter_``."""
  181. # supporting since 3.5.4 now optional trigger (either absolute or relative offset)
  182. if not filter_.get("start") and not filter_.get("end"):
  183. return False
  184. start, end = parse_time_range(filter_)
  185. matched = False
  186. def range_fn(range_start: datetime, range_end: datetime,
  187. is_recurrence: bool) -> bool:
  188. nonlocal matched
  189. if trigger:
  190. # if trigger is given, only check range_start
  191. if isinstance(trigger, timedelta):
  192. # trigger is a offset, apply to range_start
  193. if start < range_start + trigger and range_start + trigger < end:
  194. matched = True
  195. return True
  196. else:
  197. return False
  198. elif isinstance(trigger, datetime):
  199. # trigger is absolute, use instead of range_start
  200. if start < trigger and trigger < end:
  201. matched = True
  202. return True
  203. else:
  204. return False
  205. else:
  206. logger.warning("item/filter/time_range_match/range_fn: unsupported data format of provided trigger=%r", trigger)
  207. return True
  208. if start < range_end and range_start < end:
  209. matched = True
  210. return True
  211. if end < range_start and not is_recurrence:
  212. return True
  213. return False
  214. def infinity_fn(start: datetime) -> bool:
  215. return False
  216. logger.debug("TRACE/ITEM/FILTER/time_range_match: start=(%s) end=(%s) child_name=%s", start, end, child_name)
  217. visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn)
  218. return matched
  219. def time_range_fill(vobject_item: vobject.base.Component,
  220. filter_: ET.Element, child_name: str, n: int = 1
  221. ) -> List[Tuple[datetime, datetime]]:
  222. """Create a list of ``n`` occurances from the component/property ``child_name``
  223. of ``vobject_item``."""
  224. if not filter_.get("start") and not filter_.get("end"):
  225. return []
  226. start, end = parse_time_range(filter_)
  227. ranges: List[Tuple[datetime, datetime]] = []
  228. def range_fn(range_start: datetime, range_end: datetime,
  229. is_recurrence: bool) -> bool:
  230. nonlocal ranges
  231. if start < range_end and range_start < end:
  232. ranges.append((range_start, range_end))
  233. if n > 0 and len(ranges) >= n:
  234. return True
  235. if end < range_start and not is_recurrence:
  236. return True
  237. return False
  238. def infinity_fn(range_start: datetime) -> bool:
  239. return False
  240. visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn)
  241. return ranges
  242. def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
  243. range_fn: Callable[[datetime, datetime, bool], bool],
  244. infinity_fn: Callable[[datetime], bool]) -> None:
  245. """Visit all time ranges in the component/property ``child_name`` of
  246. `vobject_item`` with visitors ``range_fn`` and ``infinity_fn``.
  247. ``range_fn`` gets called for every time_range with ``start`` and ``end``
  248. datetimes and ``is_recurrence`` as arguments. If the function returns True,
  249. the operation is cancelled.
  250. ``infinity_fn`` gets called when an infinite recurrence rule is detected
  251. with ``start`` datetime as argument. If the function returns True, the
  252. operation is cancelled.
  253. See rfc4791-9.9.
  254. """
  255. # HACK: According to rfc5545-3.8.4.4 a recurrence that is rescheduled
  256. # with Recurrence ID affects the recurrence itself and all following
  257. # recurrences too. This is not respected and client don't seem to bother
  258. # either.
  259. logger.debug("TRACE/ITEM/FILTER/visit_time_ranges: child_name=%s", child_name)
  260. def getrruleset(child: vobject.base.Component, ignore: Sequence[date]
  261. ) -> Tuple[Iterable[date], bool]:
  262. infinite = False
  263. for rrule in child.contents.get("rrule", []):
  264. if (";UNTIL=" not in rrule.value.upper() and
  265. ";COUNT=" not in rrule.value.upper()):
  266. infinite = True
  267. break
  268. if infinite:
  269. for dtstart in child.getrruleset(addRDate=True):
  270. if dtstart in ignore:
  271. continue
  272. if infinity_fn(date_to_datetime(dtstart)):
  273. return (), True
  274. break
  275. return filter(lambda dtstart: dtstart not in ignore,
  276. child.getrruleset(addRDate=True)), False
  277. def get_children(components: Iterable[vobject.base.Component]) -> Iterator[
  278. Tuple[vobject.base.Component, bool, List[date]]]:
  279. main = None
  280. rec_main = None
  281. recurrences = []
  282. for comp in components:
  283. if hasattr(comp, "recurrence_id") and comp.recurrence_id.value:
  284. recurrences.append(comp.recurrence_id.value)
  285. if comp.rruleset:
  286. if comp.rruleset._len is None:
  287. logger.warning("Ignore empty RRULESET in item at RECURRENCE-ID with value '%s' and UID '%s'", comp.recurrence_id.value, comp.uid.value)
  288. else:
  289. # Prevent possible infinite loop
  290. raise ValueError("Overwritten recurrence with RRULESET")
  291. rec_main = comp
  292. yield comp, True, []
  293. else:
  294. if main is not None:
  295. raise ValueError("Multiple main components. Got comp: {}".format(comp))
  296. main = comp
  297. if main is None and len(recurrences) == 1:
  298. main = rec_main
  299. if main is None:
  300. raise ValueError("Main component missing")
  301. yield main, False, recurrences
  302. # Comments give the lines in the tables of the specification
  303. if child_name == "VEVENT":
  304. for child, is_recurrence, recurrences in get_children(
  305. vobject_item.vevent_list):
  306. # TODO: check if there's a timezone
  307. try:
  308. dtstart = child.dtstart.value
  309. except AttributeError:
  310. raise AttributeError("missing DTSTART")
  311. if child.rruleset:
  312. dtstarts, infinity = getrruleset(child, recurrences)
  313. if infinity:
  314. return
  315. else:
  316. dtstarts = (dtstart,)
  317. dtend = getattr(child, "dtend", None)
  318. if dtend is not None:
  319. dtend = dtend.value
  320. # Ensure that both datetime.datetime objects have a timezone or
  321. # both do not have one before doing calculations. This is required
  322. # as the library does not support performing mathematical operations
  323. # on timezone-aware and timezone-naive objects. See #1847
  324. if hasattr(dtstart, 'tzinfo') and hasattr(dtend, 'tzinfo'):
  325. if dtstart.tzinfo is None and dtend.tzinfo is not None:
  326. dtstart_orig = dtstart
  327. dtstart = date_to_datetime(dtstart, dtend.astimezone().tzinfo)
  328. logger.debug("TRACE/ITEM/FILTER/get_children: overtake missing tzinfo on dtstart from dtend: '%s' -> '%s'", dtstart_orig, dtstart)
  329. elif dtstart.tzinfo is not None and dtend.tzinfo is None:
  330. dtend_orig = dtend
  331. dtend = date_to_datetime(dtend, dtstart.astimezone().tzinfo)
  332. logger.debug("TRACE/ITEM/FILTER/get_children: overtake missing tzinfo on dtend from dtstart: '%s' -> '%s'", dtend_orig, dtend)
  333. original_duration = (dtend - dtstart).total_seconds()
  334. dtend = date_to_datetime(dtend)
  335. duration = getattr(child, "duration", None)
  336. if duration is not None:
  337. original_duration = duration = duration.value
  338. for dtstart in dtstarts:
  339. dtstart_is_datetime = isinstance(dtstart, datetime)
  340. dtstart = date_to_datetime(dtstart)
  341. if dtend is not None:
  342. # Line 1
  343. dtend = dtstart + timedelta(seconds=original_duration)
  344. if range_fn(dtstart, dtend, is_recurrence):
  345. return
  346. elif duration is not None:
  347. if original_duration is None:
  348. original_duration = duration.seconds
  349. if duration.seconds > 0:
  350. # Line 2
  351. if range_fn(dtstart, dtstart + duration,
  352. is_recurrence):
  353. return
  354. else:
  355. # Line 3
  356. if range_fn(dtstart, dtstart + SECOND, is_recurrence):
  357. return
  358. elif dtstart_is_datetime:
  359. # Line 4
  360. if range_fn(dtstart, dtstart + SECOND, is_recurrence):
  361. return
  362. else:
  363. # Line 5
  364. if range_fn(dtstart, dtstart + DAY, is_recurrence):
  365. return
  366. elif child_name == "VTODO":
  367. for child, is_recurrence, recurrences in get_children(
  368. vobject_item.vtodo_list):
  369. dtstart = getattr(child, "dtstart", None)
  370. duration = getattr(child, "duration", None)
  371. due = getattr(child, "due", None)
  372. completed = getattr(child, "completed", None)
  373. created = getattr(child, "created", None)
  374. if dtstart is not None:
  375. dtstart = date_to_datetime(dtstart.value)
  376. if duration is not None:
  377. duration = duration.value
  378. if due is not None:
  379. due = date_to_datetime(due.value)
  380. if dtstart is not None:
  381. original_duration = (due - dtstart).total_seconds()
  382. if completed is not None:
  383. completed = date_to_datetime(completed.value)
  384. if created is not None:
  385. created = date_to_datetime(created.value)
  386. original_duration = (completed - created).total_seconds()
  387. elif created is not None:
  388. created = date_to_datetime(created.value)
  389. if child.rruleset:
  390. reference_dates, infinity = getrruleset(child, recurrences)
  391. if infinity:
  392. return
  393. else:
  394. if dtstart is not None:
  395. reference_dates = (dtstart,)
  396. elif due is not None:
  397. reference_dates = (due,)
  398. elif completed is not None:
  399. reference_dates = (completed,)
  400. elif created is not None:
  401. reference_dates = (created,)
  402. else:
  403. # Line 8
  404. if range_fn(DATETIME_MIN, DATETIME_MAX, is_recurrence):
  405. return
  406. reference_dates = ()
  407. for reference_date in reference_dates:
  408. reference_date = date_to_datetime(reference_date)
  409. if dtstart is not None and duration is not None:
  410. # Line 1
  411. if range_fn(reference_date,
  412. reference_date + duration + SECOND,
  413. is_recurrence):
  414. return
  415. if range_fn(reference_date + duration - SECOND,
  416. reference_date + duration + SECOND,
  417. is_recurrence):
  418. return
  419. elif dtstart is not None and due is not None:
  420. # Line 2
  421. due = reference_date + timedelta(seconds=original_duration)
  422. if (range_fn(reference_date, due, is_recurrence) or
  423. range_fn(reference_date,
  424. reference_date + SECOND, is_recurrence) or
  425. range_fn(due - SECOND, due, is_recurrence) or
  426. range_fn(due - SECOND, reference_date + SECOND,
  427. is_recurrence)):
  428. return
  429. elif dtstart is not None:
  430. if range_fn(reference_date, reference_date + SECOND,
  431. is_recurrence):
  432. return
  433. elif due is not None:
  434. # Line 4
  435. if range_fn(reference_date - SECOND, reference_date,
  436. is_recurrence):
  437. return
  438. elif completed is not None and created is not None:
  439. # Line 5
  440. completed = reference_date + timedelta(
  441. seconds=original_duration)
  442. if (range_fn(reference_date - SECOND,
  443. reference_date + SECOND,
  444. is_recurrence) or
  445. range_fn(completed - SECOND, completed + SECOND,
  446. is_recurrence) or
  447. range_fn(reference_date - SECOND,
  448. reference_date + SECOND, is_recurrence) or
  449. range_fn(completed - SECOND, completed + SECOND,
  450. is_recurrence)):
  451. return
  452. elif completed is not None:
  453. # Line 6
  454. if range_fn(reference_date - SECOND,
  455. reference_date + SECOND, is_recurrence):
  456. return
  457. elif created is not None:
  458. # Line 7
  459. if range_fn(reference_date, DATETIME_MAX, is_recurrence):
  460. return
  461. elif child_name == "VJOURNAL":
  462. for child, is_recurrence, recurrences in get_children(
  463. vobject_item.vjournal_list):
  464. dtstart = getattr(child, "dtstart", None)
  465. if dtstart is not None:
  466. dtstart = dtstart.value
  467. if child.rruleset:
  468. dtstarts, infinity = getrruleset(child, recurrences)
  469. if infinity:
  470. return
  471. else:
  472. dtstarts = (dtstart,)
  473. for dtstart in dtstarts:
  474. dtstart_is_datetime = isinstance(dtstart, datetime)
  475. dtstart = date_to_datetime(dtstart)
  476. if dtstart_is_datetime:
  477. # Line 1
  478. if range_fn(dtstart, dtstart + SECOND, is_recurrence):
  479. return
  480. else:
  481. # Line 2
  482. if range_fn(dtstart, dtstart + DAY, is_recurrence):
  483. return
  484. else:
  485. # Match a property
  486. logger.debug("TRACE/ITEM/FILTER/get_children: child_name=%s property match", child_name)
  487. child = getattr(vobject_item, child_name.lower())
  488. if isinstance(child.value, date):
  489. child_is_datetime = isinstance(child.value, datetime)
  490. child = date_to_datetime(child.value)
  491. if child_is_datetime:
  492. range_fn(child, child + SECOND, False)
  493. else:
  494. range_fn(child, child + DAY, False)
  495. def text_match(vobject_item: vobject.base.Component,
  496. filter_: ET.Element, child_name: str, ns: str,
  497. attrib_name: Optional[str] = None) -> bool:
  498. """Check whether the ``item`` matches the text-match ``filter_``.
  499. See rfc4791-9.7.5.
  500. """
  501. # TODO: collations are not supported, but the default ones needed
  502. # for DAV servers are actually pretty useless. Texts are lowered to
  503. # be case-insensitive, almost as the "i;ascii-casemap" value.
  504. text = next(filter_.itertext()).lower()
  505. match_type = "contains"
  506. if ns == "CR":
  507. match_type = filter_.get("match-type", match_type)
  508. def match(value: str) -> bool:
  509. value = value.lower()
  510. if match_type == "equals":
  511. return value == text
  512. if match_type == "contains":
  513. return text in value
  514. if match_type == "starts-with":
  515. return value.startswith(text)
  516. if match_type == "ends-with":
  517. return value.endswith(text)
  518. raise ValueError("Unexpected text-match match-type: %r" % match_type)
  519. children = getattr(vobject_item, "%s_list" % child_name, [])
  520. if attrib_name is not None:
  521. condition = any(
  522. match(attrib) for child in children
  523. for attrib in child.params.get(attrib_name, []))
  524. else:
  525. res = []
  526. for child in children:
  527. # Some filters such as CATEGORIES provide a list in child.value
  528. if type(child.value) is list:
  529. for value in child.value:
  530. res.append(match(value))
  531. else:
  532. res.append(match(child.value))
  533. condition = any(res)
  534. if filter_.get("negate-condition") == "yes":
  535. return not condition
  536. return condition
  537. def param_filter_match(vobject_item: vobject.base.Component,
  538. filter_: ET.Element, parent_name: str, ns: str) -> bool:
  539. """Check whether the ``item`` matches the param-filter ``filter_``.
  540. See rfc4791-9.7.3.
  541. """
  542. name = filter_.get("name", "").upper()
  543. children = getattr(vobject_item, "%s_list" % parent_name, [])
  544. condition = any(name in child.params for child in children)
  545. if len(filter_) > 0:
  546. if filter_[0].tag == xmlutils.make_clark("%s:text-match" % ns):
  547. return condition and text_match(
  548. vobject_item, filter_[0], parent_name, ns, name)
  549. if filter_[0].tag == xmlutils.make_clark("%s:is-not-defined" % ns):
  550. return not condition
  551. return condition
  552. def simplify_prefilters(filters: Iterable[ET.Element], collection_tag: str
  553. ) -> Tuple[Optional[str], int, int, bool]:
  554. """Creates a simplified condition from ``filters``.
  555. Returns a tuple (``tag``, ``start``, ``end``, ``simple``) where ``tag`` is
  556. a string or None (match all) and ``start`` and ``end`` are POSIX
  557. timestamps (as int). ``simple`` is a bool that indicates that ``filters``
  558. and the simplified condition are identical.
  559. """
  560. flat_filters = list(chain.from_iterable(filters))
  561. simple = len(flat_filters) <= 1
  562. logger.debug("TRACE/ITEM/FILTER/simplify_prefilters: collection_tag=%s", collection_tag)
  563. for col_filter in flat_filters:
  564. if collection_tag != "VCALENDAR":
  565. simple = False
  566. break
  567. if (col_filter.tag != xmlutils.make_clark("C:comp-filter") or
  568. col_filter.get("name", "").upper() != "VCALENDAR"):
  569. simple = False
  570. continue
  571. simple &= len(col_filter) <= 1
  572. for comp_filter in col_filter:
  573. logger.debug("TRACE/ITEM/FILTER/simplify_prefilters: filter.tag=%s simple=%s", comp_filter.tag, simple)
  574. if comp_filter.tag == xmlutils.make_clark("C:time-range") and simple is True:
  575. # time-filter found on level 0
  576. start, end = time_range_timestamps(comp_filter)
  577. logger.debug("TRACE/ITEM/FILTER/simplify_prefilters: found time-filter on level 0 start=%r(%d) end=%r(%d) simple=%s", format_ut(start), start, format_ut(end), end, simple)
  578. return None, start, end, simple
  579. if comp_filter.tag != xmlutils.make_clark("C:comp-filter"):
  580. logger.debug("TRACE/ITEM/FILTER/simplify_prefilters: no comp-filter on level 0")
  581. simple = False
  582. continue
  583. tag = comp_filter.get("name", "").upper()
  584. if comp_filter.find(
  585. xmlutils.make_clark("C:is-not-defined")) is not None:
  586. simple = False
  587. continue
  588. simple &= len(comp_filter) <= 1
  589. for time_filter in comp_filter:
  590. if tag not in ("VTODO", "VEVENT", "VJOURNAL"):
  591. simple = False
  592. break
  593. if time_filter.tag != xmlutils.make_clark("C:time-range"):
  594. simple = False
  595. continue
  596. start, end = time_range_timestamps(time_filter)
  597. logger.debug("TRACE/ITEM/FILTER/simplify_prefilters: found time-filter on level 1 tag=%s start=%d end=%d simple=%s", tag, start, end, simple)
  598. return tag, start, end, simple
  599. return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
  600. return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple