| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529 |
- # This file is part of Radicale Server - Calendar Server
- # Copyright © 2008 Nicolas Kandel
- # Copyright © 2008 Pascal Halter
- # Copyright © 2008-2015 Guillaume Ayoub
- # Copyright © 2017-2018 Unrud<unrud@outlook.com>
- #
- # This library is free software: you can redistribute it and/or modify
- # it under the terms of the GNU General Public License as published by
- # the Free Software Foundation, either version 3 of the License, or
- # (at your option) any later version.
- #
- # This library is distributed in the hope that it will be useful,
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- # GNU General Public License for more details.
- #
- # You should have received a copy of the GNU General Public License
- # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
- import math
- from datetime import date, datetime, timedelta, timezone
- from itertools import chain
- from radicale import xmlutils
- from radicale.log import logger
- DAY = timedelta(days=1)
- SECOND = timedelta(seconds=1)
- DATETIME_MIN = datetime.min.replace(tzinfo=timezone.utc)
- DATETIME_MAX = datetime.max.replace(tzinfo=timezone.utc)
- TIMESTAMP_MIN = math.floor(DATETIME_MIN.timestamp())
- TIMESTAMP_MAX = math.ceil(DATETIME_MAX.timestamp())
- def date_to_datetime(date_):
- """Transform a date to a UTC datetime.
- If date_ is a datetime without timezone, return as UTC datetime. If date_
- is already a datetime with timezone, return as is.
- """
- if not isinstance(date_, datetime):
- date_ = datetime.combine(date_, datetime.min.time())
- if not date_.tzinfo:
- date_ = date_.replace(tzinfo=timezone.utc)
- return date_
- def comp_match(item, filter_, level=0):
- """Check whether the ``item`` matches the comp ``filter_``.
- If ``level`` is ``0``, the filter is applied on the
- item's collection. Otherwise, it's applied on the item.
- See rfc4791-9.7.1.
- """
- # TODO: Filtering VALARM and VFREEBUSY is not implemented
- # HACK: the filters are tested separately against all components
- if level == 0:
- tag = item.name
- elif level == 1:
- tag = item.component_name
- else:
- logger.warning(
- "Filters with three levels of comp-filter are not supported")
- return True
- if not tag:
- return False
- name = filter_.get("name").upper()
- if len(filter_) == 0:
- # Point #1 of rfc4791-9.7.1
- return name == tag
- if len(filter_) == 1:
- if filter_[0].tag == xmlutils.make_tag("C", "is-not-defined"):
- # Point #2 of rfc4791-9.7.1
- return name != tag
- if name != tag:
- return False
- if (level == 0 and name != "VCALENDAR" or
- level == 1 and name not in ("VTODO", "VEVENT", "VJOURNAL")):
- logger.warning("Filtering %s is not supported" % name)
- return True
- # Point #3 and #4 of rfc4791-9.7.1
- components = ([item.vobject_item] if level == 0
- else list(getattr(item.vobject_item,
- "%s_list" % tag.lower())))
- for child in filter_:
- if child.tag == xmlutils.make_tag("C", "prop-filter"):
- if not any(prop_match(comp, child, "C")
- for comp in components):
- return False
- elif child.tag == xmlutils.make_tag("C", "time-range"):
- if not time_range_match(item.vobject_item, filter_[0], tag):
- return False
- elif child.tag == xmlutils.make_tag("C", "comp-filter"):
- if not comp_match(item, child, level=level + 1):
- return False
- else:
- raise ValueError("Unexpected %r in comp-filter" % child.tag)
- return True
- def prop_match(vobject_item, filter_, ns):
- """Check whether the ``item`` matches the prop ``filter_``.
- See rfc4791-9.7.2 and rfc6352-10.5.1.
- """
- name = filter_.get("name").lower()
- if len(filter_) == 0:
- # Point #1 of rfc4791-9.7.2
- return name in vobject_item.contents
- if len(filter_) == 1:
- if filter_[0].tag == xmlutils.make_tag("C", "is-not-defined"):
- # Point #2 of rfc4791-9.7.2
- return name not in vobject_item.contents
- if name not in vobject_item.contents:
- return False
- # Point #3 and #4 of rfc4791-9.7.2
- for child in filter_:
- if ns == "C" and child.tag == xmlutils.make_tag("C", "time-range"):
- if not time_range_match(vobject_item, child, name):
- return False
- elif child.tag == xmlutils.make_tag(ns, "text-match"):
- if not text_match(vobject_item, child, name, ns):
- return False
- elif child.tag == xmlutils.make_tag(ns, "param-filter"):
- if not param_filter_match(vobject_item, child, name, ns):
- return False
- else:
- raise ValueError("Unexpected %r in prop-filter" % child.tag)
- return True
- def time_range_match(vobject_item, filter_, child_name):
- """Check whether the component/property ``child_name`` of
- ``vobject_item`` matches the time-range ``filter_``."""
- start = filter_.get("start")
- end = filter_.get("end")
- if not start and not end:
- return False
- if start:
- start = datetime.strptime(start, "%Y%m%dT%H%M%SZ")
- else:
- start = datetime.min
- if end:
- end = datetime.strptime(end, "%Y%m%dT%H%M%SZ")
- else:
- end = datetime.max
- start = start.replace(tzinfo=timezone.utc)
- end = end.replace(tzinfo=timezone.utc)
- matched = False
- def range_fn(range_start, range_end, is_recurrence):
- nonlocal matched
- if start < range_end and range_start < end:
- matched = True
- return True
- if end < range_start and not is_recurrence:
- return True
- return False
- def infinity_fn(start):
- return False
- visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn)
- return matched
- def visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn):
- """Visit all time ranges in the component/property ``child_name`` of
- `vobject_item`` with visitors ``range_fn`` and ``infinity_fn``.
- ``range_fn`` gets called for every time_range with ``start`` and ``end``
- datetimes and ``is_recurrence`` as arguments. If the function returns True,
- the operation is cancelled.
- ``infinity_fn`` gets called when an infiite recurrence rule is detected
- with ``start`` datetime as argument. If the function returns True, the
- operation is cancelled.
- See rfc4791-9.9.
- """
- # HACK: According to rfc5545-3.8.4.4 an recurrance that is resheduled
- # with Recurrence ID affects the recurrence itself and all following
- # recurrences too. This is not respected and client don't seem to bother
- # either.
- def getrruleset(child, ignore=()):
- if (hasattr(child, "rrule") and
- ";UNTIL=" not in child.rrule.value.upper() and
- ";COUNT=" not in child.rrule.value.upper()):
- for dtstart in child.getrruleset(addRDate=True):
- if dtstart in ignore:
- continue
- if infinity_fn(date_to_datetime(dtstart)):
- return (), True
- break
- return filter(lambda dtstart: dtstart not in ignore,
- child.getrruleset(addRDate=True)), False
- def get_children(components):
- main = None
- recurrences = []
- for comp in components:
- if hasattr(comp, "recurrence_id") and comp.recurrence_id.value:
- recurrences.append(comp.recurrence_id.value)
- if comp.rruleset:
- # Prevent possible infinite loop
- raise ValueError("Overwritten recurrence with RRULESET")
- yield comp, True, ()
- else:
- if main is not None:
- raise ValueError("Multiple main components")
- main = comp
- if main is None:
- raise ValueError("Main component missing")
- yield main, False, recurrences
- # Comments give the lines in the tables of the specification
- if child_name == "VEVENT":
- for child, is_recurrence, recurrences in get_children(
- vobject_item.vevent_list):
- # TODO: check if there's a timezone
- dtstart = child.dtstart.value
- if child.rruleset:
- dtstarts, infinity = getrruleset(child, recurrences)
- if infinity:
- return
- else:
- dtstarts = (dtstart,)
- dtend = getattr(child, "dtend", None)
- if dtend is not None:
- dtend = dtend.value
- original_duration = (dtend - dtstart).total_seconds()
- dtend = date_to_datetime(dtend)
- duration = getattr(child, "duration", None)
- if duration is not None:
- original_duration = duration = duration.value
- for dtstart in dtstarts:
- dtstart_is_datetime = isinstance(dtstart, datetime)
- dtstart = date_to_datetime(dtstart)
- if dtend is not None:
- # Line 1
- dtend = dtstart + timedelta(seconds=original_duration)
- if range_fn(dtstart, dtend, is_recurrence):
- return
- elif duration is not None:
- if original_duration is None:
- original_duration = duration.seconds
- if duration.seconds > 0:
- # Line 2
- if range_fn(dtstart, dtstart + duration,
- is_recurrence):
- return
- else:
- # Line 3
- if range_fn(dtstart, dtstart + SECOND, is_recurrence):
- return
- elif dtstart_is_datetime:
- # Line 4
- if range_fn(dtstart, dtstart + SECOND, is_recurrence):
- return
- else:
- # Line 5
- if range_fn(dtstart, dtstart + DAY, is_recurrence):
- return
- elif child_name == "VTODO":
- for child, is_recurrence, recurrences in get_children(
- vobject_item.vtodo_list):
- dtstart = getattr(child, "dtstart", None)
- duration = getattr(child, "duration", None)
- due = getattr(child, "due", None)
- completed = getattr(child, "completed", None)
- created = getattr(child, "created", None)
- if dtstart is not None:
- dtstart = date_to_datetime(dtstart.value)
- if duration is not None:
- duration = duration.value
- if due is not None:
- due = date_to_datetime(due.value)
- if dtstart is not None:
- original_duration = (due - dtstart).total_seconds()
- if completed is not None:
- completed = date_to_datetime(completed.value)
- if created is not None:
- created = date_to_datetime(created.value)
- original_duration = (completed - created).total_seconds()
- elif created is not None:
- created = date_to_datetime(created.value)
- if child.rruleset:
- reference_dates, infinity = getrruleset(child, recurrences)
- if infinity:
- return
- else:
- if dtstart is not None:
- reference_dates = (dtstart,)
- elif due is not None:
- reference_dates = (due,)
- elif completed is not None:
- reference_dates = (completed,)
- elif created is not None:
- reference_dates = (created,)
- else:
- # Line 8
- if range_fn(DATETIME_MIN, DATETIME_MAX, is_recurrence):
- return
- reference_dates = ()
- for reference_date in reference_dates:
- reference_date = date_to_datetime(reference_date)
- if dtstart is not None and duration is not None:
- # Line 1
- if range_fn(reference_date,
- reference_date + duration + SECOND,
- is_recurrence):
- return
- if range_fn(reference_date + duration - SECOND,
- reference_date + duration + SECOND,
- is_recurrence):
- return
- elif dtstart is not None and due is not None:
- # Line 2
- due = reference_date + timedelta(seconds=original_duration)
- if (range_fn(reference_date, due, is_recurrence) or
- range_fn(reference_date,
- reference_date + SECOND, is_recurrence) or
- range_fn(due - SECOND, due, is_recurrence) or
- range_fn(due - SECOND, reference_date + SECOND,
- is_recurrence)):
- return
- elif dtstart is not None:
- if range_fn(reference_date, reference_date + SECOND,
- is_recurrence):
- return
- elif due is not None:
- # Line 4
- if range_fn(reference_date - SECOND, reference_date,
- is_recurrence):
- return
- elif completed is not None and created is not None:
- # Line 5
- completed = reference_date + timedelta(
- seconds=original_duration)
- if (range_fn(reference_date - SECOND,
- reference_date + SECOND,
- is_recurrence) or
- range_fn(completed - SECOND, completed + SECOND,
- is_recurrence) or
- range_fn(reference_date - SECOND,
- reference_date + SECOND, is_recurrence) or
- range_fn(completed - SECOND, completed + SECOND,
- is_recurrence)):
- return
- elif completed is not None:
- # Line 6
- if range_fn(reference_date - SECOND,
- reference_date + SECOND, is_recurrence):
- return
- elif created is not None:
- # Line 7
- if range_fn(reference_date, DATETIME_MAX, is_recurrence):
- return
- elif child_name == "VJOURNAL":
- for child, is_recurrence, recurrences in get_children(
- vobject_item.vjournal_list):
- dtstart = getattr(child, "dtstart", None)
- if dtstart is not None:
- dtstart = dtstart.value
- if child.rruleset:
- dtstarts, infinity = getrruleset(child, recurrences)
- if infinity:
- return
- else:
- dtstarts = (dtstart,)
- for dtstart in dtstarts:
- dtstart_is_datetime = isinstance(dtstart, datetime)
- dtstart = date_to_datetime(dtstart)
- if dtstart_is_datetime:
- # Line 1
- if range_fn(dtstart, dtstart + SECOND, is_recurrence):
- return
- else:
- # Line 2
- if range_fn(dtstart, dtstart + DAY, is_recurrence):
- return
- else:
- # Match a property
- child = getattr(vobject_item, child_name.lower())
- if isinstance(child, date):
- range_fn(child, child + DAY, False)
- elif isinstance(child, datetime):
- range_fn(child, child + SECOND, False)
- def text_match(vobject_item, filter_, child_name, ns, attrib_name=None):
- """Check whether the ``item`` matches the text-match ``filter_``.
- See rfc4791-9.7.5.
- """
- # TODO: collations are not supported, but the default ones needed
- # for DAV servers are actually pretty useless. Texts are lowered to
- # be case-insensitive, almost as the "i;ascii-casemap" value.
- text = next(filter_.itertext()).lower()
- match_type = "contains"
- if ns == "CR":
- match_type = filter_.get("match-type", match_type)
- def match(value):
- value = value.lower()
- if match_type == "equals":
- return value == text
- if match_type == "contains":
- return text in value
- if match_type == "starts-with":
- return value.startswith(text)
- if match_type == "ends-with":
- return value.endswith(text)
- raise ValueError("Unexpected text-match match-type: %r" % match_type)
- children = getattr(vobject_item, "%s_list" % child_name, [])
- if attrib_name:
- condition = any(
- match(attrib) for child in children
- for attrib in child.params.get(attrib_name, []))
- else:
- condition = any(match(child.value) for child in children)
- if filter_.get("negate-condition") == "yes":
- return not condition
- else:
- return condition
- def param_filter_match(vobject_item, filter_, parent_name, ns):
- """Check whether the ``item`` matches the param-filter ``filter_``.
- See rfc4791-9.7.3.
- """
- name = filter_.get("name").upper()
- children = getattr(vobject_item, "%s_list" % parent_name, [])
- condition = any(name in child.params for child in children)
- if len(filter_):
- if filter_[0].tag == xmlutils.make_tag(ns, "text-match"):
- return condition and text_match(
- vobject_item, filter_[0], parent_name, ns, name)
- elif filter_[0].tag == xmlutils.make_tag(ns, "is-not-defined"):
- return not condition
- else:
- return condition
- def simplify_prefilters(filters, collection_tag="VCALENDAR"):
- """Creates a simplified condition from ``filters``.
- Returns a tuple (``tag``, ``start``, ``end``, ``simple``) where ``tag`` is
- a string or None (match all) and ``start`` and ``end`` are POSIX
- timestamps (as int). ``simple`` is a bool that indicates that ``filters``
- and the simplified condition are identical.
- """
- flat_filters = tuple(chain.from_iterable(filters))
- simple = len(flat_filters) <= 1
- for col_filter in flat_filters:
- if collection_tag != "VCALENDAR":
- simple = False
- break
- if (col_filter.tag != xmlutils.make_tag("C", "comp-filter") or
- col_filter.get("name").upper() != "VCALENDAR"):
- simple = False
- continue
- simple &= len(col_filter) <= 1
- for comp_filter in col_filter:
- if comp_filter.tag != xmlutils.make_tag("C", "comp-filter"):
- simple = False
- continue
- tag = comp_filter.get("name").upper()
- if comp_filter.find(
- xmlutils.make_tag("C", "is-not-defined")) is not None:
- simple = False
- continue
- simple &= len(comp_filter) <= 1
- for time_filter in comp_filter:
- if tag not in ("VTODO", "VEVENT", "VJOURNAL"):
- simple = False
- break
- if time_filter.tag != xmlutils.make_tag("C", "time-range"):
- simple = False
- continue
- start = time_filter.get("start")
- end = time_filter.get("end")
- if start:
- start = math.floor(datetime.strptime(
- start, "%Y%m%dT%H%M%SZ").replace(
- tzinfo=timezone.utc).timestamp())
- else:
- start = TIMESTAMP_MIN
- if end:
- end = math.ceil(datetime.strptime(
- end, "%Y%m%dT%H%M%SZ").replace(
- tzinfo=timezone.utc).timestamp())
- else:
- end = TIMESTAMP_MAX
- return tag, start, end, simple
- return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
- return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
|