From 989945e069e3b4fcc133262a9b845a7e1c6eae28 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Fri, 17 Apr 2026 23:55:20 +0200 Subject: [PATCH 01/10] 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 --- apps/dav/lib/CalDAV/CalDavBackend.php | 58 +-- apps/dav/lib/Search/EventsSearchProvider.php | 25 +- .../tests/unit/CalDAV/CalDavBackendTest.php | 331 +++++++++++++++++- .../unit/Search/EventsSearchProviderTest.php | 137 ++++++++ 4 files changed, 512 insertions(+), 39 deletions(-) diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index a99e03042f582..887664c53b1ba 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -2495,6 +2495,11 @@ public function searchPrincipalUri(string $principalUri, $calendarOr = []; $searchOr = []; + $start = null; + $end = null; + + // Todo: The retries when $hasLimit && $hasTimeRange from https://github.com/nextcloud/server/pull/45222 should also be applied here to the calendarObjectIdQuery + // Fetch calendars and subscription $calendars = $this->getCalendarsForUser($principalUri); $subscriptions = $this->getSubscriptionsForUser($principalUri); @@ -2573,19 +2578,21 @@ public function searchPrincipalUri(string $principalUri, if (isset($options['offset'])) { $calendarObjectIdQuery->setFirstResult($options['offset']); } - if (isset($options['timerange'])) { - if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { - $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt( - 'lastoccurence', - $calendarObjectIdQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()), - )); - } - if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { - $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt( - 'firstoccurence', - $calendarObjectIdQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()), - )); - } + if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { + /** @var DateTimeInterface $start */ + $start = $options['timerange']['start']; + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt( + 'lastoccurence', + $calendarObjectIdQuery->createNamedParameter($start->getTimestamp()), + )); + } + if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { + /** @var DateTimeInterface $end */ + $end = $options['timerange']['end']; + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt( + 'firstoccurence', + $calendarObjectIdQuery->createNamedParameter($end->getTimestamp()), + )); } $result = $calendarObjectIdQuery->executeQuery(); @@ -2600,17 +2607,22 @@ public function searchPrincipalUri(string $principalUri, ->from('calendarobjects') ->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY))); - $result = $query->executeQuery(); - $calendarObjects = []; - while (($array = $result->fetchAssociative()) !== false) { - $array['calendarid'] = (int)$array['calendarid']; - $array['calendartype'] = (int)$array['calendartype']; - $array['calendardata'] = $this->readBlob($array['calendardata']); + $calendarObjects = $this->searchCalendarObjects($query, $start, $end); - $calendarObjects[] = $array; - } - $result->closeCursor(); - return $calendarObjects; + return array_map(function ($event) use ($start, $end) { + $calendarData = Reader::read($event['calendardata']); + + // Expand recurrences if an explicit time range is requested + if ($calendarData instanceof VCalendar && isset($start, $end)) { + $calendarData = $calendarData->expand($start, $end); + } + + $event['calendardata'] = $calendarData->serialize(); + $event['calendarid'] = (int)$event['calendarid']; + $event['calendartype'] = (int)$event['calendartype']; + + return $event; + }, $calendarObjects); }, $this->db); } diff --git a/apps/dav/lib/Search/EventsSearchProvider.php b/apps/dav/lib/Search/EventsSearchProvider.php index 92c32f3de8cb4..17aa7a957588e 100644 --- a/apps/dav/lib/Search/EventsSearchProvider.php +++ b/apps/dav/lib/Search/EventsSearchProvider.php @@ -9,6 +9,7 @@ namespace OCA\DAV\Search; +use DateTimeImmutable; use OCA\DAV\CalDAV\CalDavBackend; use OCP\IUser; use OCP\Search\IFilteringProvider; @@ -101,6 +102,20 @@ public function search( /** @var string|null $term */ $term = $query->getFilter('term')?->get(); + + $since = $query->getFilter('since')?->get(); + $until = $query->getFilter('until')?->get(); + + if ($since !== null && $until === null) { + $until = new DateTimeImmutable('now', new \DateTimeZone('Z')); + } + + /** @var array{start: DateTimeImmutable|null, end: DateTimeImmutable|null} $timeRange */ + $timeRange = [ + 'start' => $since, + 'end' => $until, + ]; + if ($term === null) { $searchResults = []; } else { @@ -113,10 +128,7 @@ public function search( [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), - 'timerange' => [ - 'start' => $query->getFilter('since')?->get(), - 'end' => $query->getFilter('until')?->get(), - ], + 'timerange' => $timeRange, ] ); } @@ -133,10 +145,7 @@ public function search( [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), - 'timerange' => [ - 'start' => $query->getFilter('since')?->get(), - 'end' => $query->getFilter('until')?->get(), - ], + 'timerange' => $timeRange, ], ); diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index e0f507fbe358d..5d94b166713bf 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -1336,14 +1336,14 @@ public function testSearchPrincipal(): void { $this->assertCount(4, $mySearchResults); $this->assertCount(3, $sharerSearchResults); - $this->assertEquals($myPublic, $mySearchResults[0]['calendardata']); - $this->assertEquals($myPrivate, $mySearchResults[1]['calendardata']); - $this->assertEquals($myConfidential, $mySearchResults[2]['calendardata']); - $this->assertEquals($sharerPublic, $mySearchResults[3]['calendardata']); - - $this->assertEquals($sharerPublic, $sharerSearchResults[0]['calendardata']); - $this->assertEquals($sharerPrivate, $sharerSearchResults[1]['calendardata']); - $this->assertEquals($sharerConfidential, $sharerSearchResults[2]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($myPublic, $mySearchResults[0]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($myPrivate, $mySearchResults[1]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($myConfidential, $mySearchResults[2]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($sharerPublic, $mySearchResults[3]['calendardata']); + + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($sharerPublic, $sharerSearchResults[0]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($sharerPrivate, $sharerSearchResults[1]['calendardata']); + $this->assertStringEqualsStringIgnoringLineEndingsWithTrim($sharerConfidential, $sharerSearchResults[2]['calendardata']); } /** @@ -1988,4 +1988,319 @@ public function testDefaultAlarmProperties(): void { // Clean up $this->backend->deleteCalendar($calendars[0]['id'], true); } + + public function testSearchPrincipalWithTimeRange(): void { + $myPublic = <<createMock(IL10N::class); + $l10n + ->expects($this->any()) + ->method('t') + ->willReturnCallback(function ($text, $parameters = []) { + return vsprintf($text, $parameters); + }); + $config = $this->createMock(IConfig::class); + $this->userManager->expects($this->any()) + ->method('userExists') + ->willReturn(true); + $this->groupManager->expects($this->any()) + ->method('groupExists') + ->willReturn(true); + $this->principal->expects(self::atLeastOnce()) + ->method('findByUri') + ->willReturn(self::UNIT_TEST_USER); + + $me = self::UNIT_TEST_USER; + $sharer = self::UNIT_TEST_USER1; + $this->backend->createCalendar($me, 'calendar-uri-me', []); + $this->backend->createCalendar($sharer, 'calendar-uri-sharer', []); + + $myCalendars = $this->backend->getCalendarsForUser($me); + $this->assertCount(1, $myCalendars); + + $sharerCalendars = $this->backend->getCalendarsForUser($sharer); + $this->assertCount(1, $sharerCalendars); + + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $sharerCalendar = new Calendar($this->backend, $sharerCalendars[0], $l10n, $config, $logger); + $this->backend->updateShares($sharerCalendar, [ + [ + 'href' => 'principal:' . $me, + 'readOnly' => false, + ], + ], []); + + $this->assertCount(2, $this->backend->getCalendarsForUser($me)); + + $this->backend->createCalendarObject($myCalendars[0]['id'], 'event0.ics', $myPublic); + $this->backend->createCalendarObject($myCalendars[0]['id'], 'event1.ics', $myPrivate); + $this->backend->createCalendarObject($myCalendars[0]['id'], 'event2.ics', $myConfidential); + + $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event3.ics', $sharerPublic); + $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event4.ics', $sharerPrivate); + $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event5.ics', $sharerConfidential); + + $mySearchResults = $this->backend->searchPrincipalUri( + $me, + 'Test', + ['VEVENT'], + ['SUMMARY'], + [], + [ + 'timerange' => [ + 'start' => new DateTimeImmutable('2013-10-27 11:00:00', new DateTimeZone('UTC')), + 'end' => new DateTimeImmutable('2013-10-27 14:00:00', new DateTimeZone('UTC')), + ], + ] + ); + $sharerSearchResults = $this->backend->searchPrincipalUri($sharer, 'Test', ['VEVENT'], ['SUMMARY'], []); + + $this->assertCount(4, $mySearchResults); + $this->assertCount(3, $sharerSearchResults); + + $this->assertStringContainsString('SUMMARY:My Test (public)', $mySearchResults[0]['calendardata']); + $this->assertStringContainsString('DTSTART:20131027T120000Z', $mySearchResults[0]['calendardata']); + + $this->assertStringContainsString('SUMMARY:My Test (private)', $mySearchResults[1]['calendardata']); + $this->assertStringContainsString('DTSTART:20131027T120000Z', $mySearchResults[1]['calendardata']); + + $this->assertStringContainsString('SUMMARY:My Test (confidential)', $mySearchResults[2]['calendardata']); + $this->assertStringContainsString('DTSTART:20131027T120000Z', $mySearchResults[2]['calendardata']); + + $this->assertStringContainsString('SUMMARY:Sharer Test (public)', $mySearchResults[3]['calendardata']); + $this->assertStringContainsString('DTSTART:20131027T120000Z', $mySearchResults[3]['calendardata']); + + // Results without a time range are not expanded: the RRULE is preserved. + $this->assertStringContainsString('SUMMARY:Sharer Test (public)', $sharerSearchResults[0]['calendardata']); + $this->assertStringContainsString('RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', $sharerSearchResults[0]['calendardata']); + + $this->assertStringContainsString('SUMMARY:Sharer Test (private)', $sharerSearchResults[1]['calendardata']); + $this->assertStringContainsString('SUMMARY:Sharer Test (confidential)', $sharerSearchResults[2]['calendardata']); + } + + private function assertStringEqualsStringIgnoringLineEndingsWithTrim(string $expected, string $actual, string $message = ''): void { + $this->assertStringEqualsStringIgnoringLineEndings(trim($expected), trim($actual), $message); + } } diff --git a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php index 561eb3e21b083..a8d1f9fe3ef03 100644 --- a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php @@ -9,6 +9,8 @@ namespace OCA\DAV\Tests\unit\Search; +use OC\Search\Filter\DateTimeFilter; +use OC\Search\Filter\StringFilter; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\Search\EventsSearchProvider; use OCP\App\IAppManager; @@ -469,4 +471,139 @@ public static function generateSublineDataProvider(): array { [self::$vEvent1, '08-16 09:00 - 10:00', ['{DAV:}displayname' => '']], ]; } + + public function testSearchSince(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('john.doe'); + $query = $this->createMock(ISearchQuery::class); + $query->method('getFilter')->willReturnCallback(function ($name) { + return match ($name) { + 'term' => new StringFilter('search term'), + 'since' => new DateTimeFilter('2026-05-20'), + 'until' => new DateTimeFilter('2026-06-20'), + default => null, + }; + }); + $query->method('getLimit')->willReturn(5); + $query->method('getCursor')->willReturn(20); + $this->appManager->expects($this->once()) + ->method('isEnabledForUser') + ->with('calendar', $user) + ->willReturn(true); + $this->l10n->method('t')->willReturnArgument(0); + + $this->backend->expects($this->once()) + ->method('getCalendarsForUser') + ->with('principals/users/john.doe') + ->willReturn([ + [ + 'id' => 99, + 'principaluri' => 'principals/users/john.doe', + 'uri' => 'calendar-uri-99', + ], [ + 'id' => 123, + 'principaluri' => 'principals/users/john.doe', + 'uri' => 'calendar-uri-123', + ] + ]); + $this->backend->expects($this->once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/john.doe') + ->willReturn([ + [ + 'id' => 1337, + 'principaluri' => 'principals/users/john.doe', + 'uri' => 'subscription-uri-1337', + ] + ]); + $this->backend->expects($this->once()) + ->method('searchPrincipalUri') + ->with('principals/users/john.doe', 'search term', ['VEVENT'], + ['SUMMARY', 'LOCATION', 'DESCRIPTION', 'ATTENDEE', 'ORGANIZER', 'CATEGORIES'], + ['ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN']], + ['limit' => 5, 'offset' => 20, 'timerange' => ['start' => new \DateTimeImmutable('2026-05-20 00:00:00'), 'end' => new \DateTimeImmutable('2026-06-20 00:00:00')]]) + ->willReturn([ + [ + 'calendarid' => 99, + 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, + 'uri' => 'event0.ics', + 'calendardata' => self::$vEvent0, + ], + [ + 'calendarid' => 123, + 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, + 'uri' => 'event1.ics', + 'calendardata' => self::$vEvent1, + ], + [ + 'calendarid' => 1337, + 'calendartype' => CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION, + 'uri' => 'event2.ics', + 'calendardata' => self::$vEvent2, + ] + ]); + + $provider = $this->getMockBuilder(EventsSearchProvider::class) + ->setConstructorArgs([ + $this->appManager, + $this->l10n, + $this->urlGenerator, + $this->backend, + ]) + ->onlyMethods([ + 'getDeepLinkToCalendarApp', + 'generateSubline', + ]) + ->getMock(); + + $provider->expects($this->exactly(3)) + ->method('generateSubline') + ->willReturn('subline'); + $provider->expects($this->exactly(3)) + ->method('getDeepLinkToCalendarApp') + ->willReturnMap([ + ['principals/users/john.doe', 'calendar-uri-99', 'event0.ics', 'deep-link-to-calendar'], + ['principals/users/john.doe', 'calendar-uri-123', 'event1.ics', 'deep-link-to-calendar'], + ['principals/users/john.doe', 'subscription-uri-1337', 'event2.ics', 'deep-link-to-calendar'] + ]); + + $actual = $provider->search($user, $query); + $data = $actual->jsonSerialize(); + $this->assertInstanceOf(SearchResult::class, $actual); + $this->assertEquals('Events', $data['name']); + $this->assertCount(3, $data['entries']); + $this->assertTrue($data['isPaginated']); + $this->assertEquals(23, $data['cursor']); + + $result0 = $data['entries'][0]; + $result0Data = $result0->jsonSerialize(); + $result1 = $data['entries'][1]; + $result1Data = $result1->jsonSerialize(); + $result2 = $data['entries'][2]; + $result2Data = $result2->jsonSerialize(); + + $this->assertInstanceOf(SearchResultEntry::class, $result0); + $this->assertEmpty($result0Data['thumbnailUrl']); + $this->assertEquals('Untitled event', $result0Data['title']); + $this->assertEquals('subline', $result0Data['subline']); + $this->assertEquals('deep-link-to-calendar', $result0Data['resourceUrl']); + $this->assertEquals('icon-calendar-dark', $result0Data['icon']); + $this->assertFalse($result0Data['rounded']); + + $this->assertInstanceOf(SearchResultEntry::class, $result1); + $this->assertEmpty($result1Data['thumbnailUrl']); + $this->assertEquals('Test Europe Berlin', $result1Data['title']); + $this->assertEquals('subline', $result1Data['subline']); + $this->assertEquals('deep-link-to-calendar', $result1Data['resourceUrl']); + $this->assertEquals('icon-calendar-dark', $result1Data['icon']); + $this->assertFalse($result1Data['rounded']); + + $this->assertInstanceOf(SearchResultEntry::class, $result2); + $this->assertEmpty($result2Data['thumbnailUrl']); + $this->assertEquals('Test Europe Berlin', $result2Data['title']); + $this->assertEquals('subline', $result2Data['subline']); + $this->assertEquals('deep-link-to-calendar', $result2Data['resourceUrl']); + $this->assertEquals('icon-calendar-dark', $result2Data['icon']); + $this->assertFalse($result2Data['rounded']); + } } From 7a4d3c25ad7fc2664717485bc2f23e3a757573de Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 9 Jun 2026 15:51:31 +0200 Subject: [PATCH 02/10] fixup! fix(caldav): Expand recurring events for principal calendar search --- apps/dav/lib/CalDAV/CalDavBackend.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 887664c53b1ba..9f5a0d3c54969 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -2473,12 +2473,15 @@ private function transformSearchProperty(Property $prop) { } /** + * Find events across calendars. + * * @param string $principalUri * @param string $pattern * @param array $componentTypes * @param array $searchProperties * @param array $searchParameters * @param array $options + * * @return array */ public function searchPrincipalUri(string $principalUri, @@ -2609,18 +2612,9 @@ public function searchPrincipalUri(string $principalUri, $calendarObjects = $this->searchCalendarObjects($query, $start, $end); - return array_map(function ($event) use ($start, $end) { - $calendarData = Reader::read($event['calendardata']); - - // Expand recurrences if an explicit time range is requested - if ($calendarData instanceof VCalendar && isset($start, $end)) { - $calendarData = $calendarData->expand($start, $end); - } - - $event['calendardata'] = $calendarData->serialize(); + return array_map(static function ($event) { $event['calendarid'] = (int)$event['calendarid']; $event['calendartype'] = (int)$event['calendartype']; - return $event; }, $calendarObjects); }, $this->db); From d2dc1f1c2e6f9af8f0c48cefca19855937722e4b Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 9 Jun 2026 16:44:10 +0200 Subject: [PATCH 03/10] fixup! fix(caldav): Expand recurring events for principal calendar search --- apps/dav/lib/CalDAV/CalDavBackend.php | 7 ++++++- apps/dav/lib/Search/ACalendarSearchProvider.php | 12 +++++++++++- apps/dav/lib/Search/EventsSearchProvider.php | 4 ++-- apps/dav/lib/Search/TasksSearchProvider.php | 2 +- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 9f5a0d3c54969..178509b69297a 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -2482,7 +2482,7 @@ private function transformSearchProperty(Property $prop) { * @param array $searchParameters * @param array $options * - * @return array + * @return list */ public function searchPrincipalUri(string $principalUri, string $pattern, @@ -2615,6 +2615,11 @@ public function searchPrincipalUri(string $principalUri, return array_map(static function ($event) { $event['calendarid'] = (int)$event['calendarid']; $event['calendartype'] = (int)$event['calendartype']; + + if (is_resource($event['calendardata'])) { + $event['calendardata'] = stream_get_contents($event['calendardata']); + } + return $event; }, $calendarObjects); }, $this->db); diff --git a/apps/dav/lib/Search/ACalendarSearchProvider.php b/apps/dav/lib/Search/ACalendarSearchProvider.php index 489b07aad57be..9e9b034a92941 100644 --- a/apps/dav/lib/Search/ACalendarSearchProvider.php +++ b/apps/dav/lib/Search/ACalendarSearchProvider.php @@ -15,6 +15,8 @@ use OCP\IURLGenerator; use OCP\Search\IProvider; use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\InvalidDataException; use Sabre\VObject\Reader; /** @@ -87,9 +89,17 @@ protected function getSortedSubscriptions(string $principalUri): array { * @param string $componentName * @return Component */ - protected function getPrimaryComponent(string $calendarData, string $componentName): Component { + protected function getPrimaryComponent(string $calendarData, string $componentName, \DateTimeInterface|null $since, \DateTimeInterface|null $until): Component { $vCalendar = Reader::read($calendarData, Reader::OPTION_FORGIVING); + if ($vCalendar instanceof VCalendar && isset($since, $until)) { + try { + $vCalendar = $vCalendar->expand($since, $until); + } catch (InvalidDataException $e) { + // fallback to the original event without expanding + } + } + $components = $vCalendar->select($componentName); if (count($components) === 1) { return $components[0]; diff --git a/apps/dav/lib/Search/EventsSearchProvider.php b/apps/dav/lib/Search/EventsSearchProvider.php index 17aa7a957588e..4543719e9767f 100644 --- a/apps/dav/lib/Search/EventsSearchProvider.php +++ b/apps/dav/lib/Search/EventsSearchProvider.php @@ -161,8 +161,8 @@ public function search( $searchResults[] = $attendeeResult; } } - $formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById): SearchResultEntry { - $component = $this->getPrimaryComponent($eventRow['calendardata'], self::COMPONENT_TYPE); + $formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById, $since, $until): SearchResultEntry { + $component = $this->getPrimaryComponent($eventRow['calendardata'], self::COMPONENT_TYPE, $since, $until); $title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled event')); if ($eventRow['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) { diff --git a/apps/dav/lib/Search/TasksSearchProvider.php b/apps/dav/lib/Search/TasksSearchProvider.php index 622e564d1364e..2a9d69b2d95f8 100644 --- a/apps/dav/lib/Search/TasksSearchProvider.php +++ b/apps/dav/lib/Search/TasksSearchProvider.php @@ -99,7 +99,7 @@ public function search( ] ); $formattedResults = \array_map(function (array $taskRow) use ($calendarsById, $subscriptionsById):SearchResultEntry { - $component = $this->getPrimaryComponent($taskRow['calendardata'], self::COMPONENT_TYPE); + $component = $this->getPrimaryComponent($taskRow['calendardata'], self::COMPONENT_TYPE, null, null); $title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled task')); if ($taskRow['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) { From bcd46a783e7d2b6e07b85f93fc022965cc81a1a7 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 9 Jun 2026 21:27:46 +0200 Subject: [PATCH 04/10] fixup! fix(caldav): Expand recurring events for principal calendar search --- .../unit/Search/EventsSearchProviderTest.php | 153 +++++++++++++++++- .../unit/Search/TasksSearchProviderTest.php | 10 +- 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php index a8d1f9fe3ef03..784e04f97a67c 100644 --- a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php @@ -213,6 +213,64 @@ class EventsSearchProviderTest extends TestCase { . 'END:VEVENT' . PHP_EOL . 'END:VCALENDAR'; + private static string $vEventSince0 = 'BEGIN:VCALENDAR' . PHP_EOL + . 'VERSION:2.0' . PHP_EOL + . 'PRODID:-//Tests//' . PHP_EOL + . 'CALSCALE:GREGORIAN' . PHP_EOL + . 'BEGIN:VEVENT' . PHP_EOL + . 'UID:event-since-0@example.com' . PHP_EOL + . 'DTEND;VALUE=DATE:20260522' . PHP_EOL + . 'TRANSP:TRANSPARENT' . PHP_EOL + . 'DTSTART;VALUE=DATE:20260521' . PHP_EOL + . 'DTSTAMP:20260501T080000Z' . PHP_EOL + . 'SEQUENCE:0' . PHP_EOL + . 'END:VEVENT' . PHP_EOL + . 'END:VCALENDAR'; + + private static string $vEventSince1 = 'BEGIN:VCALENDAR' . PHP_EOL + . 'VERSION:2.0' . PHP_EOL + . 'PRODID:-//Tests//' . PHP_EOL + . 'CALSCALE:GREGORIAN' . PHP_EOL + . 'BEGIN:VEVENT' . PHP_EOL + . 'UID:event-since-1@example.com' . PHP_EOL + . 'DTEND:20260522T100000Z' . PHP_EOL + . 'TRANSP:OPAQUE' . PHP_EOL + . 'SUMMARY:Test Europe Berlin' . PHP_EOL + . 'DTSTART:20260522T090000Z' . PHP_EOL + . 'DTSTAMP:20260501T080000Z' . PHP_EOL + . 'SEQUENCE:0' . PHP_EOL + . 'END:VEVENT' . PHP_EOL + . 'END:VCALENDAR'; + + private static string $vEventSince2 = 'BEGIN:VCALENDAR' . PHP_EOL + . 'VERSION:2.0' . PHP_EOL + . 'PRODID:-//Tests//' . PHP_EOL + . 'CALSCALE:GREGORIAN' . PHP_EOL + . 'BEGIN:VEVENT' . PHP_EOL + . 'UID:event-since-2@example.com' . PHP_EOL + . 'DTEND:20260524T100000Z' . PHP_EOL + . 'TRANSP:OPAQUE' . PHP_EOL + . 'SUMMARY:Test Europe Berlin' . PHP_EOL + . 'DTSTART:20260523T090000Z' . PHP_EOL + . 'DTSTAMP:20260501T080000Z' . PHP_EOL + . 'SEQUENCE:0' . PHP_EOL + . 'END:VEVENT' . PHP_EOL + . 'END:VCALENDAR'; + + private static string $vRecurringEvent = 'BEGIN:VCALENDAR' . PHP_EOL + . 'VERSION:2.0' . PHP_EOL + . 'PRODID:-//Tests//' . PHP_EOL + . 'CALSCALE:GREGORIAN' . PHP_EOL + . 'BEGIN:VEVENT' . PHP_EOL + . 'UID:recurring-test@example.com' . PHP_EOL + . 'DTSTAMP:20260501T080000Z' . PHP_EOL + . 'DTSTART:20260501T090000Z' . PHP_EOL + . 'DTEND:20260501T100000Z' . PHP_EOL + . 'RRULE:FREQ=WEEKLY;COUNT=5' . PHP_EOL + . 'SUMMARY:Recurring event' . PHP_EOL + . 'END:VEVENT' . PHP_EOL + . 'END:VCALENDAR'; + protected function setUp(): void { parent::setUp(); @@ -323,19 +381,19 @@ public function testSearch(): void { 'calendarid' => 99, 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, 'uri' => 'event0.ics', - 'calendardata' => self::$vEvent0, + 'calendardata' => self::$vEventSince0, ], [ 'calendarid' => 123, 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, 'uri' => 'event1.ics', - 'calendardata' => self::$vEvent1, + 'calendardata' => self::$vEventSince1, ], [ 'calendarid' => 1337, 'calendartype' => CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION, 'uri' => 'event2.ics', - 'calendardata' => self::$vEvent2, + 'calendardata' => self::$vEventSince2, ] ]); @@ -606,4 +664,93 @@ public function testSearchSince(): void { $this->assertEquals('icon-calendar-dark', $result2Data['icon']); $this->assertFalse($result2Data['rounded']); } + + public function testSearchExpandsRecurringEventForRequestedTimeRange(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('john.doe'); + $query = $this->createMock(ISearchQuery::class); + $query->method('getFilter')->willReturnCallback(function ($name) { + return match ($name) { + 'term' => new StringFilter('search term'), + 'since' => new DateTimeFilter('2026-05-20'), + 'until' => new DateTimeFilter('2026-05-23'), + default => null, + }; + }); + $query->method('getLimit')->willReturn(5); + $query->method('getCursor')->willReturn(20); + + $this->appManager->expects($this->once()) + ->method('isEnabledForUser') + ->with('calendar', $user) + ->willReturn(true); + $this->l10n->method('t')->willReturnArgument(0); + $this->l10n->method('l') + ->willReturnCallback(static function (string $type, \DateTime $date, $_): string { + if ($type === 'time') { + return $date->format('H:i'); + } + + return $date->format('m-d'); + }); + + $this->backend->expects($this->once()) + ->method('getCalendarsForUser') + ->with('principals/users/john.doe') + ->willReturn([ + [ + 'id' => 99, + 'principaluri' => 'principals/users/john.doe', + 'uri' => 'calendar-uri-99', + '{DAV:}displayname' => 'My Calendar', + ], + ]); + $this->backend->expects($this->once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/john.doe') + ->willReturn([]); + $this->backend->expects($this->once()) + ->method('searchPrincipalUri') + ->with( + 'principals/users/john.doe', + 'search term', + ['VEVENT'], + ['SUMMARY', 'LOCATION', 'DESCRIPTION', 'ATTENDEE', 'ORGANIZER', 'CATEGORIES'], + ['ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN']], + ['limit' => 5, 'offset' => 20, 'timerange' => ['start' => new \DateTimeImmutable('2026-05-20 00:00:00'), 'end' => new \DateTimeImmutable('2026-05-23 00:00:00')]], + ) + ->willReturn([ + [ + 'calendarid' => 99, + 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, + 'uri' => 'recurring-event.ics', + 'calendardata' => self::$vRecurringEvent, + ], + ]); + + $this->urlGenerator->expects($this->once()) + ->method('linkTo') + ->with('', 'remote.php') + ->willReturn('link-to-remote.php'); + $this->urlGenerator->expects($this->once()) + ->method('linkToRoute') + ->with('calendar.view.index') + ->willReturn('link-to-route-calendar/'); + $this->urlGenerator->expects($this->once()) + ->method('getAbsoluteURL') + ->with('link-to-route-calendar/edit/bGluay10by1yZW1vdGUucGhwL2Rhdi9jYWxlbmRhcnMvam9obi5kb2UvY2FsZW5kYXItdXJpLTk5L3JlY3VycmluZy1ldmVudC5pY3M=') + ->willReturn('deep-link-to-calendar'); + + $actual = $this->provider->search($user, $query); + $data = $actual->jsonSerialize(); + + $this->assertCount(1, $data['entries']); + $result = $data['entries'][0]; + $resultData = $result->jsonSerialize(); + + $this->assertSame('Recurring event', $resultData['title']); + $this->assertSame('05-22 09:00 - 10:00 (My Calendar)', $resultData['subline']); + $this->assertSame('deep-link-to-calendar', $resultData['resourceUrl']); + $this->assertSame('1779440400', $resultData['attributes']['createdAt']); + } } diff --git a/apps/dav/tests/unit/Search/TasksSearchProviderTest.php b/apps/dav/tests/unit/Search/TasksSearchProviderTest.php index 1424f8c4dbb7c..c76d6f84b175f 100644 --- a/apps/dav/tests/unit/Search/TasksSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/TasksSearchProviderTest.php @@ -9,6 +9,7 @@ namespace OCA\DAV\Tests\unit\Search; +use OC\Search\Filter\StringFilter; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\Search\TasksSearchProvider; use OCP\App\IAppManager; @@ -152,7 +153,12 @@ public function testSearch(): void { $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('john.doe'); $query = $this->createMock(ISearchQuery::class); - $query->method('getTerm')->willReturn('search term'); + $query->method('getFilter')->willReturnCallback(static function (string $name) { + return match ($name) { + 'term' => new StringFilter('search term'), + default => null, + }; + }); $query->method('getLimit')->willReturn(5); $query->method('getCursor')->willReturn(20); $this->appManager->expects($this->once()) @@ -187,7 +193,7 @@ public function testSearch(): void { ]); $this->backend->expects($this->once()) ->method('searchPrincipalUri') - ->with('principals/users/john.doe', '', ['VTODO'], + ->with('principals/users/john.doe', 'search term', ['VTODO'], ['SUMMARY', 'DESCRIPTION', 'CATEGORIES'], [], ['limit' => 5, 'offset' => 20, 'since' => null, 'until' => null]) From a54621c5ec9a84dc07102951c3a2f0a8f58340b8 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 9 Jun 2026 22:32:59 +0200 Subject: [PATCH 05/10] fixup! fix(caldav): Expand recurring events for principal calendar search --- .../unit/Search/EventsSearchProviderTest.php | 240 +++--------------- 1 file changed, 32 insertions(+), 208 deletions(-) diff --git a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php index 784e04f97a67c..635f10f528a44 100644 --- a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php @@ -213,61 +213,17 @@ class EventsSearchProviderTest extends TestCase { . 'END:VEVENT' . PHP_EOL . 'END:VCALENDAR'; - private static string $vEventSince0 = 'BEGIN:VCALENDAR' . PHP_EOL - . 'VERSION:2.0' . PHP_EOL - . 'PRODID:-//Tests//' . PHP_EOL - . 'CALSCALE:GREGORIAN' . PHP_EOL - . 'BEGIN:VEVENT' . PHP_EOL - . 'UID:event-since-0@example.com' . PHP_EOL - . 'DTEND;VALUE=DATE:20260522' . PHP_EOL - . 'TRANSP:TRANSPARENT' . PHP_EOL - . 'DTSTART;VALUE=DATE:20260521' . PHP_EOL - . 'DTSTAMP:20260501T080000Z' . PHP_EOL - . 'SEQUENCE:0' . PHP_EOL - . 'END:VEVENT' . PHP_EOL - . 'END:VCALENDAR'; - - private static string $vEventSince1 = 'BEGIN:VCALENDAR' . PHP_EOL - . 'VERSION:2.0' . PHP_EOL - . 'PRODID:-//Tests//' . PHP_EOL - . 'CALSCALE:GREGORIAN' . PHP_EOL - . 'BEGIN:VEVENT' . PHP_EOL - . 'UID:event-since-1@example.com' . PHP_EOL - . 'DTEND:20260522T100000Z' . PHP_EOL - . 'TRANSP:OPAQUE' . PHP_EOL - . 'SUMMARY:Test Europe Berlin' . PHP_EOL - . 'DTSTART:20260522T090000Z' . PHP_EOL - . 'DTSTAMP:20260501T080000Z' . PHP_EOL - . 'SEQUENCE:0' . PHP_EOL - . 'END:VEVENT' . PHP_EOL - . 'END:VCALENDAR'; - - private static string $vEventSince2 = 'BEGIN:VCALENDAR' . PHP_EOL - . 'VERSION:2.0' . PHP_EOL - . 'PRODID:-//Tests//' . PHP_EOL - . 'CALSCALE:GREGORIAN' . PHP_EOL - . 'BEGIN:VEVENT' . PHP_EOL - . 'UID:event-since-2@example.com' . PHP_EOL - . 'DTEND:20260524T100000Z' . PHP_EOL - . 'TRANSP:OPAQUE' . PHP_EOL - . 'SUMMARY:Test Europe Berlin' . PHP_EOL - . 'DTSTART:20260523T090000Z' . PHP_EOL - . 'DTSTAMP:20260501T080000Z' . PHP_EOL - . 'SEQUENCE:0' . PHP_EOL - . 'END:VEVENT' . PHP_EOL - . 'END:VCALENDAR'; - private static string $vRecurringEvent = 'BEGIN:VCALENDAR' . PHP_EOL . 'VERSION:2.0' . PHP_EOL . 'PRODID:-//Tests//' . PHP_EOL . 'CALSCALE:GREGORIAN' . PHP_EOL . 'BEGIN:VEVENT' . PHP_EOL - . 'UID:recurring-test@example.com' . PHP_EOL - . 'DTSTAMP:20260501T080000Z' . PHP_EOL - . 'DTSTART:20260501T090000Z' . PHP_EOL - . 'DTEND:20260501T100000Z' . PHP_EOL - . 'RRULE:FREQ=WEEKLY;COUNT=5' . PHP_EOL - . 'SUMMARY:Recurring event' . PHP_EOL + . 'UID:recurring-yearly@example.com' . PHP_EOL + . 'DTSTAMP:20240601T080000Z' . PHP_EOL + . 'DTSTART:20240601T090000Z' . PHP_EOL + . 'DTEND:20240601T100000Z' . PHP_EOL + . 'RRULE:FREQ=YEARLY' . PHP_EOL + . 'SUMMARY:Recurring yearly event' . PHP_EOL . 'END:VEVENT' . PHP_EOL . 'END:VCALENDAR'; @@ -381,19 +337,19 @@ public function testSearch(): void { 'calendarid' => 99, 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, 'uri' => 'event0.ics', - 'calendardata' => self::$vEventSince0, + 'calendardata' => self::$vEvent0, ], [ 'calendarid' => 123, 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, 'uri' => 'event1.ics', - 'calendardata' => self::$vEventSince1, + 'calendardata' => self::$vEvent1, ], [ 'calendarid' => 1337, 'calendartype' => CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION, 'uri' => 'event2.ics', - 'calendardata' => self::$vEventSince2, + 'calendardata' => self::$vEvent2, ] ]); @@ -537,149 +493,13 @@ public function testSearchSince(): void { $query->method('getFilter')->willReturnCallback(function ($name) { return match ($name) { 'term' => new StringFilter('search term'), - 'since' => new DateTimeFilter('2026-05-20'), - 'until' => new DateTimeFilter('2026-06-20'), + 'since' => new DateTimeFilter('2026-01-01'), + 'until' => new DateTimeFilter('2026-12-31'), default => null, }; }); $query->method('getLimit')->willReturn(5); $query->method('getCursor')->willReturn(20); - $this->appManager->expects($this->once()) - ->method('isEnabledForUser') - ->with('calendar', $user) - ->willReturn(true); - $this->l10n->method('t')->willReturnArgument(0); - - $this->backend->expects($this->once()) - ->method('getCalendarsForUser') - ->with('principals/users/john.doe') - ->willReturn([ - [ - 'id' => 99, - 'principaluri' => 'principals/users/john.doe', - 'uri' => 'calendar-uri-99', - ], [ - 'id' => 123, - 'principaluri' => 'principals/users/john.doe', - 'uri' => 'calendar-uri-123', - ] - ]); - $this->backend->expects($this->once()) - ->method('getSubscriptionsForUser') - ->with('principals/users/john.doe') - ->willReturn([ - [ - 'id' => 1337, - 'principaluri' => 'principals/users/john.doe', - 'uri' => 'subscription-uri-1337', - ] - ]); - $this->backend->expects($this->once()) - ->method('searchPrincipalUri') - ->with('principals/users/john.doe', 'search term', ['VEVENT'], - ['SUMMARY', 'LOCATION', 'DESCRIPTION', 'ATTENDEE', 'ORGANIZER', 'CATEGORIES'], - ['ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN']], - ['limit' => 5, 'offset' => 20, 'timerange' => ['start' => new \DateTimeImmutable('2026-05-20 00:00:00'), 'end' => new \DateTimeImmutable('2026-06-20 00:00:00')]]) - ->willReturn([ - [ - 'calendarid' => 99, - 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, - 'uri' => 'event0.ics', - 'calendardata' => self::$vEvent0, - ], - [ - 'calendarid' => 123, - 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, - 'uri' => 'event1.ics', - 'calendardata' => self::$vEvent1, - ], - [ - 'calendarid' => 1337, - 'calendartype' => CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION, - 'uri' => 'event2.ics', - 'calendardata' => self::$vEvent2, - ] - ]); - - $provider = $this->getMockBuilder(EventsSearchProvider::class) - ->setConstructorArgs([ - $this->appManager, - $this->l10n, - $this->urlGenerator, - $this->backend, - ]) - ->onlyMethods([ - 'getDeepLinkToCalendarApp', - 'generateSubline', - ]) - ->getMock(); - - $provider->expects($this->exactly(3)) - ->method('generateSubline') - ->willReturn('subline'); - $provider->expects($this->exactly(3)) - ->method('getDeepLinkToCalendarApp') - ->willReturnMap([ - ['principals/users/john.doe', 'calendar-uri-99', 'event0.ics', 'deep-link-to-calendar'], - ['principals/users/john.doe', 'calendar-uri-123', 'event1.ics', 'deep-link-to-calendar'], - ['principals/users/john.doe', 'subscription-uri-1337', 'event2.ics', 'deep-link-to-calendar'] - ]); - - $actual = $provider->search($user, $query); - $data = $actual->jsonSerialize(); - $this->assertInstanceOf(SearchResult::class, $actual); - $this->assertEquals('Events', $data['name']); - $this->assertCount(3, $data['entries']); - $this->assertTrue($data['isPaginated']); - $this->assertEquals(23, $data['cursor']); - - $result0 = $data['entries'][0]; - $result0Data = $result0->jsonSerialize(); - $result1 = $data['entries'][1]; - $result1Data = $result1->jsonSerialize(); - $result2 = $data['entries'][2]; - $result2Data = $result2->jsonSerialize(); - - $this->assertInstanceOf(SearchResultEntry::class, $result0); - $this->assertEmpty($result0Data['thumbnailUrl']); - $this->assertEquals('Untitled event', $result0Data['title']); - $this->assertEquals('subline', $result0Data['subline']); - $this->assertEquals('deep-link-to-calendar', $result0Data['resourceUrl']); - $this->assertEquals('icon-calendar-dark', $result0Data['icon']); - $this->assertFalse($result0Data['rounded']); - - $this->assertInstanceOf(SearchResultEntry::class, $result1); - $this->assertEmpty($result1Data['thumbnailUrl']); - $this->assertEquals('Test Europe Berlin', $result1Data['title']); - $this->assertEquals('subline', $result1Data['subline']); - $this->assertEquals('deep-link-to-calendar', $result1Data['resourceUrl']); - $this->assertEquals('icon-calendar-dark', $result1Data['icon']); - $this->assertFalse($result1Data['rounded']); - - $this->assertInstanceOf(SearchResultEntry::class, $result2); - $this->assertEmpty($result2Data['thumbnailUrl']); - $this->assertEquals('Test Europe Berlin', $result2Data['title']); - $this->assertEquals('subline', $result2Data['subline']); - $this->assertEquals('deep-link-to-calendar', $result2Data['resourceUrl']); - $this->assertEquals('icon-calendar-dark', $result2Data['icon']); - $this->assertFalse($result2Data['rounded']); - } - - public function testSearchExpandsRecurringEventForRequestedTimeRange(): void { - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('john.doe'); - $query = $this->createMock(ISearchQuery::class); - $query->method('getFilter')->willReturnCallback(function ($name) { - return match ($name) { - 'term' => new StringFilter('search term'), - 'since' => new DateTimeFilter('2026-05-20'), - 'until' => new DateTimeFilter('2026-05-23'), - default => null, - }; - }); - $query->method('getLimit')->willReturn(5); - $query->method('getCursor')->willReturn(20); - $this->appManager->expects($this->once()) ->method('isEnabledForUser') ->with('calendar', $user) @@ -703,7 +523,7 @@ public function testSearchExpandsRecurringEventForRequestedTimeRange(): void { 'principaluri' => 'principals/users/john.doe', 'uri' => 'calendar-uri-99', '{DAV:}displayname' => 'My Calendar', - ], + ] ]); $this->backend->expects($this->once()) ->method('getSubscriptionsForUser') @@ -711,21 +531,17 @@ public function testSearchExpandsRecurringEventForRequestedTimeRange(): void { ->willReturn([]); $this->backend->expects($this->once()) ->method('searchPrincipalUri') - ->with( - 'principals/users/john.doe', - 'search term', - ['VEVENT'], + ->with('principals/users/john.doe', 'search term', ['VEVENT'], ['SUMMARY', 'LOCATION', 'DESCRIPTION', 'ATTENDEE', 'ORGANIZER', 'CATEGORIES'], ['ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN']], - ['limit' => 5, 'offset' => 20, 'timerange' => ['start' => new \DateTimeImmutable('2026-05-20 00:00:00'), 'end' => new \DateTimeImmutable('2026-05-23 00:00:00')]], - ) + ['limit' => 5, 'offset' => 20, 'timerange' => ['start' => new \DateTimeImmutable('2026-01-01 00:00:00'), 'end' => new \DateTimeImmutable('2026-12-31 00:00:00')]]) ->willReturn([ [ 'calendarid' => 99, 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, - 'uri' => 'recurring-event.ics', + 'uri' => 'recurring-yearly-event.ics', 'calendardata' => self::$vRecurringEvent, - ], + ] ]); $this->urlGenerator->expects($this->once()) @@ -738,19 +554,27 @@ public function testSearchExpandsRecurringEventForRequestedTimeRange(): void { ->willReturn('link-to-route-calendar/'); $this->urlGenerator->expects($this->once()) ->method('getAbsoluteURL') - ->with('link-to-route-calendar/edit/bGluay10by1yZW1vdGUucGhwL2Rhdi9jYWxlbmRhcnMvam9obi5kb2UvY2FsZW5kYXItdXJpLTk5L3JlY3VycmluZy1ldmVudC5pY3M=') + ->with('link-to-route-calendar/edit/bGluay10by1yZW1vdGUucGhwL2Rhdi9jYWxlbmRhcnMvam9obi5kb2UvY2FsZW5kYXItdXJpLTk5L3JlY3VycmluZy15ZWFybHktZXZlbnQuaWNz') ->willReturn('deep-link-to-calendar'); $actual = $this->provider->search($user, $query); $data = $actual->jsonSerialize(); - + $this->assertInstanceOf(SearchResult::class, $actual); + $this->assertEquals('Events', $data['name']); $this->assertCount(1, $data['entries']); - $result = $data['entries'][0]; - $resultData = $result->jsonSerialize(); + $this->assertTrue($data['isPaginated']); + $this->assertEquals(21, $data['cursor']); + + $result0 = $data['entries'][0]; + $result0Data = $result0->jsonSerialize(); - $this->assertSame('Recurring event', $resultData['title']); - $this->assertSame('05-22 09:00 - 10:00 (My Calendar)', $resultData['subline']); - $this->assertSame('deep-link-to-calendar', $resultData['resourceUrl']); - $this->assertSame('1779440400', $resultData['attributes']['createdAt']); + $this->assertInstanceOf(SearchResultEntry::class, $result0); + $this->assertEmpty($result0Data['thumbnailUrl']); + $this->assertEquals('Recurring yearly event', $result0Data['title']); + $this->assertEquals('06-01 09:00 - 10:00 (My Calendar)', $result0Data['subline']); + $this->assertEquals('deep-link-to-calendar', $result0Data['resourceUrl']); + $this->assertEquals('icon-calendar-dark', $result0Data['icon']); + $this->assertFalse($result0Data['rounded']); + $this->assertEquals('1780304400', $result0Data['attributes']['createdAt']); } } From 3533424b0581e9dc091df2c3afff91889be7bd95 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 9 Jun 2026 22:33:46 +0200 Subject: [PATCH 06/10] fixup! fix(caldav): Expand recurring events for principal calendar search --- apps/dav/tests/unit/Search/EventsSearchProviderTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php index 635f10f528a44..e6ab06fec9431 100644 --- a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php @@ -493,8 +493,8 @@ public function testSearchSince(): void { $query->method('getFilter')->willReturnCallback(function ($name) { return match ($name) { 'term' => new StringFilter('search term'), - 'since' => new DateTimeFilter('2026-01-01'), - 'until' => new DateTimeFilter('2026-12-31'), + 'since' => new DateTimeFilter('2026-05-15'), + 'until' => new DateTimeFilter('2026-06-14'), default => null, }; }); @@ -534,7 +534,7 @@ public function testSearchSince(): void { ->with('principals/users/john.doe', 'search term', ['VEVENT'], ['SUMMARY', 'LOCATION', 'DESCRIPTION', 'ATTENDEE', 'ORGANIZER', 'CATEGORIES'], ['ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN']], - ['limit' => 5, 'offset' => 20, 'timerange' => ['start' => new \DateTimeImmutable('2026-01-01 00:00:00'), 'end' => new \DateTimeImmutable('2026-12-31 00:00:00')]]) + ['limit' => 5, 'offset' => 20, 'timerange' => ['start' => new \DateTimeImmutable('2026-05-15 00:00:00'), 'end' => new \DateTimeImmutable('2026-06-14 00:00:00')]]) ->willReturn([ [ 'calendarid' => 99, From 325b92ea3d8c4fc84e01f3cdd9a5c034f14fa9ad Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 9 Jun 2026 22:40:12 +0200 Subject: [PATCH 07/10] fixup! fix(caldav): Expand recurring events for principal calendar search --- apps/dav/lib/CalDAV/CalDavBackend.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 178509b69297a..2c13d5854173c 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -2473,7 +2473,11 @@ private function transformSearchProperty(Property $prop) { } /** - * Find events across calendars. + * Search calendar objects across a principal's calendars. + * + * This returns the stored calendar objects and does not expand recurring + * events. Callers that need the concrete occurrence for a requested time + * range must expand recurrences from `calendardata` themselves. * * @param string $principalUri * @param string $pattern From b090be8a782fdbee3c6a205b72f076750266522a Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 9 Jun 2026 23:12:03 +0200 Subject: [PATCH 08/10] fixup! fix(caldav): Expand recurring events for principal calendar search --- apps/dav/lib/CalDAV/CalDavBackend.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 2c13d5854173c..408b957b1e378 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -2616,14 +2616,10 @@ public function searchPrincipalUri(string $principalUri, $calendarObjects = $this->searchCalendarObjects($query, $start, $end); - return array_map(static function ($event) { + return array_map(function ($event) { $event['calendarid'] = (int)$event['calendarid']; $event['calendartype'] = (int)$event['calendartype']; - - if (is_resource($event['calendardata'])) { - $event['calendardata'] = stream_get_contents($event['calendardata']); - } - + $event['calendardata'] = $this->readBlob($event['calendardata']); return $event; }, $calendarObjects); }, $this->db); From 48d672f14295b94a75be560e83462df39afa046e Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 9 Jun 2026 23:21:21 +0200 Subject: [PATCH 09/10] fixup! fix(caldav): Expand recurring events for principal calendar search --- apps/dav/lib/Search/ACalendarSearchProvider.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/dav/lib/Search/ACalendarSearchProvider.php b/apps/dav/lib/Search/ACalendarSearchProvider.php index 9e9b034a92941..d34e800bc85d7 100644 --- a/apps/dav/lib/Search/ACalendarSearchProvider.php +++ b/apps/dav/lib/Search/ACalendarSearchProvider.php @@ -101,6 +101,9 @@ protected function getPrimaryComponent(string $calendarData, string $componentNa } $components = $vCalendar->select($componentName); + + // Expanded results: every instance has a RECURRENCE-ID; just take the first in-range occurrence. + // Stored objects: a recurrence-set is the master (no RECURRENCE-ID) plus override exceptions. if (count($components) === 1) { return $components[0]; } From cf432acbe4839faf5d1d92b081314912bfb33f09 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 10 Jun 2026 00:29:11 +0200 Subject: [PATCH 10/10] fixup! fix(caldav): Expand recurring events for principal calendar search --- .../lib/Search/ACalendarSearchProvider.php | 38 ++++++++++++++++++- .../tests/unit/CalDAV/CalDavBackendTest.php | 25 ++++-------- .../unit/Search/EventsSearchProviderTest.php | 20 +++++----- 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/apps/dav/lib/Search/ACalendarSearchProvider.php b/apps/dav/lib/Search/ACalendarSearchProvider.php index d34e800bc85d7..246b50bbe3fbd 100644 --- a/apps/dav/lib/Search/ACalendarSearchProvider.php +++ b/apps/dav/lib/Search/ACalendarSearchProvider.php @@ -92,16 +92,36 @@ protected function getSortedSubscriptions(string $principalUri): array { protected function getPrimaryComponent(string $calendarData, string $componentName, \DateTimeInterface|null $since, \DateTimeInterface|null $until): Component { $vCalendar = Reader::read($calendarData, Reader::OPTION_FORGIVING); + $originalTimeZone = null; if ($vCalendar instanceof VCalendar && isset($since, $until)) { + // expand() rewrites every occurrence's DTSTART/DTEND to UTC, so remember + // the event's original timezone to display the occurrence in local time. + $baseComponent = $vCalendar->getBaseComponent($componentName); + if ($baseComponent !== null && isset($baseComponent->DTSTART) && $baseComponent->DTSTART->hasTime()) { + $originalTimeZone = $baseComponent->DTSTART->getDateTime()->getTimezone(); + } + try { $vCalendar = $vCalendar->expand($since, $until); } catch (InvalidDataException $e) { - // fallback to the original event without expanding + // fallback to the original event without expanding, leave its timezone untouched + $originalTimeZone = null; } } - $components = $vCalendar->select($componentName); + $component = $this->selectPrimaryComponent($vCalendar->select($componentName)); + + if ($originalTimeZone !== null) { + $this->applyTimeZone($component, $originalTimeZone); + } + return $component; + } + + /** + * @param Component[] $components + */ + private function selectPrimaryComponent(array $components): Component { // Expanded results: every instance has a RECURRENCE-ID; just take the first in-range occurrence. // Stored objects: a recurrence-set is the master (no RECURRENCE-ID) plus override exceptions. if (count($components) === 1) { @@ -119,4 +139,18 @@ protected function getPrimaryComponent(string $calendarData, string $componentNa // In case of error, just fallback to the first element in the set return $components[0]; } + + /** + * Move the occurrence back into the event's original timezone after expand() + * has rewritten it to UTC, so the rendered time matches the user's local time. + */ + private function applyTimeZone(Component $component, \DateTimeZone $timeZone): void { + foreach (['DTSTART', 'DTEND'] as $name) { + if (isset($component->$name) && $component->$name->hasTime()) { + $component->$name->setDateTime( + $component->$name->getDateTime()->setTimezone($timeZone), + ); + } + } + } } diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index 5d94b166713bf..4b1687a9a42a9 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -2236,13 +2236,10 @@ public function testSearchPrincipalWithTimeRange(): void { $sharer = self::UNIT_TEST_USER1; $this->backend->createCalendar($me, 'calendar-uri-me', []); $this->backend->createCalendar($sharer, 'calendar-uri-sharer', []); - $myCalendars = $this->backend->getCalendarsForUser($me); $this->assertCount(1, $myCalendars); - $sharerCalendars = $this->backend->getCalendarsForUser($sharer); $this->assertCount(1, $sharerCalendars); - $logger = $this->createMock(\Psr\Log\LoggerInterface::class); $sharerCalendar = new Calendar($this->backend, $sharerCalendars[0], $l10n, $config, $logger); $this->backend->updateShares($sharerCalendar, [ @@ -2251,13 +2248,10 @@ public function testSearchPrincipalWithTimeRange(): void { 'readOnly' => false, ], ], []); - $this->assertCount(2, $this->backend->getCalendarsForUser($me)); - $this->backend->createCalendarObject($myCalendars[0]['id'], 'event0.ics', $myPublic); $this->backend->createCalendarObject($myCalendars[0]['id'], 'event1.ics', $myPrivate); $this->backend->createCalendarObject($myCalendars[0]['id'], 'event2.ics', $myConfidential); - $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event3.ics', $sharerPublic); $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event4.ics', $sharerPrivate); $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event5.ics', $sharerConfidential); @@ -2277,25 +2271,22 @@ public function testSearchPrincipalWithTimeRange(): void { ); $sharerSearchResults = $this->backend->searchPrincipalUri($sharer, 'Test', ['VEVENT'], ['SUMMARY'], []); + // Results with a time range are filtered but not expanded: the original + // DTSTART and RRULE are preserved (callers expand the occurrence themselves). + // Results without a time range are likewise not expanded. $this->assertCount(4, $mySearchResults); $this->assertCount(3, $sharerSearchResults); - $this->assertStringContainsString('SUMMARY:My Test (public)', $mySearchResults[0]['calendardata']); - $this->assertStringContainsString('DTSTART:20131027T120000Z', $mySearchResults[0]['calendardata']); - + $this->assertStringContainsString('DTSTART;TZID=Europe/Berlin:20131027T130000', $mySearchResults[0]['calendardata']); + $this->assertStringContainsString('RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', $mySearchResults[0]['calendardata']); $this->assertStringContainsString('SUMMARY:My Test (private)', $mySearchResults[1]['calendardata']); - $this->assertStringContainsString('DTSTART:20131027T120000Z', $mySearchResults[1]['calendardata']); - + $this->assertStringContainsString('DTSTART;TZID=Europe/Berlin:20131027T130000', $mySearchResults[1]['calendardata']); $this->assertStringContainsString('SUMMARY:My Test (confidential)', $mySearchResults[2]['calendardata']); - $this->assertStringContainsString('DTSTART:20131027T120000Z', $mySearchResults[2]['calendardata']); - + $this->assertStringContainsString('DTSTART;TZID=Europe/Berlin:20131027T130000', $mySearchResults[2]['calendardata']); $this->assertStringContainsString('SUMMARY:Sharer Test (public)', $mySearchResults[3]['calendardata']); - $this->assertStringContainsString('DTSTART:20131027T120000Z', $mySearchResults[3]['calendardata']); - - // Results without a time range are not expanded: the RRULE is preserved. + $this->assertStringContainsString('DTSTART;TZID=Europe/Berlin:20131027T130000', $mySearchResults[3]['calendardata']); $this->assertStringContainsString('SUMMARY:Sharer Test (public)', $sharerSearchResults[0]['calendardata']); $this->assertStringContainsString('RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', $sharerSearchResults[0]['calendardata']); - $this->assertStringContainsString('SUMMARY:Sharer Test (private)', $sharerSearchResults[1]['calendardata']); $this->assertStringContainsString('SUMMARY:Sharer Test (confidential)', $sharerSearchResults[2]['calendardata']); } diff --git a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php index e6ab06fec9431..96bc2f5aa7996 100644 --- a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php @@ -213,15 +213,17 @@ class EventsSearchProviderTest extends TestCase { . 'END:VEVENT' . PHP_EOL . 'END:VCALENDAR'; - private static string $vRecurringEvent = 'BEGIN:VCALENDAR' . PHP_EOL + // Stored in a non-UTC timezone on purpose: expand() rewrites occurrences to UTC, + // so this exercises that the result is converted back to the event's local time. + private static string $vEvent8 = 'BEGIN:VCALENDAR' . PHP_EOL . 'VERSION:2.0' . PHP_EOL . 'PRODID:-//Tests//' . PHP_EOL . 'CALSCALE:GREGORIAN' . PHP_EOL . 'BEGIN:VEVENT' . PHP_EOL . 'UID:recurring-yearly@example.com' . PHP_EOL . 'DTSTAMP:20240601T080000Z' . PHP_EOL - . 'DTSTART:20240601T090000Z' . PHP_EOL - . 'DTEND:20240601T100000Z' . PHP_EOL + . 'DTSTART;TZID=Europe/Berlin:20240601T090000' . PHP_EOL + . 'DTEND;TZID=Europe/Berlin:20240601T100000' . PHP_EOL . 'RRULE:FREQ=YEARLY' . PHP_EOL . 'SUMMARY:Recurring yearly event' . PHP_EOL . 'END:VEVENT' . PHP_EOL @@ -510,10 +512,8 @@ public function testSearchSince(): void { if ($type === 'time') { return $date->format('H:i'); } - return $date->format('m-d'); }); - $this->backend->expects($this->once()) ->method('getCalendarsForUser') ->with('principals/users/john.doe') @@ -540,10 +540,9 @@ public function testSearchSince(): void { 'calendarid' => 99, 'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR, 'uri' => 'recurring-yearly-event.ics', - 'calendardata' => self::$vRecurringEvent, + 'calendardata' => self::$vEvent8, ] ]); - $this->urlGenerator->expects($this->once()) ->method('linkTo') ->with('', 'remote.php') @@ -558,23 +557,24 @@ public function testSearchSince(): void { ->willReturn('deep-link-to-calendar'); $actual = $this->provider->search($user, $query); + $data = $actual->jsonSerialize(); $this->assertInstanceOf(SearchResult::class, $actual); $this->assertEquals('Events', $data['name']); $this->assertCount(1, $data['entries']); $this->assertTrue($data['isPaginated']); $this->assertEquals(21, $data['cursor']); - $result0 = $data['entries'][0]; $result0Data = $result0->jsonSerialize(); - $this->assertInstanceOf(SearchResultEntry::class, $result0); $this->assertEmpty($result0Data['thumbnailUrl']); $this->assertEquals('Recurring yearly event', $result0Data['title']); + // The occurrence is shown in the event's local time (Europe/Berlin, 09:00), + // not in the UTC time that expand() produces (07:00). $this->assertEquals('06-01 09:00 - 10:00 (My Calendar)', $result0Data['subline']); $this->assertEquals('deep-link-to-calendar', $result0Data['resourceUrl']); $this->assertEquals('icon-calendar-dark', $result0Data['icon']); $this->assertFalse($result0Data['rounded']); - $this->assertEquals('1780304400', $result0Data['attributes']['createdAt']); + $this->assertEquals('1780297200', $result0Data['attributes']['createdAt']); } }