Skip to content

Commit 146ca93

Browse files
authored
Add a DTSTAMP in events that are missing them.
Fixes #504 Test case partly made by Claude. I'd say I did it the right way, first asking Claude to make a (broken) test, adding some corner case to it, then implementing the feature and then debugging and fixing both the logic and test until it passed. Asking Claude to make the test code *after* the logic has been fixed sometimes causes bugs in the code to be mirrored in the test. This fix may not cover all possible corner cases, but I consider it good enough.
1 parent d045494 commit 146ca93

3 files changed

Lines changed: 102 additions & 20 deletions

File tree

caldav/compatibility_hints.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -874,11 +874,9 @@ def dotted_feature_set_list(self, compact=False):
874874
'search.time-range.alarm': False,
875875
'sync-token': 'fragile',
876876
'delete-calendar': False,
877-
'delete-calendar.free-namespace': False,
878877
'search.comp-type-optional': 'fragile',
879878
"search.recurrences.expanded.exception": False,
880-
'test-calendar': {'cleanup-regime': 'wipe-calendar'},
881-
'old_flags': ['vtodo_datesearch_nodtstart_task_is_skipped'],
879+
'old_flags': ['vtodo_datesearch_nodtstart_task_is_skipped'],
882880
}
883881

884882
baikal = { ## version 0.10.1
@@ -1036,12 +1034,6 @@ def dotted_feature_set_list(self, compact=False):
10361034
# 'isnotdefined_not_working',
10371035
#]
10381036

1039-
#synology = [
1040-
# "fragile_sync_tokens",
1041-
# "vtodo_datesearch_notime_task_is_skipped",
1042-
# "no_recurring_todo",
1043-
#]
1044-
10451037
robur = {
10461038
"auto-connect.url": {
10471039
'domain': 'calendar.robur.coop',

caldav/lib/vcal.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ def fix(event):
5858
and duration set - which is forbidden according to the RFC. We
5959
should probably verify that the data is consistent. As for now,
6060
we'll just drop DURATION or DTEND (whatever comes last).
61+
62+
6) On FOSSDEM I was presented with icalendar data missing the
63+
DTSTAMP field. This is mandatory according to the RFC.
64+
65+
All logic here is done with on the ical string, and not on
66+
icalendar objects. There are two reasons for it, originally
67+
optimization (not having to parse the icalendar data and create an
68+
object, if it's to be tossed away again shortly afterwards), but
69+
also because broken icalendar data may cause the instantiation of
70+
an icalendar object to break.
71+
72+
TODO: this should probably be moved out from the library.
6173
"""
6274
event = to_normal_str(event)
6375
if not event.endswith("\n"):
@@ -77,6 +89,17 @@ def fix(event):
7789
## 4) trailing whitespace probably never makes sense
7890
fixed = re.sub(" *$", "", fixed)
7991

92+
## 6) add DTSTAMP if not given
93+
## (corner case that DTSTAMP is given in one but not all the recurrences is ignored)
94+
if not "\nDTSTAMP:" in fixed:
95+
assert "\nEND" in fixed
96+
dtstamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime(
97+
"%Y%m%dT%H%M%SZ"
98+
)
99+
fixed = re.sub(
100+
"(\nEND:(VTODO|VEVENT|VJOURNAL))", f"\nDTSTAMP:{dtstamp}\\1", fixed
101+
)
102+
80103
## 3 fix duplicated DTSTAMP ... and ...
81104
## 5 prepare to remove DURATION or DTEND/DUE if both DURATION and
82105
## DTEND/DUE is set.

tests/test_vcal.py

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def normalize(s, ignore_uid):
5656
self.assertEqual(normalize(ical1, ignore_uid), normalize(ical2, ignore_uid))
5757
return ical2
5858

59-
def verifyICal(self, ical):
59+
def verifyICal(self, ical, allow_reordering=False):
6060
"""
6161
Does a best effort on verifying that the ical is correct, by
6262
pushing it through the vobject and icalendar library
@@ -155,16 +155,6 @@ def test_vcal_fixups(self):
155155
CATEGORIES:oslo
156156
END:VEVENT
157157
END:VCALENDAR
158-
""",
159-
## Next one contains a DTSTAMP before BEGIN:VEVENT
160-
## Doesn’t make sense, but valid, and more importantly,
161-
## not failing during the `fix` call.
162-
"""DTSTAMP:20210205T101751Z
163-
BEGIN:VEVENT
164-
UID:20200516T060000Z-123401@example.com
165-
SUMMARY:Do the needful
166-
DTSTART:20210517T060000Z
167-
END:VEVENT
168158
""",
169159
]
170160
broken_ical = [
@@ -295,3 +285,80 @@ def test_vcal_fixups(self):
295285

296286
for ical in non_broken_ical:
297287
assert vcal.fix(ical) == ical
288+
289+
def test_missing_dtstamp_fix(self) -> None:
290+
"""
291+
Test that missing DTSTAMP is added by the fix function.
292+
DTSTAMP is mandatory according to RFC5545 but doesn't cause
293+
vobject to fail, so it needs its own test (issue #504).
294+
"""
295+
# Event without DTSTAMP
296+
double_event_without_dtstamp = """BEGIN:VCALENDAR
297+
VERSION:2.0
298+
PRODID:-//FOSDEM//Example//EN
299+
BEGIN:VEVENT
300+
UID:missing-dtstamp-test@example.com
301+
DTSTART:20250101T100000Z
302+
DTEND:20250101T110000Z
303+
SUMMARY:Event without DTSTAMP
304+
RRULE:FREQ=YEARLY
305+
END:VEVENT
306+
BEGIN:VEVENT
307+
UID:missing-dtstamp-test@example.com
308+
DTSTART:20260101T100000Z
309+
DTEND:20260101T110000Z
310+
SUMMARY:Event without DateTimeSTAMP 2026
311+
RECURRENCE-ID:20260101T100000Z
312+
END:VEVENT
313+
END:VCALENDAR"""
314+
315+
# Todo without DTSTAMP
316+
todo_without_dtstamp = """BEGIN:VCALENDAR
317+
VERSION:2.0
318+
PRODID:-//FOSDEM//Example//EN
319+
BEGIN:VTODO
320+
UID:missing-dtstamp-todo@example.com
321+
SUMMARY:Todo without DTSTAMP
322+
DUE:20250101T120000Z
323+
END:VTODO
324+
END:VCALENDAR"""
325+
326+
# Journal without DTSTAMP
327+
journal_without_dtstamp = """BEGIN:VCALENDAR
328+
VERSION:2.0
329+
PRODID:-//FOSDEM//Example//EN
330+
BEGIN:VJOURNAL
331+
UID:missing-dtstamp-journal@example.com
332+
SUMMARY:Journal without DTSTAMP
333+
DTSTART:20250101T100000Z
334+
END:VJOURNAL
335+
END:VCALENDAR"""
336+
337+
# Test each component type
338+
for ical in [
339+
double_event_without_dtstamp,
340+
todo_without_dtstamp,
341+
journal_without_dtstamp,
342+
]:
343+
# Verify the original doesn't have DTSTAMP
344+
assert "DTSTAMP:" not in ical
345+
346+
# Apply the fix
347+
fixed = vcal.fix(ical)
348+
349+
# Verify DTSTAMP was added
350+
assert "DTSTAMP:" in fixed, f"DTSTAMP should be added to:\n{ical}"
351+
352+
# Verify it matches the expected format (YYYYMMDDTHHMMSSZ)
353+
dtstamp_match = re.search(r"DTSTAMP:(\d{8}T\d{6}Z)", fixed)
354+
assert (
355+
dtstamp_match is not None
356+
), f"DTSTAMP should be in correct format in:\n{fixed}"
357+
358+
if ical.count("BEGIN:VEVENT") == 2:
359+
assert fixed.count("DTSTAMP:") == 2
360+
else:
361+
assert fixed.count("DTSTAMP:") == 1
362+
363+
# Verify the fixed ical is valid
364+
self.verifyICal(fixed)

0 commit comments

Comments
 (0)