diff --git a/radicale/app/report.py b/radicale/app/report.py
index 5807f6e6..0c869107 100644
--- a/radicale/app/report.py
+++ b/radicale/app/report.py
@@ -18,11 +18,17 @@
# along with Radicale. If not, see .
import contextlib
+import datetime
import posixpath
import socket
+import copy
import xml.etree.ElementTree as ET
+from vobject.base import ContentLine
from http import client
-from typing import Callable, Iterable, Iterator, Optional, Sequence, Tuple
+from typing import (
+ Callable, Iterable, Iterator,
+ Optional, Sequence, Tuple,
+)
from urllib.parse import unquote, urlparse
import radicale.item as radicale_item
@@ -64,9 +70,8 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
logger.warning("Invalid REPORT method %r on %r requested",
xmlutils.make_human_tag(root.tag), path)
return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
- prop_element = root.find(xmlutils.make_clark("D:prop"))
- props = ([prop.tag for prop in prop_element]
- if prop_element is not None else [])
+
+ props = root.find(xmlutils.make_clark("D:prop")) or []
hreferences: Iterable[str]
if root.tag in (
@@ -138,19 +143,40 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
found_props = []
not_found_props = []
- for tag in props:
- element = ET.Element(tag)
- if tag == xmlutils.make_clark("D:getetag"):
+ for prop in props:
+ element = ET.Element(prop.tag)
+ if prop.tag == xmlutils.make_clark("D:getetag"):
element.text = item.etag
found_props.append(element)
- elif tag == xmlutils.make_clark("D:getcontenttype"):
+ elif prop.tag == xmlutils.make_clark("D:getcontenttype"):
element.text = xmlutils.get_content_type(item, encoding)
found_props.append(element)
- elif tag in (
+ elif prop.tag in (
xmlutils.make_clark("C:calendar-data"),
xmlutils.make_clark("CR:address-data")):
element.text = item.serialize()
- found_props.append(element)
+
+ expand = prop.find(xmlutils.make_clark("C:expand"))
+ if expand is not None:
+ start = expand.get('start')
+ end = expand.get('end')
+
+ if (start is None) or (end is None):
+ return client.FORBIDDEN, \
+ xmlutils.webdav_error("C:expand")
+
+ start = datetime.datetime.strptime(
+ start, '%Y%m%dT%H%M%SZ'
+ ).replace(tzinfo=datetime.timezone.utc)
+ end = datetime.datetime.strptime(
+ end, '%Y%m%dT%H%M%SZ'
+ ).replace(tzinfo=datetime.timezone.utc)
+
+ expanded_element = _expand(
+ element, copy.copy(item), start, end)
+ found_props.append(expanded_element)
+ else:
+ found_props.append(element)
else:
not_found_props.append(element)
@@ -164,6 +190,90 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
return client.MULTI_STATUS, multistatus
+def _expand(
+ element: ET.Element,
+ item: radicale_item.Item,
+ start: datetime.datetime,
+ end: datetime.datetime,
+) -> ET.Element:
+ rruleset = None
+ if hasattr(item.vobject_item.vevent, 'rrule'):
+ rruleset = item.vobject_item.vevent.getrruleset()
+
+ expanded_item = _make_vobject_expanded_item(item)
+
+ if rruleset:
+ recurrences = rruleset.between(start, end)
+
+ expanded = None
+ for recurrence_dt in recurrences:
+ vobject_item = copy.copy(expanded_item.vobject_item)
+
+ recurrence_utc = recurrence_dt.astimezone(datetime.timezone.utc)
+
+ vevent = copy.deepcopy(vobject_item.vevent)
+ vevent.recurrence_id = ContentLine(
+ name='RECURRENCE-ID',
+ value=recurrence_utc.strftime('%Y%m%dT%H%M%SZ'), params={}
+ )
+
+ if expanded is None:
+ vobject_item.vevent = vevent
+ expanded = vobject_item
+ else:
+ expanded.add(vevent)
+
+ element.text = expanded.serialize()
+ else:
+ element.text = expanded_item.vobject_item.serialize()
+
+ return element
+
+
+def _make_vobject_expanded_item(
+ item: radicale_item.Item
+) -> radicale_item.Item:
+ # https://www.rfc-editor.org/rfc/rfc4791#section-9.6.5
+ # The returned calendar components MUST NOT use recurrence
+ # properties (i.e., EXDATE, EXRULE, RDATE, and RRULE) and MUST NOT
+ # have reference to or include VTIMEZONE components. Date and local
+ # time with reference to time zone information MUST be converted
+ # into date with UTC time.
+
+ item = copy.copy(item)
+ vevent = item.vobject_item.vevent
+
+ start_utc = vevent.dtstart.value.astimezone(datetime.timezone.utc)
+ vevent.dtstart = ContentLine(
+ name='DTSTART',
+ value=start_utc.strftime('%Y%m%dT%H%M%SZ'), params={})
+
+ dt_end = getattr(vevent, 'dtend', None)
+ if dt_end is not None:
+ end_utc = dt_end.value.astimezone(datetime.timezone.utc)
+ vevent.dtend = ContentLine(
+ name='DTEND',
+ value=end_utc.strftime('%Y%m%dT%H%M%SZ'), params={})
+
+ timezones_to_remove = []
+ for component in item.vobject_item.components():
+ if component.name == 'VTIMEZONE':
+ timezones_to_remove.append(component)
+
+ for timezone in timezones_to_remove:
+ item.vobject_item.remove(timezone)
+
+ try:
+ delattr(item.vobject_item.vevent, 'rrule')
+ delattr(item.vobject_item.vevent, 'exdate')
+ delattr(item.vobject_item.vevent, 'exrule')
+ delattr(item.vobject_item.vevent, 'rdate')
+ except AttributeError:
+ pass
+
+ return item
+
+
def xml_item_response(base_prefix: str, href: str,
found_props: Sequence[ET.Element] = (),
not_found_props: Sequence[ET.Element] = (),
diff --git a/radicale/tests/static/event_daily_rrule.ics b/radicale/tests/static/event_daily_rrule.ics
new file mode 100644
index 00000000..362a18e4
--- /dev/null
+++ b/radicale/tests/static/event_daily_rrule.ics
@@ -0,0 +1,28 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+BEGIN:VTIMEZONE
+LAST-MODIFIED:20040110T032845Z
+TZID:US/Eastern
+BEGIN:DAYLIGHT
+DTSTART:20000404T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20001026T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTART;TZID=US/Eastern:20060102T120000
+DURATION:PT1H
+RRULE:FREQ=DAILY;COUNT=5
+SUMMARY:Recurring event
+UID:event_daily_rrule
+END:VEVENT
+END:VCALENDAR
diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py
index a0d3d534..0743dd6c 100644
--- a/radicale/tests/test_base.py
+++ b/radicale/tests/test_base.py
@@ -1525,6 +1525,87 @@ permissions: RrWw""")
calendar_path, "http://radicale.org/ns/sync/INVALID")
assert not sync_token
+ def test_report_with_expand_property(self) -> None:
+ self.put("/calendar.ics/", get_file_content("event_daily_rrule.ics"))
+ req_body_without_expand = \
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ _, responses = self.report("/calendar.ics/", req_body_without_expand)
+ assert len(responses) == 1
+
+ response = responses['/calendar.ics/event_daily_rrule.ics']
+ status, element = list(response.values())[0]
+
+ assert status == 200
+
+ assert "RRULE" in element.text
+ assert "BEGIN:VTIMEZONE" in element.text
+ assert "RECURRENCE-ID" not in element.text
+
+ uids = []
+ for line in element.text.split("\n"):
+ if line.startswith("UID:"):
+ uid = line[len("UID:"):]
+ assert uid == "event_daily_rrule"
+ uids.append(uids)
+
+ assert len(uids) == 1
+
+ req_body_with_expand = \
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ _, responses = self.report("/calendar.ics/", req_body_with_expand)
+
+ assert len(responses) == 1
+
+ response = responses['/calendar.ics/event_daily_rrule.ics']
+ status, element = list(response.values())[0]
+
+ assert status == 200
+ assert "RRULE" not in element.text
+ assert "BEGIN:VTIMEZONE" not in element.text
+
+ uids = []
+ recurrence_ids = []
+ for line in element.text.split("\n"):
+ if line.startswith("UID:"):
+ assert line == "UID:event_daily_rrule"
+ uids.append(uids)
+
+ if line.startswith("RECURRENCE-ID:"):
+ recurrence_ids.append(line)
+
+ assert len(uids) == 2
+ assert len(set(recurrence_ids)) == 2
+
def test_propfind_sync_token(self) -> None:
"""Retrieve the sync-token with a propfind request"""
calendar_path = "/calendar.ics/"