Skip to content

Commit 156899b

Browse files
kesselbbackportbot[bot]
authored andcommitted
fix(caldav): Expand recurring events for principal calendar search
Assisted-by: ClaudeCode:claude-opus-5 Assisted-by: ClaudeCode:claude-sonnet-4-6 Assisted-by: ClaudeCode:claude-sonnet-5 Assisted-by: OpenCode:github-copilot/gpt-5.4 Signed-off-by: Daniel Kesselberg <mail@danielkesselberg.de>
1 parent 8698ea8 commit 156899b

7 files changed

Lines changed: 1178 additions & 93 deletions

File tree

apps/dav/lib/CalDAV/CalDavBackend.php

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

23882388
try {
2389+
// The time-range filter is hardcoded to VEVENT: Sabre only
2390+
// expands VEVENT recurrences (EventIterator is VEVENT-only and
2391+
// VTodo::isInTimeRange ignores RRULE), so other component types
2392+
// would not be filtered correctly here.
23892393
$isValid = $this->validateFilterForObject($row, [
23902394
'name' => 'VCALENDAR',
23912395
'comp-filters' => [
@@ -2489,13 +2493,24 @@ private function transformSearchProperty(Property $prop) {
24892493
}
24902494

24912495
/**
2496+
* Search calendar objects across a principal's calendars.
2497+
*
2498+
* This returns the stored calendar objects and does not expand recurring
2499+
* events. Callers that need the concrete occurrence for a requested time
2500+
* range must expand recurrences from `calendardata` themselves.
2501+
*
2502+
* Note: when a `timerange` option is given, the precise filtering assumes
2503+
* VEVENT components (see searchCalendarObjects()). Passing other component
2504+
* types together with a `timerange` would drop all results.
2505+
*
24922506
* @param string $principalUri
24932507
* @param string $pattern
24942508
* @param array $componentTypes
24952509
* @param array $searchProperties
24962510
* @param array $searchParameters
24972511
* @param array $options
2498-
* @return array
2512+
*
2513+
* @return list<array{uri: string, calendarid: int, calendartype: int, calendardata: string}>
24992514
*/
25002515
public function searchPrincipalUri(string $principalUri,
25012516
string $pattern,
@@ -2511,6 +2526,11 @@ public function searchPrincipalUri(string $principalUri,
25112526
$calendarOr = [];
25122527
$searchOr = [];
25132528

2529+
$start = null;
2530+
$end = null;
2531+
2532+
// Todo: The retries when $hasLimit && $hasTimeRange from https://github.com/nextcloud/server/pull/45222 should also be applied here to the calendarObjectIdQuery
2533+
25142534
// Fetch calendars and subscription
25152535
$calendars = $this->getCalendarsForUser($principalUri);
25162536
$subscriptions = $this->getSubscriptionsForUser($principalUri);
@@ -2589,19 +2609,21 @@ public function searchPrincipalUri(string $principalUri,
25892609
if (isset($options['offset'])) {
25902610
$calendarObjectIdQuery->setFirstResult($options['offset']);
25912611
}
2592-
if (isset($options['timerange'])) {
2593-
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
2594-
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt(
2595-
'lastoccurence',
2596-
$calendarObjectIdQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()),
2597-
));
2598-
}
2599-
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
2600-
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt(
2601-
'firstoccurence',
2602-
$calendarObjectIdQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()),
2603-
));
2604-
}
2612+
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
2613+
/** @var DateTimeInterface $start */
2614+
$start = $options['timerange']['start'];
2615+
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt(
2616+
'lastoccurence',
2617+
$calendarObjectIdQuery->createNamedParameter($start->getTimestamp()),
2618+
));
2619+
}
2620+
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
2621+
/** @var DateTimeInterface $end */
2622+
$end = $options['timerange']['end'];
2623+
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt(
2624+
'firstoccurence',
2625+
$calendarObjectIdQuery->createNamedParameter($end->getTimestamp()),
2626+
));
26052627
}
26062628

26072629
$result = $calendarObjectIdQuery->executeQuery();
@@ -2616,17 +2638,16 @@ public function searchPrincipalUri(string $principalUri,
26162638
->from('calendarobjects')
26172639
->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY)));
26182640

2619-
$result = $query->executeQuery();
2620-
$calendarObjects = [];
2621-
while (($array = $result->fetchAssociative()) !== false) {
2622-
$array['calendarid'] = (int)$array['calendarid'];
2623-
$array['calendartype'] = (int)$array['calendartype'];
2624-
$array['calendardata'] = $this->readBlob($array['calendardata']);
2641+
$calendarObjects = $this->searchCalendarObjects($query, $start, $end);
26252642

2626-
$calendarObjects[] = $array;
2627-
}
2628-
$result->closeCursor();
2629-
return $calendarObjects;
2643+
return array_values(array_map(function ($event) {
2644+
return [
2645+
'uri' => (string)$event['uri'],
2646+
'calendarid' => (int)$event['calendarid'],
2647+
'calendartype' => (int)$event['calendartype'],
2648+
'calendardata' => (string)$this->readBlob($event['calendardata']),
2649+
];
2650+
}, $calendarObjects));
26302651
}, $this->db);
26312652
}
26322653

apps/dav/lib/Search/ACalendarSearchProvider.php

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

1919
/**
2020
* Class ACalendarSearchProvider
@@ -75,34 +75,32 @@ protected function getSortedSubscriptions(string $principalUri): array {
7575

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

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

0 commit comments

Comments
 (0)