Skip to content

Commit d66fe27

Browse files
committed
fix(caldav): Expand recurring events for principal calendar search
Assisted-by: Claude:claude-sonnet-4-6 Assisted-by: OpenCode:github-copilot/gpt-5.4 Signed-off-by: Daniel Kesselberg <mail@danielkesselberg.de>
1 parent 49cd308 commit d66fe27

7 files changed

Lines changed: 855 additions & 91 deletions

File tree

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2370,6 +2370,10 @@ private function searchCalendarObjects(IQueryBuilder $query, ?DateTimeInterface
23702370
}
23712371

23722372
try {
2373+
// The time-range filter is hardcoded to VEVENT: Sabre only
2374+
// expands VEVENT recurrences (EventIterator is VEVENT-only and
2375+
// VTodo::isInTimeRange ignores RRULE), so other component types
2376+
// would not be filtered correctly here.
23732377
$isValid = $this->validateFilterForObject($row, [
23742378
'name' => 'VCALENDAR',
23752379
'comp-filters' => [
@@ -2473,13 +2477,24 @@ private function transformSearchProperty(Property $prop) {
24732477
}
24742478

24752479
/**
2480+
* Search calendar objects across a principal's calendars.
2481+
*
2482+
* This returns the stored calendar objects and does not expand recurring
2483+
* events. Callers that need the concrete occurrence for a requested time
2484+
* range must expand recurrences from `calendardata` themselves.
2485+
*
2486+
* Note: when a `timerange` option is given, the precise filtering assumes
2487+
* VEVENT components (see searchCalendarObjects()). Passing other component
2488+
* types together with a `timerange` would drop all results.
2489+
*
24762490
* @param string $principalUri
24772491
* @param string $pattern
24782492
* @param array $componentTypes
24792493
* @param array $searchProperties
24802494
* @param array $searchParameters
24812495
* @param array $options
2482-
* @return array
2496+
*
2497+
* @return list<array{uri: string, calendarid: int, calendartype: int, calendardata: string}>
24832498
*/
24842499
public function searchPrincipalUri(string $principalUri,
24852500
string $pattern,
@@ -2495,6 +2510,11 @@ public function searchPrincipalUri(string $principalUri,
24952510
$calendarOr = [];
24962511
$searchOr = [];
24972512

2513+
$start = null;
2514+
$end = null;
2515+
2516+
// Todo: The retries when $hasLimit && $hasTimeRange from https://github.com/nextcloud/server/pull/45222 should also be applied here to the calendarObjectIdQuery
2517+
24982518
// Fetch calendars and subscription
24992519
$calendars = $this->getCalendarsForUser($principalUri);
25002520
$subscriptions = $this->getSubscriptionsForUser($principalUri);
@@ -2573,19 +2593,21 @@ public function searchPrincipalUri(string $principalUri,
25732593
if (isset($options['offset'])) {
25742594
$calendarObjectIdQuery->setFirstResult($options['offset']);
25752595
}
2576-
if (isset($options['timerange'])) {
2577-
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
2578-
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt(
2579-
'lastoccurence',
2580-
$calendarObjectIdQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()),
2581-
));
2582-
}
2583-
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
2584-
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt(
2585-
'firstoccurence',
2586-
$calendarObjectIdQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()),
2587-
));
2588-
}
2596+
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
2597+
/** @var DateTimeInterface $start */
2598+
$start = $options['timerange']['start'];
2599+
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt(
2600+
'lastoccurence',
2601+
$calendarObjectIdQuery->createNamedParameter($start->getTimestamp()),
2602+
));
2603+
}
2604+
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
2605+
/** @var DateTimeInterface $end */
2606+
$end = $options['timerange']['end'];
2607+
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt(
2608+
'firstoccurence',
2609+
$calendarObjectIdQuery->createNamedParameter($end->getTimestamp()),
2610+
));
25892611
}
25902612

25912613
$result = $calendarObjectIdQuery->executeQuery();
@@ -2600,17 +2622,16 @@ public function searchPrincipalUri(string $principalUri,
26002622
->from('calendarobjects')
26012623
->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY)));
26022624

