calquery: don't drop events beyond the capped expansion window on open-ended time-range

A calendar-query REPORT whose <C:time-range> has a start but no end
(RFC 4791 section 9.9: an absent end means +infinity) dropped every event
whose next occurrence fell beyond time_range_start + 730 days: future
single events and sparse recurrences (e.g. FREQ=YEARLY;INTERVAL=4).
Clients such as DAVx5 send exactly this shape when they limit how far
into the past they sync, so those events silently disappeared.

The SQL prefilter is not at fault: for an open range it correctly emits
only the lower-bound condition. The drop happens in the post-fetch loop:
for any time-range query each resource is expanded into
[expand_range_start, expand_range_end] and skipped when the expansion is
empty (ComponentCount() == 0). For an open range expand_range_end is
capped at start + 730 days, so an event with no instance inside that cap
is discarded even though the preceding
getVCalendarRange()->overlaps($range_filter) test -- which treats a null
upper bound as +infinity -- already proved that it matches.

This became user-visible in 1.1.12: commit b40c96de ("If time-range is
set, only return matching events.") extended the empty-expansion skip,
previously reached only for explicit <C:expand> requests, to every
time-range query via isset($range_filter).

Only enforce the empty-expansion skip when the upper bound is real
(range_filter->until is set) or the client explicitly requested expansion
(need_expansion). For an open range the exact overlaps() test above stands
and the unexpanded master component is returned as normal. Bounded and
<C:expand> queries are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Michael Braun 2026-07-27 18:48:24 +02:00
parent e990c2edcb
commit bf0f92842c

View File

@ -456,7 +456,15 @@ if ( $qry->Exec("calquery",__LINE__,__FILE__) && $qry->rows() > 0 ) {
$expanded = expand_event_instances($vResource, $expand_range_start, $expand_range_end, $expand_as_floating , $dav_object->collection_tzid);
if ( $expanded->ComponentCount() == 0 ) continue;
# An empty expansion only proves a non-match when the expansion
# window equals the requested range. For an open-ended time-range
# ($range_filter->until is null) the window is artificially capped
# (expand_range_end defaults to time_range_start + 730 days), so a
# future or sparsely-recurring event whose next instance lies beyond
# the cap must NOT be dropped here -- the getVCalendarRange()->overlaps()
# test above is already exact for open ranges. Only enforce the
# empty-window skip for a real upper bound or an explicit expansion.
if ( $expanded->ComponentCount() == 0 && ( $need_expansion || isset($range_filter->until) ) ) continue;
# We only keep the expanded instaneces if need_expansion is set,
# RFC4791 Section 7.4. Otherwise we should return the original