Skip to content

Commit b3a28e1

Browse files
tobixenclaude
andcommitted
fix: VTODO with only COMPLETED/CREATED
Doing time filtering on a VTODO with a COMPLETED, but no DTSTART, DUE, or CREATED, things would crash. This was discovered a bit ad-hoc while importing some slightly broken icalendar data into a calendar, and Claude had a go on fixing it. Also, I reread RFC4791 section 9.9 and had Claude reread it again, and now I think all the corner cases are covered according to the RFC. prompt: (work on some unrelated stuff and asking Claude to investigate the crash) (I rewrote Clauds work completely) prompt: tests are broken with my last changes, but please verify if the correct thing is asserted. As I understand the relevant RFC-section, a VTODO with CREATED set but no other time-related properties should match if and only if the search interval covers the CREATED timestamp. followup-prompt: and read through section 9.9 in the caldav RFC as well. (claude concluded my new code was not in accordance with the RFC, the pre-existing assert in the test code is in accordance with the RFC, and the code section was rewritten for the second time) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f2d935f commit b3a28e1

2 files changed

Lines changed: 52 additions & 17 deletions

File tree

src/icalendar_searcher/filters.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -85,38 +85,47 @@ def _check_range(
8585
## problem for full-day events is at least simplified.
8686

8787
elif comp_name == "VTODO":
88-
## There is a long matrix for VTODO in the RFC, and it
89-
## may seem complicated, but it isn't that bad:
88+
## There is a long matrix for VTODO in the RFC4701,
89+
## section 9.9, and it may seem complicated, but it isn't
90+
## that bad:
9091

9192
## * A task with DTSTART and DURATION is equivalent with a
9293
## task with DTSTART and DUE. This complexity is
9394
## already handled by the icalendar library, so all rows
94-
## in the matrix where VTODO has the DURATION property?"
95-
## is Y may be removed.
95+
## in the matrix where "VTODO has the DURATION property?"
96+
## is "Y" may be removed.
9697
##
97-
## * If either DUE or DTSTART is set, use it.
98-
if comp_end and not comp_start:
99-
comp_start = comp_end
100-
if comp_start and not comp_end:
101-
comp_end = comp_start
102-
103-
## * If both created/completed is set and
104-
## comp_start/comp_end is not set, then use those instead
105-
if not comp_start:
98+
## * The matrix says that if NEITHER DTSTART, DUE nor DURATION
99+
## is given, then CREATED and COMPLETED serve as the time bounds.
100+
## When both are present, treat them as a [CREATED, COMPLETED] range.
101+
## When only COMPLETED is present, treat it as a point event.
102+
## When only CREATED is present, the RFC condition is just (end > CREATED)
103+
## — the task is open-ended from CREATED onward, so comp_end = DATE_MAX_DT.
104+
if not comp_start and not comp_end:
106105
if "CREATED" in component:
107106
comp_start = _normalize_dt(component["CREATED"].dt)
108107
if "COMPLETED" in component:
109108
comp_end = _normalize_dt(component["COMPLETED"].dt)
109+
if comp_start and not comp_end:
110+
comp_end = _normalize_dt(DATE_MAX_DT)
111+
112+
## * If only COMPLETED is given (no DTSTART/DUE/CREATED), treat as a point event
113+
if comp_end and not comp_start:
114+
comp_start = comp_end
115+
## * If only DTSTART is given (no DUE/DURATION), treat as a zero-duration event
116+
if comp_start and not comp_end:
117+
comp_end = comp_start
110118

111-
## * A task may have a DUE before the DTSTART. The
112-
## complicated OR-logic in the table may be eliminated
119+
## * A task may have the end before the start. The
120+
## complicated OR-logic in the matrix may be eliminated
113121
## by swapping start/end if necessary:
114122
if comp_end and comp_start and comp_end < comp_start:
115123
tmp = comp_start
116124
comp_start = comp_end
117125
comp_end = tmp
118126

119-
## * A task with no timestamps is considered to be done "at any or all days".
127+
## * A task with no timestamps is considered to be done "at any or all days",
128+
## and should always be found when doing a date search:
120129
if not comp_end and not comp_start:
121130
comp_start = _normalize_dt(DATE_MIN_DT)
122131
comp_end = _normalize_dt(DATE_MAX_DT)
@@ -159,7 +168,9 @@ def _check_completed_filter(
159168
pre-resolved value from check_component() to avoid mutating self.
160169
:return: True if the component should be included, False if it should be filtered out
161170
"""
162-
include_completed = _include_completed if _include_completed is not None else self.include_completed
171+
include_completed = (
172+
_include_completed if _include_completed is not None else self.include_completed
173+
)
163174
if include_completed:
164175
return True
165176

tests/test_check_component.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,27 @@ def test_yule_tree2() -> None:
179179
searcher.add_property_filter(*prop_filter)
180180
assert not searcher._check_property_filters(cal.subcomponents[0])
181181
assert not searcher.check_component(cal)
182+
183+
184+
@pytest.mark.parametrize("rrule", [None, "RRULE:FREQ=DAILY\n"])
185+
def test_vtodo_no_dtstart_range_check(rrule: str | None) -> None:
186+
"""
187+
A VTODO with no DTSTART/DUE/CREATED must not crash on a time-range search,
188+
whether or not it has an RRULE.
189+
"""
190+
rrule_line = rrule or ""
191+
data = f"""BEGIN:VCALENDAR
192+
VERSION:2.0
193+
BEGIN:VTODO
194+
COMPLETED:20231001T105204Z
195+
DTSTAMP:20231001T124832Z
196+
{rrule_line}STATUS:COMPLETED
197+
SUMMARY:g\xe5 tur
198+
UID:d432d0a2-6058-11ee-97c8-982cbcdd642c
199+
END:VTODO
200+
END:VCALENDAR"""
201+
cal = Calendar.from_ical(data)
202+
end = datetime(2026, 5, 28, 0, 0, 0)
203+
searcher = Searcher(todo=True, end=end, include_completed=True)
204+
result = searcher.check_component(cal)
205+
assert bool(result)

0 commit comments

Comments
 (0)