diff --git a/CHANGELOG.md b/CHANGELOG.md index 536005f9..8efac321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 3.7.6.dev * Extension: item verification on commandline +* Improvement: catch lack of support of PERIOD in vobject <= 0.9.9 ## 3.7.5 * Add: [sharing] conversion_bday_summary_template (customize summary) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index c6419526..17cb66fb 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -200,6 +200,27 @@ def check_and_sanitize_items( logger.trace("ITEM/check_and_sanitize_item: remove existing tzinfo (dtstart has none): '%s' -> '%s'", date, dates.value[i]) if all(type(d) is type(ref_date) for d in dates.value): continue + if dates.params.get("VALUE") == ["PERIOD"]: + if not utils.vobject_supports_period(): + raise ValueError("PERIOD not supported by used vobject=%s in object %r" % (utils.package_version("vobject"), component_uid)) + # period = period-explicit / period-start + # period-explicit = date-time "/" date-time + # period-start = date-time "/" dur-value + for i, date in enumerate(dates.value): + if not isinstance(date, tuple): + raise ValueError("invalid PERIOD (not a tuple) in object %r" % component_uid) + if len(date) != 2: + raise ValueError("invalid PERIOD (not 2 elements) in object %r" % component_uid) + if type(date[0]) is datetime.datetime and type(date[1]) is datetime.datetime: + if (date[0] > date[1]): + raise ValueError("invalid PERIOD (end before start) in object %r" % component_uid) + # skip explicit tzinfo check for now, no buggy client known + logger.trace("ITEM/check_and_sanitize_item: PERIOD/start-stop found: '%s'", date) + elif type(date[0]) is datetime.datetime and type(date[1]) is datetime.timedelta: + logger.trace("ITEM/check_and_sanitize_item: PERIOD/start-duration found: '%s'", date) + else: + raise ValueError("invalid PERIOD (element types not matching) in object %r" % component_uid) + continue for i, date in enumerate(dates.value): dates.value[i] = ref_date.replace( date.year, date.month, date.day) @@ -568,8 +589,8 @@ class Item: try: self._text = self.vobject_item.serialize() except Exception as e: - raise RuntimeError("Failed to serialize item %r from %r: %s" % - (self.href, self._collection_path, + raise RuntimeError("Failed to serialize item %r with UID %r from %r: %s" % + (self.href, self.uid, self._collection_path, e)) from e return self._text diff --git a/radicale/tests/static/event_rdate_period_start_duration.ics b/radicale/tests/static/event_rdate_period_start_duration.ics new file mode 100644 index 00000000..5a767fd6 --- /dev/null +++ b/radicale/tests/static/event_rdate_period_start_duration.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:test +BEGIN:VEVENT +UID:test-rdate-period-start-duration +DESCRIPTION:event with rdate period start duration +DTSTART:20000101T000000Z +DURATION:PT1H +DTSTAMP:20000101T000000Z +RRULE:FREQ=WEEKLY +RDATE;VALUE=PERIOD:20000102T000000Z/PT2H +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_rdate_period_start_duration_multi.ics b/radicale/tests/static/event_rdate_period_start_duration_multi.ics new file mode 100644 index 00000000..ebbd2a92 --- /dev/null +++ b/radicale/tests/static/event_rdate_period_start_duration_multi.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:test +BEGIN:VEVENT +UID:test-rdate-period-start-duration-multi +DESCRIPTION:event with rdate period multiple start duration +DTSTART:20000101T000000Z +DURATION:PT1H +DTSTAMP:20000101T000000Z +RRULE:FREQ=WEEKLY +RDATE;VALUE=PERIOD:20000102T000000Z/PT2H,20010102T000000Z/PT2H +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_rdate_period_start_stop.ics b/radicale/tests/static/event_rdate_period_start_stop.ics new file mode 100644 index 00000000..f76a1680 --- /dev/null +++ b/radicale/tests/static/event_rdate_period_start_stop.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:test +BEGIN:VEVENT +UID:test-rdate-period-time-start-stop +DESCRIPTION:event with rdate period start and stop +DTSTART:20000101T000000Z +DURATION:PT1H +DTSTAMP:20000101T000000Z +RRULE:FREQ=WEEKLY +RDATE;VALUE=PERIOD:20000102T000000Z/20000402T000000Z/ +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_rdate_period_start_stop_multi.ics b/radicale/tests/static/event_rdate_period_start_stop_multi.ics new file mode 100644 index 00000000..aad13b3f --- /dev/null +++ b/radicale/tests/static/event_rdate_period_start_stop_multi.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:test +BEGIN:VEVENT +UID:test-rdate-period-time-start-stop-multi +DESCRIPTION:event with rdate period multiple start and stop +DTSTART:20000101T000000Z +DURATION:PT1H +DTSTAMP:20000101T000000Z +RRULE:FREQ=WEEKLY +RDATE;VALUE=PERIOD:20000102T000000Z/20000402T000000Z,20010102T000000Z/20010402T000000Z +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index d17dc70f..38775595 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -151,6 +151,48 @@ permissions: RrWw""") assert "Event" in answer assert "UID:event" in answer + @pytest.mark.skipif(not utils.vobject_supports_period(), reason="vobject <= 0.9.9 does not support PERIOD") + def test_add_event_with_rdate_period_start_duration_single(self) -> None: + """Add an event with RDATE/PERIOD with start+duration.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_rdate_period_start_duration.ics") + path = "/calendar.ics/event_rdate_period_start_duration.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + assert "RDATE;VALUE=PERIOD:20000102T000000Z/PT2H" in answer + + @pytest.mark.skipif(not utils.vobject_supports_period(), reason="vobject <= 0.9.9 does not support PERIOD") + def test_add_event_with_rdate_period_start_duration_multi(self) -> None: + """Add an event with RDATE/PERIOD with multiple start+duration.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_rdate_period_start_duration_multi.ics") + path = "/calendar.ics/event_rdate_period_start_duration_multi.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + assert "RDATE;VALUE=PERIOD:20000102T000000Z/PT2H,20010102T000000Z/PT2H" in answer + + @pytest.mark.skipif(not utils.vobject_supports_period(), reason="vobject <= 0.9.9 does not support PERIOD") + def test_add_event_with_rdate_period_start_stop_single(self) -> None: + """Add an event with RDATE/PERIOD with start+stop.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_rdate_period_start_stop.ics") + path = "/calendar.ics/event_rdate_period_start_stop.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + assert "RDATE;VALUE=PERIOD:20000102T000000Z/20000402T000000Z" in answer + + @pytest.mark.skipif(not utils.vobject_supports_period(), reason="vobject <= 0.9.9 does not support PERIOD") + def test_add_event_with_rdate_period_start_stop_multi(self) -> None: + """Add an event with RDATE/PERIOD with multiple start+stop.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_rdate_period_start_stop_multi.ics") + path = "/calendar.ics/event_rdate_period_start_stop_multi.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + # wrapped answer + assert "RDATE;VALUE=PERIOD:20000102T000000Z/20000402T000000Z,20010102T000000Z/20010" in answer + assert " 402T000000Z" in answer + def test_add_event_with_desc_ok(self) -> None: """Add an event.""" self.mkcalendar("/calendar.ics/") diff --git a/radicale/utils.py b/radicale/utils.py index fd56f286..dbf8b8eb 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -27,6 +27,8 @@ from importlib import import_module, metadata from string import ascii_letters, digits, punctuation from typing import Callable, Sequence, Tuple, Type, TypeVar, Union +import vobject + from radicale import config from radicale.log import logger @@ -107,6 +109,43 @@ def vobject_supports_vcard4() -> bool: return False +# global cache +vobject_supports_period_cache: Union[bool, None] = None + + +def vobject_supports_period() -> bool: + """Check if vobject supports period (requires version > 0.9.9).""" + global vobject_supports_period_cache + + if vobject_supports_period_cache is not None: + return vobject_supports_period_cache + + content_test = """ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:test +BEGIN:VEVENT +UID:test-rdate-period-time-start-stop +DTSTART:20000101T000000Z +DURATION:PT1H +DESCRIPTION:event with rdate period start and stop +DTSTAMP:20000101T000000Z +RDATE;VALUE=PERIOD:20000102T000000Z/20000402T000000Z +RRULE:FREQ=WEEKLY +END:VEVENT +END:VCALENDAR +""" + obj = vobject.readOne(content_test) + + try: + obj.serialize() + except Exception: + vobject_supports_period_cache = False + else: + vobject_supports_period_cache = True + return vobject_supports_period_cache + + def packages_version(): versions = [] versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])) @@ -373,11 +412,11 @@ def textwrap_str(content: str, limit: int = DEFAULT_LIMIT_CONTENT) -> str: def dataToHex(data, count): result = '' - for item in range(count): - if ((item > 0) and ((item % 8) == 0)): + for i in range(count): + if ((i > 0) and ((i % 8) == 0)): result += ' ' - if (item < len(data)): - result += '%02x' % data[item] + ' ' + if (i < len(data)): + result += '%02x' % data[i] + ' ' else: result += ' ' return result @@ -385,9 +424,9 @@ def dataToHex(data, count): def dataToAscii(data, count): result = '' - for item in range(count): - if (item < len(data)): - char = chr(data[item]) + for i in range(count): + if (i < len(data)): + char = chr(data[i]) if char in ascii_letters or \ char in digits or \ char in punctuation or \ @@ -400,9 +439,9 @@ def dataToAscii(data, count): def dataToSpecial(data, count): result = '' - for item in range(count): - if (item < len(data)): - char = chr(data[item]) + for i in range(count): + if (i < len(data)): + char = chr(data[i]) if char == '\r': result += 'C' elif char == '\n':