Skip to content

Commit

Permalink
Merge pull request #9002 from nextcloud/feat/8864/advanced-search
Browse files Browse the repository at this point in the history
feat(search): allow date and recipient search
  • Loading branch information
ChristophWurst authored Nov 14, 2023
2 parents b415472 + 8e12c63 commit 0ce59ec
Show file tree
Hide file tree
Showing 7 changed files with 272 additions and 3 deletions.
9 changes: 8 additions & 1 deletion lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';

Expand Down Expand Up @@ -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);

Expand Down
12 changes: 12 additions & 0 deletions lib/Db/MessageMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
94 changes: 94 additions & 0 deletions lib/Search/FilteringProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

/*
* @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @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 <http://www.gnu.org/licenses/>.
*/

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 [];
}

}
8 changes: 6 additions & 2 deletions lib/Search/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);

Expand Down
1 change: 1 addition & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<referencedClass name="Symfony\Component\Console\Input\InputInterface" />
<referencedClass name="Symfony\Component\Console\Input\InputOption" />
<referencedClass name="Symfony\Component\Console\Output\OutputInterface" />
<referencedClass name="OCP\Search\IFilteringProvider" /><!-- 28+ -->
<referencedClass name="OCP\TextProcessing\IManager" />
<referencedClass name="OCP\TextProcessing\SummaryTaskType" />
<referencedClass name="OCP\TextProcessing\Task" />
Expand Down
150 changes: 150 additions & 0 deletions tests/Unit/Search/FilteringProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

declare(strict_types=1);

/*
* @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @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 <http://www.gnu.org/licenses/>.
*/

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 <sender@domain.tld>'));
$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 <other@domain.tld>'));
$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'] ?? []);
}

}
1 change: 1 addition & 0 deletions tests/psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<files psalm-version="5.14.1@b9d355e0829c397b9b3b47d0c0ed042a8a70284d">
<file src="lib/AppInfo/Application.php">
<MissingDependency>
<code>FilteringProvider</code>
<code>ImportantMailWidgetV2</code>
<code>UnreadMailWidgetV2</code>
</MissingDependency>
Expand Down

0 comments on commit 0ce59ec

Please sign in to comment.