2603-
$result = $query->executeQuery();
2604-
$calendarObjects = [];
2605-
while (($array = $result->fetchAssociative()) !== false) {
2606-
$array['calendarid'] = (int)$array['calendarid'];
2607-
$array['calendartype'] = (int)$array['calendartype'];
2608-
$array['calendardata'] = $this->readBlob($array['calendardata']);
2625+
$calendarObjects = $this->searchCalendarObjects($query, $start, $end);
26092626

2610-
$calendarObjects[] = $array;
2611-
}
2612-
$result->closeCursor();
2613-
return $calendarObjects;
2627+
return array_values(array_map(function ($event) {
2628+
return [
2629+
'uri' => (string)$event['uri'],
2630+
'calendarid' => (int)$event['calendarid'],
2631+
'calendartype' => (int)$event['calendartype'],
2632+
'calendardata' => (string)$this->readBlob($event['calendardata']),
2633+
];
2634+
}, $calendarObjects));
26142635
}, $this->db);
26152636
}
26162637

apps/dav/lib/Search/ACalendarSearchProvider.php

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use OCP\IURLGenerator;
1616
use OCP\Search\IProvider;
1717
use Sabre\VObject\Component;
18-
use Sabre\VObject\Reader;
18+
use Sabre\VObject\Component\VCalendar;
1919

