Merge pull request #2167 from pbiering/rrdate-period

proper PERIOD handling for RDATE depending on vobject version
This commit is contained in:
Peter Bieringer
2026-06-21 18:30:55 +03:00
committed by GitHub
8 changed files with 167 additions and 12 deletions

View File

@@ -2,6 +2,7 @@
## 3.7.6.dev ## 3.7.6.dev
* Extension: item verification on commandline * Extension: item verification on commandline
* Improvement: catch lack of support of PERIOD in vobject <= 0.9.9
## 3.7.5 ## 3.7.5
* Add: [sharing] conversion_bday_summary_template (customize summary) * Add: [sharing] conversion_bday_summary_template (customize summary)

View File

@@ -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]) 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): if all(type(d) is type(ref_date) for d in dates.value):
continue 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): for i, date in enumerate(dates.value):
dates.value[i] = ref_date.replace( dates.value[i] = ref_date.replace(
date.year, date.month, date.day) date.year, date.month, date.day)
@@ -568,8 +589,8 @@ class Item:
try: try:
self._text = self.vobject_item.serialize() self._text = self.vobject_item.serialize()
except Exception as e: except Exception as e:
raise RuntimeError("Failed to serialize item %r from %r: %s" % raise RuntimeError("Failed to serialize item %r with UID %r from %r: %s" %
(self.href, self._collection_path, (self.href, self.uid, self._collection_path,
e)) from e e)) from e
return self._text return self._text

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -151,6 +151,48 @@ permissions: RrWw""")
assert "Event" in answer assert "Event" in answer
assert "UID: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: def test_add_event_with_desc_ok(self) -> None:
"""Add an event.""" """Add an event."""
self.mkcalendar("/calendar.ics/") self.mkcalendar("/calendar.ics/")

View File

@@ -27,6 +27,8 @@ from importlib import import_module, metadata
from string import ascii_letters, digits, punctuation from string import ascii_letters, digits, punctuation
from typing import Callable, Sequence, Tuple, Type, TypeVar, Union from typing import Callable, Sequence, Tuple, Type, TypeVar, Union
import vobject
from radicale import config from radicale import config
from radicale.log import logger from radicale.log import logger
@@ -107,6 +109,43 @@ def vobject_supports_vcard4() -> bool:
return False 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(): def packages_version():
versions = [] versions = []
versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])) 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): def dataToHex(data, count):
result = '' result = ''
for item in range(count): for i in range(count):
if ((item > 0) and ((item % 8) == 0)): if ((i > 0) and ((i % 8) == 0)):
result += ' ' result += ' '
if (item < len(data)): if (i < len(data)):
result += '%02x' % data[item] + ' ' result += '%02x' % data[i] + ' '
else: else:
result += ' ' result += ' '
return result return result
@@ -385,9 +424,9 @@ def dataToHex(data, count):
def dataToAscii(data, count): def dataToAscii(data, count):
result = '' result = ''
for item in range(count): for i in range(count):
if (item < len(data)): if (i < len(data)):
char = chr(data[item]) char = chr(data[i])
if char in ascii_letters or \ if char in ascii_letters or \
char in digits or \ char in digits or \
char in punctuation or \ char in punctuation or \
@@ -400,9 +439,9 @@ def dataToAscii(data, count):
def dataToSpecial(data, count): def dataToSpecial(data, count):
result = '' result = ''
for item in range(count): for i in range(count):
if (item < len(data)): if (i < len(data)):
char = chr(data[item]) char = chr(data[i])
if char == '\r': if char == '\r':
result += 'C' result += 'C'
elif char == '\n': elif char == '\n':