Skip to content

Commit 6a122f4

Browse files
committed
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 01e4068 commit 6a122f4

7 files changed

Lines changed: 1179 additions & 94 deletions

File tree

apps/dav/lib/CalDAV/CalDavBackend.php

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

23552355
try {
2356+
// The time-range filter is hardcoded to VEVENT: Sabre only
2357+
// expands VEVENT recurrences (EventIterator is VEVENT-only and
2358+
// VTodo::isInTimeRange ignores RRULE), so other component types
2359+
// would not be filtered correctly here.
23562360
$isValid = $this->validateFilterForObject($row, [
23572361
'name' => 'VCALENDAR',
23582362
'comp-filters' => [
@@ -2456,13 +2460,24 @@ private function transformSearchProperty(Property $prop) {
24562460
}
24572461

24582462
/**
2463+
* Search calendar objects across a principal's calendars.
2464+
*
2465+
* This returns the stored calendar objects and does not expand recurring
2466+
* events. Callers that need the concrete occurrence for a requested time
2467+
* range must expand recurrences from `calendardata` themselves.
2468+
*
2469+
* Note: when a `timerange` option is given, the precise filtering assumes
2470+
* VEVENT components (see searchCalendarObjects()). Passing other component
2471+
* types together with a `timerange` would drop all results.
2472+
*
24592473
* @param string $principalUri
24602474
* @param string $pattern
24612475
* @param array $componentTypes
24622476
* @param array $searchProperties
24632477
* @param array $searchParameters
24642478
* @param array $options
2465-
* @return array
2479+
*
2480+
* @return list<array{uri: string, calendarid: int, calendartype: int, calendardata: string}>
24662481
*/
24672482
public function searchPrincipalUri(string $principalUri,
24682483
string $pattern,
@@ -2478,6 +2493,11 @@ public function searchPrincipalUri(string $principalUri,
24782493
$calendarOr = [];
24792494
$searchOr = [];
24802495

2496+
$start = null;
2497+
$end = null;
2498+
2499+
// Todo: The retries when $hasLimit && $hasTimeRange from https://github.com/nextcloud/server/pull/45222 should also be applied here to the calendarObjectIdQuery
2500+
24812501
// Fetch calendars and subscription
24822502
$calendars = $this->getCalendarsForUser($principalUri);
24832503
$subscriptions = $this->getSubscriptionsForUser($principalUri);
@@ -2556,19 +2576,21 @@ public function searchPrincipalUri(string $principalUri,
25562576
if (isset($options['offset'])) {
25572577
$calendarObjectIdQuery->setFirstResult($options['offset']);
25582578
}
2559-
if (isset($options['timerange'])) {
2560-
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
2561-
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt(
2562-
'lastoccurence',
2563-
$calendarObjectIdQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()),
2564-
));
2565-
}
2566-
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
2567-
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt(
2568-
'firstoccurence',
2569-
$calendarObjectIdQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()),
2570-
));
2571-
}
2579+
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
2580+
/** @var DateTimeInterface $start */
2581+
$start = $options['timerange']['start'];
2582+
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt(
2583+
'lastoccurence',
2584+
$calendarObjectIdQuery->createNamedParameter($start->getTimestamp()),
2585+
));
2586+
}
2587+
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
2588+
/** @var DateTimeInterface $end */
2589+
$end = $options['timerange']['end'];
2590+
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt(
2591+
'firstoccurence',
2592+
$calendarObjectIdQuery->createNamedParameter($end->getTimestamp()),
2593+
));
25722594
}
25732595

25742596
$result = $calendarObjectIdQuery->executeQuery();
@@ -2583,17 +2605,16 @@ public function searchPrincipalUri(string $principalUri,
25832605
->from('calendarobjects')
25842606
->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY)));
25852607

2586-
$result = $query->executeQuery();
2587-
$calendarObjects = [];
2588-
while (($array = $result->fetchAssociative()) !== false) {
2589-
$array['calendarid'] = (int)$array['calendarid'];
2590-
$array['calendartype'] = (int)$array['calendartype'];
2591-
$array['calendardata'] = $this->readBlob($array['calendardata']);
2608+
$calendarObjects = $this->searchCalendarObjects($query, $start, $end);
25922609

2593-
$calendarObjects[] = $array;
2594-
}
2595-
$result->closeCursor();
2596-
return $calendarObjects;
2610+
return array_values(array_map(function ($event) {
2611+
return [
2612+
'uri' => (string)$event['uri'],
2613+
'calendarid' => (int)$event['calendarid'],
2614+
'calendartype' => (int)$event['calendartype'],
2615+
'calendardata' => (string)$this->readBlob($event['calendardata']),
2616+
];
2617+
}, $calendarObjects));
25972618
}, $this->db);
25982619
}
25992620

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)