2020
/**
2121
* Class ACalendarSearchProvider
@@ -76,34 +76,32 @@ protected function getSortedSubscriptions(string $principalUri): array {
7676

7777
/**
7878
* Returns the primary VEvent / VJournal / VTodo component
79+
*
7980
* If it's a component with recurrence-ids, it will return
8081
* the primary component
8182
*
8283
* TODO: It would be a nice enhancement to show recurrence-exceptions
8384
* as individual search-results.
85+
*
8486
* For now we will just display the primary element of a recurrence-set.
8587
*
86-
* @param string $calendarData
88+
* Returns null when the calendar has no component of the requested type.
89+
*
90+
* @param VCalendar $vCalendar
8791
* @param string $componentName
88-
* @return Component
92+
* @return Component|null
8993
*/
90-
protected function getPrimaryComponent(string $calendarData, string $componentName): Component {
91-
$vCalendar = Reader::read($calendarData, Reader::OPTION_FORGIVING);
92-
93-
$components = $vCalendar->select($componentName);
94-
if (count($components) === 1) {
95-
return $components[0];
96-
}
97-
98-
// If it's a recurrence-set, take the primary element
99-
foreach ($components as $component) {
94+
protected function getPrimaryComponent(VCalendar $vCalendar, string $componentName): ?Component {
95+
$first = null;
96+
foreach ($vCalendar->select($componentName) as $component) {
10097
/** @var Component $component */
98+
// Prefer the recurrence-set master (no RECURRENCE-ID); otherwise the first element.
99+
$first ??= $component;
101100
if (!$component->{'RECURRENCE-ID'}) {
102101
return $component;
103102
}
104103
}
105104

106-
// In case of error, just fallback to the first element in the set
107-
return $components[0];
105+
return $first;
108106
}
109107
}

apps/dav/lib/Search/EventsSearchProvider.php

Lines changed: 121 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,22 @@
99

1010
namespace OCA\DAV\Search;
1111

12+
use DateTimeImmutable;
1213
use OCA\DAV\CalDAV\CalDavBackend;
1314
use OCP\IUser;
1415
use OCP\Search\IFilteringProvider;
1516
use OCP\Search\ISearchQuery;
1617
use OCP\Search\SearchResult;
1718
use OCP\Search\SearchResultEntry;
1819
use Sabre\VObject\Component;
20+
use Sabre\VObject\Component\VCalendar;
1921
use Sabre\VObject\DateTimeParser;
22+
use Sabre\VObject\InvalidDataException;
2023
use Sabre\VObject\Property;
2124
use Sabre\VObject\Property\ICalendar\DateTime;
22-
use function array_combine;
23-
use function array_fill;
24-
use function array_key_exists;
25-
use function array_map;
25+
use Sabre\VObject\Reader;
26+
use function array_push;
27+
use function array_values;
2628

2729
/**
2830
* Class EventsSearchProvider
@@ -101,6 +103,20 @@ public function search(
101103

102104
/** @var string|null $term */
103105
$term = $query->getFilter('term')?->get();
106+
107+
$since = $query->getFilter('since')?->get();
108+
$until = $query->getFilter('until')?->get();
109+
110+
if ($since !== null && $until === null) {
111+
$until = new DateTimeImmutable('now', new \DateTimeZone('Z'));
112+
}
113+
114+
/** @var array{start: DateTimeImmutable|null, end: DateTimeImmutable|null} $timeRange */
115+
$timeRange = [
116+
'start' => $since,
117+
'end' => $until,
118+
];
119+
104120
if ($term === null) {
105121
$searchResults = [];
106122
} else {
@@ -113,18 +129,15 @@ public function search(
113129
[
114130
'limit' => $query->getLimit(),
115131
'offset' => $query->getCursor(),
116-
'timerange' => [
117-
'start' => $query->getFilter('since')?->get(),
118-
'end' => $query->getFilter('until')?->get(),
119-
],
132+
'timerange' => $timeRange,
120133
]
121134
);
122135
}
123136
/** @var IUser|null $person */
124137
$person = $query->getFilter('person')?->get();
125138
$personDisplayName = $person?->getDisplayName();
126139
if ($personDisplayName !== null) {
127-
$attendeeSearchResults = $this->backend->searchPrincipalUri(
140+
array_push($searchResults, ...$this->backend->searchPrincipalUri(
128141
$principalUri,
129142
$personDisplayName,
130143
[self::COMPONENT_TYPE],
@@ -133,55 +146,125 @@ public function search(
133146
[
134147
'limit' => $query->getLimit(),
135148
'offset' => $query->getCursor(),
136-
'timerange' => [
137-
'start' => $query->getFilter('since')?->get(),
138-
'end' => $query->getFilter('until')?->get(),
139-
],
149+
'timerange' => $timeRange,
140150
],
141-
);
151+
));
152+
}
142153

143-
$searchResultIndex = array_combine(
144-
array_map(fn ($event) => $event['calendarid'] . '-' . $event['uri'], $searchResults),
145-
array_fill(0, count($searchResults), null),
146-
);
147-
foreach ($attendeeSearchResults as $attendeeResult) {
148-
if (array_key_exists($attendeeResult['calendarid'] . '-' . $attendeeResult['uri'], $searchResultIndex)) {
149-
// Duplicate
150-
continue;
151-
}
152-
$searchResults[] = $attendeeResult;
154+
// Resolve each row to its in-range component (deduplicating events that
155+
// matched both the term and attendee searches, keyed by calendarid-uri, and
156+
// dropping anything that does not resolve to a usable in-range component) and
157+
// format it.
158+
$formattedResults = [];
159+
foreach ($searchResults as $searchResult) {
160+
$key = $searchResult['calendarid'] . '-' . $searchResult['uri'];
161+
if (isset($formattedResults[$key])) {
162+
continue;
163+
}
164+
$component = $this->resolveComponent($searchResult['calendardata'], $since, $until);
165+
if ($component === null) {
166+
continue;
153167
}
154-
}
155-
$formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById): SearchResultEntry {
156-
$component = $this->getPrimaryComponent($eventRow['calendardata'], self::COMPONENT_TYPE);
157-
$title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled event'));
158168

159-
if ($eventRow['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) {
160-
$calendar = $calendarsById[$eventRow['calendarid']];
169+
$title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled event'));
170+
if ($searchResult['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) {
171+
$calendar = $calendarsById[$searchResult['calendarid']];
161172
} else {
162-
$calendar = $subscriptionsById[$eventRow['calendarid']];
173+
$calendar = $subscriptionsById[$searchResult['calendarid']];
163174
}
164175
$subline = $this->generateSubline($component, $calendar);
165-
$resourceUrl = $this->getDeepLinkToCalendarApp($calendar['principaluri'], $calendar['uri'], $eventRow['uri']);
176+
$resourceUrl = $this->getDeepLinkToCalendarApp($calendar['principaluri'], $calendar['uri'], $searchResult['uri']);
166177
$result = new SearchResultEntry('', $title, $subline, $resourceUrl, 'icon-calendar-dark', false);
167178

168179
$dtStart = $component->DTSTART;
169-
170180
if ($dtStart instanceof DateTime) {
171-
$startDateTime = $dtStart->getDateTime()->format('U');
172-
$result->addAttribute('createdAt', $startDateTime);
181+
$result->addAttribute('createdAt', $dtStart->getDateTime()->format('U'));
173182
}
174183

175-
return $result;
176-
}, $searchResults);
184+
$formattedResults[$key] = $result;
185+
}
177186

178187
return SearchResult::paginated(
179188
$this->getName(),
180-
$formattedResults,
189+
array_values($formattedResults),
181190
$query->getCursor() + count($formattedResults)
182191
);
183192
}
184193

194+
/**
195+
* Resolve the component to display for a result row.
196+
*
197+
* Parses the calendar data and, when a time range is requested,
198+
* expands it to the in-range occurrence. Returns null to drop the row when the
199+
* data is not a calendar or has no occurrence within since and until.
200+
*/
201+
private function resolveComponent(string $calendarData, ?\DateTimeInterface $since, ?\DateTimeInterface $until): ?Component {
202+
$document = Reader::read($calendarData, Reader::OPTION_FORGIVING);
203+
if (!$document instanceof VCalendar) {
204+
return null;
205+
}
206+
207+
if ($since !== null && $until !== null) {
208+
$document = $this->expandInRange($document, $since, $until);
209+
if ($document === null) {
210+
return null;
211+
}
212+
}
213+
214+
return $this->getPrimaryComponent($document, self::COMPONENT_TYPE);
215+
}
216+
217+
/**
218+
* Expand a recurring event into its occurrences within the requested
219+
* [$since, $until] window, converted back from the UTC that expand() forces
220+
* into the event's original timezone.
221+
*
222+
* Returns null when the event has no occurrence in range (recurrence gap) or
223+
* cannot be expanded.
224+
*/
225+
private function expandInRange(VCalendar $vCalendar, \DateTimeInterface $since, \DateTimeInterface $until): ?VCalendar {
226+
// expand() rewrites every occurrence's DTSTART/DTEND to UTC, so remember
227+
// the event's original timezone to display the occurrence in local time.
228+
$originalTimeZone = null;
229+
$baseComponent = $vCalendar->getBaseComponent(self::COMPONENT_TYPE);
230+
if ($baseComponent !== null && isset($baseComponent->DTSTART) && $baseComponent->DTSTART->hasTime()) {
231+
$originalTimeZone = $baseComponent->DTSTART->getDateTime()->getTimezone();
232+
}
233+
234+
try {
235+
$expanded = $vCalendar->expand($since, $until);
236+
} catch (InvalidDataException $e) {
237+
return null;
238+
}
239+
240+
$occurrences = $expanded->select(self::COMPONENT_TYPE);
241+
if ($occurrences === []) {
242+
return null;
243+
}
244+
245+
if ($originalTimeZone !== null) {
246+
foreach ($occurrences as $occurrence) {
247+
$this->applyTimeZone($occurrence, $originalTimeZone);
248+
}
249+
}
250+
251+
return $expanded;
252+
}
253+
254+
/**
255+
* Move the occurrence back into the event's original timezone after expand()
256+
* has rewritten it to UTC, so the rendered time matches the user's local time.
257+
*/
258+
private function applyTimeZone(Component $component, \DateTimeZone $timeZone): void {
259+
foreach (['DTSTART', 'DTEND'] as $name) {
260+
if (isset($component->$name) && $component->$name->hasTime()) {
261+
$component->$name->setDateTime(
262+
$component->$name->getDateTime()->setTimezone($timeZone),
263+
);
264+
}
265+
}
266+
}
267+
185268
protected function getDeepLinkToCalendarApp(
186269
string $principalUri,
187270
string $calendarUri,

0 commit comments

Comments
 (0)