diff --git a/apps/comments/lib/Search/CommentsSearchProvider.php b/apps/comments/lib/Search/CommentsSearchProvider.php index 87a658cab1c6c..24b3f47b98229 100644 --- a/apps/comments/lib/Search/CommentsSearchProvider.php +++ b/apps/comments/lib/Search/CommentsSearchProvider.php @@ -6,8 +6,15 @@ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\Comments\Search; +use OCP\Comments\IComment; +use OCP\Comments\ICommentsManager; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUser; @@ -16,15 +23,16 @@ use OCP\Search\ISearchQuery; use OCP\Search\SearchResult; use OCP\Search\SearchResultEntry; -use function array_map; -use function pathinfo; +use Psr\Log\LoggerInterface; class CommentsSearchProvider implements IProvider { public function __construct( private IUserManager $userManager, private IL10N $l10n, private IURLGenerator $urlGenerator, - private LegacyProvider $legacyProvider, + private ICommentsManager $commentsManager, + private IRootFolder $rootFolder, + private LoggerInterface $logger, ) { } @@ -45,27 +53,93 @@ public function getOrder(string $route, array $routeParameters): int { } public function search(IUser $user, ISearchQuery $query): SearchResult { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $result = $this->findCommentsBySearchQuery($query, $userFolder); + return SearchResult::complete( $this->l10n->t('Comments'), - array_map(function (Result $result) { - $path = $result->path; - $pathInfo = pathinfo($path); - $isUser = $this->userManager->userExists($result->authorId); + $result + ); + } + + /** + * @return list + */ + private function findCommentsBySearchQuery(ISearchQuery $query, Folder $userFolder): array { + $result = []; + $numComments = 50; + $offset = 0; + $limit = $numComments; + + while (count($result) < $numComments) { + $comments = $this->commentsManager->search( + $query->getTerm(), + 'files', + '', + 'comment', + $offset, + $limit, + ); + + foreach ($comments as $comment) { + if ($comment->getActorType() !== 'users') { + continue; + } + + try { + $node = $this->getFileForComment($userFolder, $comment); + } catch (\Throwable $e) { + $this->logger->debug('Found comment for a file, but obtaining the file thrown an exception', ['exception' => $e]); + continue; + } + + $actorId = $comment->getActorId(); + $isUser = $this->userManager->userExists($actorId); + $avatarUrl = $isUser - ? $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $result->authorId, 'size' => 42]) - : $this->urlGenerator->linkToRouteAbsolute('core.GuestAvatar.getAvatar', ['guestName' => $result->authorId, 'size' => 42]); - return new SearchResultEntry( + ? $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $actorId, 'size' => 42]) + : $this->urlGenerator->linkToRouteAbsolute('core.GuestAvatar.getAvatar', ['guestName' => $actorId, 'size' => 42]); + + $path = $userFolder->getRelativePath($node->getPath()); + + // Use shortened link to centralize the various + // files/folder url redirection in files.View.showFile + $link = $this->urlGenerator->linkToRoute( + 'files.View.showFile', + ['fileid' => $node->getId()] + ); + + $searchResultEntry = new SearchResultEntry( $avatarUrl, - $result->name, - $path, - $this->urlGenerator->linkToRouteAbsolute('files.view.index', [ - 'dir' => $pathInfo['dirname'], - 'scrollto' => $pathInfo['basename'], - ]), + $comment->getMessage(), + ltrim($path, '/'), + $this->urlGenerator->getAbsoluteURL($link), '', - true ); - }, $this->legacyProvider->search($query->getTerm())) - ); + $searchResultEntry->addAttribute('fileId', (string)$node->getId()); + $searchResultEntry->addAttribute('path', $path); + + $result[] = $searchResultEntry; + } + + if (count($comments) < $limit) { + // Didn't find more comments when we tried to get, so there are no more comments. + break; + } + + $offset += $limit; + $limit = $numComments - count($result); + } + + return $result; + } + + private function getFileForComment(Folder $userFolder, IComment $comment): Node { + $node = $userFolder->getFirstNodeById((int)$comment->getObjectId()); + if ($node === null) { + throw new NotFoundException('File not found'); + } + + return $node; } } diff --git a/apps/comments/tests/Unit/Search/CommentsSearchProviderTest.php b/apps/comments/tests/Unit/Search/CommentsSearchProviderTest.php new file mode 100644 index 0000000000000..a3aca625cac74 --- /dev/null +++ b/apps/comments/tests/Unit/Search/CommentsSearchProviderTest.php @@ -0,0 +1,164 @@ +userManager = $this->createMock(IUserManager::class); + $this->l10n = $this->createMock(IL10N::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->commentsManager = $this->createMock(ICommentsManager::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + + $userFolder = $this->createMock(Folder::class); + $userFolder->method('getFirstNodeById')->willReturnCallback(function (int $id) { + if ($id % 4 === 0) { + // Returning null for every fourth file to simulate a file not found case. + return null; + } + $node = $this->createMock(File::class); + $node->method('getId')->willReturn($id); + $node->method('getPath')->willReturn('/' . $id . '.txt'); + return $node; + }); + $userFolder->method('getRelativePath')->willReturnArgument(0); + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + + $this->userManager->method('userExists')->willReturn(true); + + $this->l10n->method('t')->willReturnArgument(0); + + $this->provider = new CommentsSearchProvider( + $this->userManager, + $this->l10n, + $this->urlGenerator, + $this->commentsManager, + $this->rootFolder, + new NullLogger(), + ); + } + + public function testGetId(): void { + $this->assertEquals('comments', $this->provider->getId()); + } + + public function testGetName(): void { + $this->l10n->expects($this->once()) + ->method('t') + ->with('Comments') + ->willReturnArgument(0); + + $this->assertEquals('Comments', $this->provider->getName()); + } + + public function testSearch(): void { + $this->commentsManager->method('search')->willReturnCallback(function (string $search, string $objectType, string $objectId, string $verb, int $offset, int $limit = 50) { + // The search method is call until 50 comments are found or there are no more comments to search. + $comments = []; + for ($i = 1; $i <= $limit; $i++) { + $comments[] = $this->mockComment(($offset + $i)); + } + return $comments; + }); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $searchTermFilter = $this->createMock(IFilter::class); + $searchTermFilter->method('get')->willReturn('search term'); + $searchQuery = $this->createMock(ISearchQuery::class); + $searchQuery->method('getFilter')->willReturnCallback(function ($name) use ($searchTermFilter) { + return match ($name) { + 'term' => $searchTermFilter, + default => null, + }; + }); + + $result = $this->provider->search($user, $searchQuery); + $data = $result->jsonSerialize(); + + $this->assertCount(50, $data['entries']); + } + + public function testSearchNoMoreComments(): void { + $this->commentsManager->method('search')->willReturnCallback(function (string $search, string $objectType, string $objectId, string $verb, int $offset, int $limit = 50) { + // Decrease the limit to simulate no more comments to search -> the break case. + if ($offset > 0) { + $limit--; + } + $comments = []; + for ($i = 1; $i <= $limit; $i++) { + $comments[] = $this->mockComment(($offset + $i)); + } + return $comments; + }); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $searchTermFilter = $this->createMock(IFilter::class); + $searchTermFilter->method('get')->willReturn('search term'); + $searchQuery = $this->createMock(ISearchQuery::class); + $searchQuery->method('getFilter')->willReturnCallback(function ($name) use ($searchTermFilter) { + return match ($name) { + 'term' => $searchTermFilter, + default => null, + }; + }); + + + $result = $this->provider->search($user, $searchQuery); + $data = $result->jsonSerialize(); + + $this->assertCount(46, $data['entries']); + } + + private function mockComment(int $id): IComment { + return new Comment([ + 'id' => (string)$id, + 'parent_id' => '0', + 'topmost_parent_id' => '0', + 'children_count' => 0, + 'actor_type' => 'users', + 'actor_id' => 'user' . $id, + 'message' => 'Comment ' . $id, + 'verb' => 'comment', + 'creation_timestamp' => new \DateTime(), + 'latest_child_timestamp' => null, + 'object_type' => 'files', + 'object_id' => (string)$id + ]); + } + +} diff --git a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php index d14512ba71277..2b91cd145b7bc 100644 --- a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php @@ -269,14 +269,14 @@ public function testSearch(): void { $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('john.doe'); $query = $this->createMock(ISearchQuery::class); - $seachTermFilter = $this->createMock(IFilter::class); - $query->method('getFilter')->willReturnCallback(function ($name) use ($seachTermFilter) { + $searchTermFilter = $this->createMock(IFilter::class); + $query->method('getFilter')->willReturnCallback(function ($name) use ($searchTermFilter) { return match ($name) { - 'term' => $seachTermFilter, + 'term' => $searchTermFilter, default => null, }; }); - $seachTermFilter->method('get')->willReturn('search term'); + $searchTermFilter->method('get')->willReturn('search term'); $query->method('getLimit')->willReturn(5); $query->method('getCursor')->willReturn(20); $this->appManager->expects($this->once()) diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index fd9c1b31a310d..466884118a46d 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -63,18 +63,6 @@ - - - - - - authorId]]> - authorId]]> - authorId]]> - name]]> - path]]> - -