From 8e12c633ddd3c20c70099c9ab94705d9eb44f7f0 Mon Sep 17 00:00:00 2001 From: Johannes Merkel Date: Wed, 25 Oct 2023 17:19:39 +0200 Subject: [PATCH] feat(advanced-search): allow date and recipient search Signed-off-by: Johannes Merkel --- lib/AppInfo/Application.php | 9 +- lib/Db/MessageMapper.php | 12 ++ lib/Search/FilteringProvider.php | 94 ++++++++++++ lib/Search/Provider.php | 8 +- psalm.xml | 1 + tests/Unit/Search/FilteringProviderTest.php | 150 ++++++++++++++++++++ tests/psalm-baseline.xml | 1 + 7 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 lib/Search/FilteringProvider.php create mode 100644 tests/Unit/Search/FilteringProviderTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 94c4948f3d..dd71aa309e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -70,6 +70,7 @@ use OCA\Mail\Listener\SpamReportListener; use OCA\Mail\Listener\UserDeletedListener; use OCA\Mail\Notification\Notifier; +use OCA\Mail\Search\FilteringProvider; use OCA\Mail\Search\Provider; use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\AvatarService; @@ -86,9 +87,11 @@ use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\Dashboard\IAPIWidgetV2; use OCP\IServerContainer; +use OCP\Search\IFilteringProvider; use OCP\User\Events\UserDeletedEvent; use OCP\Util; use Psr\Container\ContainerInterface; +use function interface_exists; include_once __DIR__ . '/../../vendor/autoload.php'; @@ -150,7 +153,11 @@ public function register(IRegistrationContext $context): void { $context->registerDashboardWidget(UnreadMailWidget::class); } - $context->registerSearchProvider(Provider::class); + if (interface_exists(IFilteringProvider::class)) { + $context->registerSearchProvider(FilteringProvider::class); + } else { + $context->registerSearchProvider(Provider::class); + } $context->registerNotifierService(Notifier::class); diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index 34fc1eb828..b38bfa3327 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -966,6 +966,18 @@ public function findIdsGloballyByQuery(IUser $user, SearchQuery $query, ?int $li ); } + if (!empty($query->getStart())) { + $select->andWhere( + $qb->expr()->gte('m.sent_at', $qb->createNamedParameter($query->getStart()), IQueryBuilder::PARAM_INT) + ); + } + + if (!empty($query->getEnd())) { + $select->andWhere( + $qb->expr()->lte('m.sent_at', $qb->createNamedParameter($query->getEnd()), IQueryBuilder::PARAM_INT) + ); + } + if ($query->getCursor() !== null) { $select->andWhere( $qb->expr()->lt('m.sent_at', $qb->createNamedParameter($query->getCursor(), IQueryBuilder::PARAM_INT)) diff --git a/lib/Search/FilteringProvider.php b/lib/Search/FilteringProvider.php new file mode 100644 index 0000000000..8468be5527 --- /dev/null +++ b/lib/Search/FilteringProvider.php @@ -0,0 +1,94 @@ + + * + * @author 2023 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Search; + +use DateTimeImmutable; +use OCP\IUser; +use OCP\Search\IFilteringProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; +use function implode; + +class FilteringProvider extends Provider implements IFilteringProvider { + + public function search(IUser $user, ISearchQuery $query): SearchResult { + $filters = []; + if ($term = $query->getFilter('term')?->get()) { + if (is_string($term)) { + $filters[] = "subject:$term"; + } + } + if ($since = $query->getFilter('since')?->get()) { + if ($since instanceof DateTimeImmutable) { + $ts = $since->getTimestamp(); + $filters[] = "start:$ts"; + } + } + if ($until = $query->getFilter('until')?->get()) { + if ($until instanceof DateTimeImmutable) { + $ts = $until->getTimestamp(); + $filters[] = "end:$ts"; + } + } + if ($userFilter = $query->getFilter('person')?->get()) { + if ($userFilter instanceof IUser) { + $email = $userFilter->getEMailAddress(); + if ($email !== null) { + $filters[] = "from:$email"; + $filters[] = "to:$email"; + $filters[] = "cc:$email"; + } + } + } + + if (count($filters) === 0) { + return SearchResult::complete( + $this->getName(), + [] + ); + } + + return $this->searchByFilter($user, $query, implode(' ', $filters)); + } + + public function getSupportedFilters(): array { + return [ + 'term', + 'since', + 'until', + 'person', + ]; + } + + public function getAlternateIds(): array { + return []; + } + + public function getCustomFilters(): array { + return []; + } + +} diff --git a/lib/Search/Provider.php b/lib/Search/Provider.php index a2c7a28e8f..292884e382 100644 --- a/lib/Search/Provider.php +++ b/lib/Search/Provider.php @@ -79,11 +79,15 @@ public function getOrder(string $route, array $routeParameters): int { } public function search(IUser $user, ISearchQuery $query): SearchResult { + return $this->searchByFilter($user, $query, $query->getTerm()); + } + + protected function searchByFilter(IUser $user, ISearchQuery $query, string $filter): SearchResult { $cursor = $query->getCursor(); $messages = $this->mailSearch->findMessagesGlobally( $user, - $query->getTerm(), - empty($cursor) ? null : ((int) $cursor), + $filter, + empty($cursor) ? null : ((int)$cursor), $query->getLimit() ); diff --git a/psalm.xml b/psalm.xml index a8079f99f6..644ab5e266 100644 --- a/psalm.xml +++ b/psalm.xml @@ -42,6 +42,7 @@ + diff --git a/tests/Unit/Search/FilteringProviderTest.php b/tests/Unit/Search/FilteringProviderTest.php new file mode 100644 index 0000000000..324869b764 --- /dev/null +++ b/tests/Unit/Search/FilteringProviderTest.php @@ -0,0 +1,150 @@ + + * + * @author 2023 Christoph Wurst + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Mail\Tests\Unit\Search; + +use ChristophWurst\Nextcloud\Testing\ServiceMockObject; +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\AddressList; +use OCA\Mail\Db\Message; +use OCA\Mail\Search\FilteringProvider; +use OCP\IUser; +use OCP\Search\IFilter; +use OCP\Search\IFilteringProvider; +use OCP\Search\ISearchQuery; +use function interface_exists; + +/** + * @covers \OCA\Mail\Search\FilteringProvider + */ +class FilteringProviderTest extends TestCase { + private ServiceMockObject $serviceMock; + private FilteringProvider $provider; + + protected function setUp(): void { + parent::setUp(); + + if (!interface_exists(IFilteringProvider::class)) { + $this->markTestSkipped('Base class missing'); + } + + $this->serviceMock = $this->createServiceMock(FilteringProvider::class); + $this->provider = $this->serviceMock->getService(); + } + + public function testSearchForTerm(): void { + $term = 'spam'; + $user = $this->createMock(IUser::class); + $query = $this->createMock(ISearchQuery::class); + $termFilter = $this->createMock(IFilter::class); + $termFilter->method('get')->willReturn($term); + $query->method('getFilter')->willReturnCallback(function ($filter) use ($termFilter) { + return match ($filter) { + 'term' => $termFilter, + default => null, + }; + }); + $message1 = new Message(); + $message1->setSubject('This is not spam'); + $message1->setFrom(AddressList::parse('Sender ')); + $this->serviceMock->getParameter('mailSearch') + ->expects(self::once()) + ->method('findMessagesGlobally') + ->with( + $user, + 'subject:spam' + ) + ->willReturn([ + $message1, + ]); + + $result = $this->provider->search( + $user, + $query, + ); + + self::assertNotEmpty($result->jsonSerialize()['entries'] ?? []); + } + + public function testSearchForUserNoEmail(): void { + $user = $this->createMock(IUser::class); + $otherUser = $this->createMock(IUser::class); + $query = $this->createMock(ISearchQuery::class); + $termFilter = $this->createMock(IFilter::class); + $termFilter->method('get')->willReturn($otherUser); + $query->method('getFilter')->willReturnCallback(function ($filter) use ($termFilter) { + return match ($filter) { + 'person' => $termFilter, + default => null, + }; + }); + $this->serviceMock->getParameter('mailSearch') + ->expects(self::never()) + ->method('findMessagesGlobally'); + + $result = $this->provider->search( + $user, + $query, + ); + + self::assertEmpty($result->jsonSerialize()['entries'] ?? []); + } + + public function testSearchForUser(): void { + $user = $this->createMock(IUser::class); + $otherUser = $this->createMock(IUser::class); + $otherUser->method('getEMailAddress')->willReturn('other@domain.tld'); + $query = $this->createMock(ISearchQuery::class); + $userFilter = $this->createMock(IFilter::class); + $userFilter->method('get')->willReturn($otherUser); + $query->method('getFilter')->willReturnCallback(function ($filter) use ($userFilter) { + return match ($filter) { + 'person' => $userFilter, + default => null, + }; + }); + $message1 = new Message(); + $message1->setSubject('This is not spam'); + $message1->setFrom(AddressList::parse('Other ')); + $this->serviceMock->getParameter('mailSearch') + ->expects(self::once()) + ->method('findMessagesGlobally') + ->with( + $user, + "from:other@domain.tld to:other@domain.tld cc:other@domain.tld" + ) + ->willReturn([ + $message1, + ]); + + $result = $this->provider->search( + $user, + $query, + ); + + self::assertNotEmpty($result->jsonSerialize()['entries'] ?? []); + } + +} diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index fa697a1c2a..f39a3adb99 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -2,6 +2,7 @@ + FilteringProvider ImportantMailWidgetV2 UnreadMailWidgetV2