diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index b42750c25ea88..bd63dee13b7d9 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -210,6 +210,8 @@ 'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningNode' => $baseDir . '/../lib/Provisioning/Apple/AppleProvisioningNode.php', 'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningPlugin' => $baseDir . '/../lib/Provisioning/Apple/AppleProvisioningPlugin.php', 'OCA\\DAV\\RootCollection' => $baseDir . '/../lib/RootCollection.php', + 'OCA\\DAV\\Search\\ContactsSearchProvider' => $baseDir . '/../lib/Search/ContactsSearchProvider.php', + 'OCA\\DAV\\Search\\ContactsSearchResultEntry' => $baseDir . '/../lib/Search/ContactsSearchResultEntry.php', 'OCA\\DAV\\Server' => $baseDir . '/../lib/Server.php', 'OCA\\DAV\\Settings\\CalDAVSettings' => $baseDir . '/../lib/Settings/CalDAVSettings.php', 'OCA\\DAV\\Storage\\PublicOwnerWrapper' => $baseDir . '/../lib/Storage/PublicOwnerWrapper.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 2d579289f0516..a664c86f5fd8e 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -225,6 +225,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningNode' => __DIR__ . '/..' . '/../lib/Provisioning/Apple/AppleProvisioningNode.php', 'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningPlugin' => __DIR__ . '/..' . '/../lib/Provisioning/Apple/AppleProvisioningPlugin.php', 'OCA\\DAV\\RootCollection' => __DIR__ . '/..' . '/../lib/RootCollection.php', + 'OCA\\DAV\\Search\\ContactsSearchProvider' => __DIR__ . '/..' . '/../lib/Search/ContactsSearchProvider.php', + 'OCA\\DAV\\Search\\ContactsSearchResultEntry' => __DIR__ . '/..' . '/../lib/Search/ContactsSearchResultEntry.php', 'OCA\\DAV\\Server' => __DIR__ . '/..' . '/../lib/Server.php', 'OCA\\DAV\\Settings\\CalDAVSettings' => __DIR__ . '/..' . '/../lib/Settings/CalDAVSettings.php', 'OCA\\DAV\\Storage\\PublicOwnerWrapper' => __DIR__ . '/..' . '/../lib/Storage/PublicOwnerWrapper.php', diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php index bf1e614633048..6f2f7b2915342 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -54,6 +54,7 @@ use OCA\DAV\CardDAV\PhotoCache; use OCA\DAV\CardDAV\SyncService; use OCA\DAV\HookManager; +use OCA\DAV\Search\ContactsSearchProvider; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -96,6 +97,11 @@ public function register(IRegistrationContext $context): void { * Register capabilities */ $context->registerCapability(Capabilities::class); + + /* + * Register Search Providers + */ + $context->registerSearchProvider(ContactsSearchProvider::class); } public function boot(IBootContext $context): void { diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php index 9d602025c7a49..3b3474cdd01a3 100644 --- a/apps/dav/lib/CardDAV/CardDavBackend.php +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -951,7 +951,7 @@ public function updateShares(IShareable $shareable, $add, $remove) { } /** - * search contact + * Search contacts in a specific address-book * * @param int $addressBookId * @param string $pattern which should match within the $searchProperties @@ -962,11 +962,55 @@ public function updateShares(IShareable $shareable, $add, $remove) { * - 'offset' - Set the offset for the limited search results * @return array an array of contacts which are arrays of key-value-pairs */ - public function search($addressBookId, $pattern, $searchProperties, $options = []) { + public function search($addressBookId, $pattern, $searchProperties, $options = []): array { + return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options); + } + + /** + * Search contacts in all address-books accessible by a user + * + * @param string $principalUri + * @param string $pattern + * @param array $searchProperties + * @param array $options + * @return array + */ + public function searchPrincipalUri(string $principalUri, + string $pattern, + array $searchProperties, + array $options = []): array { + $addressBookIds = array_map(static function ($row):int { + return (int) $row['id']; + }, $this->getAddressBooksForUser($principalUri)); + + return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options); + } + + /** + * @param array $addressBookIds + * @param string $pattern + * @param array $searchProperties + * @param array $options + * @return array + */ + private function searchByAddressBookIds(array $addressBookIds, + string $pattern, + array $searchProperties, + array $options = []): array { $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false; $query2 = $this->db->getQueryBuilder(); - $or = $query2->expr()->orX(); + + $addressBookOr = $query2->expr()->orX(); + foreach ($addressBookIds as $addressBookId) { + $addressBookOr->add($query2->expr()->eq('cp.addressbookid', $query2->createNamedParameter($addressBookId))); + } + + if ($addressBookOr->count() === 0) { + return []; + } + + $propertyOr = $query2->expr()->orX(); foreach ($searchProperties as $property) { if ($escapePattern) { if ($property === 'EMAIL' && strpos($pattern, ' ') !== false) { @@ -980,17 +1024,17 @@ public function search($addressBookId, $pattern, $searchProperties, $options = [ } } - $or->add($query2->expr()->eq('cp.name', $query2->createNamedParameter($property))); + $propertyOr->add($query2->expr()->eq('cp.name', $query2->createNamedParameter($property))); } - if ($or->count() === 0) { + if ($propertyOr->count() === 0) { return []; } $query2->selectDistinct('cp.cardid') ->from($this->dbCardsPropertiesTable, 'cp') - ->andWhere($query2->expr()->eq('cp.addressbookid', $query2->createNamedParameter($addressBookId))) - ->andWhere($or); + ->andWhere($addressBookOr) + ->andWhere($propertyOr); // No need for like when the pattern is empty if ('' !== $pattern) { @@ -1016,7 +1060,7 @@ public function search($addressBookId, $pattern, $searchProperties, $options = [ }, $matches); $query = $this->db->getQueryBuilder(); - $query->select('c.carddata', 'c.uri') + $query->select('c.addressbookid', 'c.carddata', 'c.uri') ->from($this->dbCardsTable, 'c') ->where($query->expr()->in('c.id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY))); @@ -1026,6 +1070,7 @@ public function search($addressBookId, $pattern, $searchProperties, $options = [ $result->closeCursor(); return array_map(function ($array) { + $array['addressbookid'] = (int) $array['addressbookid']; $modified = false; $array['carddata'] = $this->readBlob($array['carddata'], $modified); if ($modified) { diff --git a/apps/dav/lib/Search/ContactsSearchProvider.php b/apps/dav/lib/Search/ContactsSearchProvider.php new file mode 100644 index 0000000000000..f9399861c73eb --- /dev/null +++ b/apps/dav/lib/Search/ContactsSearchProvider.php @@ -0,0 +1,189 @@ + + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ +namespace OCA\DAV\Search; + +use OCA\DAV\CardDAV\CardDavBackend; +use OCP\App\IAppManager; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\Search\IProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; +use Sabre\VObject\Component\VCard; +use Sabre\VObject\Reader; + +class ContactsSearchProvider implements IProvider { + + /** @var IAppManager */ + private $appManager; + + /** @var IL10N */ + private $l10n; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var CardDavBackend */ + private $backend; + + /** + * @var string[] + */ + private static $searchProperties = [ + 'N', + 'FN', + 'NICKNAME', + 'EMAIL', + 'ADR', + ]; + + /** + * ContactsSearchProvider constructor. + * + * @param IAppManager $appManager + * @param IL10N $l10n + * @param IURLGenerator $urlGenerator + * @param CardDavBackend $backend + */ + public function __construct(IAppManager $appManager, + IL10N $l10n, + IURLGenerator $urlGenerator, + CardDavBackend $backend) { + $this->appManager = $appManager; + $this->l10n = $l10n; + $this->urlGenerator = $urlGenerator; + $this->backend = $backend; + } + + /** + * @inheritDoc + */ + public function getId(): string { + return 'dav-contacts'; + } + + /** + * @inheritDoc + */ + public function getName(): string { + return $this->l10n->t('Contacts'); + } + + /** + * @inheritDoc + */ + public function search(IUser $user, ISearchQuery $query): SearchResult { + if (!$this->appManager->isEnabledForUser('contacts', $user)) { + return SearchResult::complete($this->getName(), []); + } + + $principalUri = 'principals/users/' . $user->getUID(); + $addressBooks = $this->backend->getAddressBooksForUser($principalUri); + $addressBooksById = []; + foreach ($addressBooks as $addressBook) { + $addressBooksById[(int) $addressBook['id']] = $addressBook; + } + + $searchResults = $this->backend->searchPrincipalUri( + $principalUri, + $query->getTerm(), + self::$searchProperties, + [ + 'limit' => $query->getLimit(), + 'offset' => $query->getCursor(), + ] + ); + $formattedResults = \array_map(function (array $contactRow) use ($addressBooksById):ContactsSearchResultEntry { + $addressBook = $addressBooksById[$contactRow['addressbookid']]; + + /** @var VCard $vCard */ + $vCard = Reader::read($contactRow['carddata']); + $thumbnailUrl = ''; + if ($vCard->PHOTO) { + $thumbnailUrl = $this->getDavUrlForContact($addressBook['principaluri'], $addressBook['uri'], $contactRow['uri']) . '?photo'; + } + + $title = (string)$vCard->FN; + $subline = $this->generateSubline($vCard); + $resourceUrl = $this->getDeepLinkToContactsApp($addressBook['uri'], (string) $vCard->UID); + + return new ContactsSearchResultEntry($thumbnailUrl, $title, $subline, $resourceUrl, 'icon-contacts-dark', true); + }, $searchResults); + + return SearchResult::paginated( + $this->getName(), + $formattedResults, + $query->getCursor() + count($formattedResults) + ); + } + + /** + * @param string $principalUri + * @param string $addressBookUri + * @param string $contactsUri + * @return string + */ + protected function getDavUrlForContact(string $principalUri, + string $addressBookUri, + string $contactsUri): string { + [, $principalType, $principalId] = explode('/', $principalUri, 3); + + return $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkTo('', 'remote.php') . '/dav/addressbooks/' + . $principalType . '/' + . $principalId . '/' + . $addressBookUri . '/' + . $contactsUri + ); + } + + /** + * @param string $addressBookUri + * @param string $contactUid + * @return string + */ + protected function getDeepLinkToContactsApp(string $addressBookUri, + string $contactUid): string { + return $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkToRoute('contacts.contacts.direct', [ + 'contact' => $contactUid . '~' . $addressBookUri + ]) + ); + } + + /** + * @param VCard $vCard + * @return string + */ + protected function generateSubline(VCard $vCard): string { + $emailAddresses = $vCard->select('EMAIL'); + if (!is_array($emailAddresses) || empty($emailAddresses)) { + return ''; + } + + return (string)$emailAddresses[0]; + } +} diff --git a/apps/dav/lib/Search/ContactsSearchResultEntry.php b/apps/dav/lib/Search/ContactsSearchResultEntry.php new file mode 100644 index 0000000000000..698fc1b3f4ad6 --- /dev/null +++ b/apps/dav/lib/Search/ContactsSearchResultEntry.php @@ -0,0 +1,30 @@ + + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ +namespace OCA\DAV\Search; + +use OCP\Search\ASearchResultEntry; + +class ContactsSearchResultEntry extends ASearchResultEntry { +} diff --git a/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php b/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php new file mode 100644 index 0000000000000..2f7249dc733d1 --- /dev/null +++ b/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php @@ -0,0 +1,278 @@ + + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ +namespace OCA\DAV\Tests\unit; + +use OCA\DAV\CardDAV\CardDavBackend; +use OCA\DAV\Search\ContactsSearchProvider; +use OCA\DAV\Search\ContactsSearchResultEntry; +use OCP\App\IAppManager; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; +use Sabre\VObject\Reader; +use Test\TestCase; + +class ContactsSearchProviderTest extends TestCase { + + /** @var IAppManager|\PHPUnit\Framework\MockObject\MockObject */ + private $appManager; + + /** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */ + private $l10n; + + /** @var IURLGenerator|\PHPUnit\Framework\MockObject\MockObject */ + private $urlGenerator; + + /** @var CardDavBackend|\PHPUnit\Framework\MockObject\MockObject */ + private $backend; + + /** @var ContactsSearchProvider */ + private $provider; + + private $vcardTest0 = 'BEGIN:VCARD'.PHP_EOL. + 'VERSION:3.0'.PHP_EOL. + 'PRODID:-//Sabre//Sabre VObject 4.1.2//EN'.PHP_EOL. + 'UID:Test'.PHP_EOL. + 'FN:FN of Test'.PHP_EOL. + 'N:Test;;;;'.PHP_EOL. + 'EMAIL:forrestgump@example.com'.PHP_EOL. + 'END:VCARD'; + + private $vcardTest1 = 'BEGIN:VCARD'.PHP_EOL. + 'VERSION:3.0'.PHP_EOL. + 'PRODID:-//Sabre//Sabre VObject 4.1.2//EN'.PHP_EOL. + 'PHOTO;ENCODING=b;TYPE=image/jpeg:'.PHP_EOL. + 'UID:Test2'.PHP_EOL. + 'FN:FN of Test2'.PHP_EOL. + 'N:Test2;;;;'.PHP_EOL. + 'END:VCARD'; + + protected function setUp(): void { + parent::setUp(); + + $this->appManager = $this->createMock(IAppManager::class); + $this->l10n = $this->createMock(IL10N::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->backend = $this->createMock(CardDavBackend::class); + + $this->provider = new ContactsSearchProvider( + $this->appManager, + $this->l10n, + $this->urlGenerator, + $this->backend + ); + } + + public function testGetId(): void { + $this->assertEquals('dav-contacts', $this->provider->getId()); + } + + public function testGetName(): void { + $this->l10n->expects($this->exactly(1)) + ->method('t') + ->with('Contacts') + ->willReturnArgument(0); + + $this->assertEquals('Contacts', $this->provider->getName()); + } + + public function testSearchAppDisabled(): void { + $user = $this->createMock(IUser::class); + $query = $this->createMock(ISearchQuery::class); + $this->appManager->expects($this->once()) + ->method('isEnabledForUser') + ->with('contacts', $user) + ->willReturn(false); + $this->l10n->expects($this->exactly(1)) + ->method('t') + ->with('Contacts') + ->willReturnArgument(0); + $this->backend->expects($this->never()) + ->method('getAddressBooksForUser'); + $this->backend->expects($this->never()) + ->method('searchPrincipalUri'); + + $actual = $this->provider->search($user, $query); + $data = $actual->jsonSerialize(); + $this->assertInstanceOf(SearchResult::class, $actual); + $this->assertEquals('Contacts', $data['name']); + $this->assertEmpty($data['entries']); + $this->assertFalse($data['isPaginated']); + $this->assertNull($data['cursor']); + } + + 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('getLimit')->willReturn(5); + $query->method('getCursor')->willReturn(20); + $this->appManager->expects($this->once()) + ->method('isEnabledForUser') + ->with('contacts', $user) + ->willReturn(true); + $this->l10n->expects($this->exactly(1)) + ->method('t') + ->with('Contacts') + ->willReturnArgument(0); + + $this->backend->expects($this->once()) + ->method('getAddressBooksForUser') + ->with('principals/users/john.doe') + ->willReturn([ + [ + 'id' => 99, + 'principaluri' => 'principals/users/john.doe', + 'uri' => 'addressbook-uri-99', + ], [ + 'id' => 123, + 'principaluri' => 'principals/users/john.doe', + 'uri' => 'addressbook-uri-123', + ] + ]); + $this->backend->expects($this->once()) + ->method('searchPrincipalUri') + ->with('principals/users/john.doe', 'search term', + ['N', 'FN', 'NICKNAME', 'EMAIL', 'ADR'], + ['limit' => 5, 'offset' => 20]) + ->willReturn([ + [ + 'addressbookid' => 99, + 'uri' => 'vcard0.vcf', + 'carddata' => $this->vcardTest0, + ], + [ + 'addressbookid' => 123, + 'uri' => 'vcard1.vcf', + 'carddata' => $this->vcardTest1, + ], + ]); + + $provider = $this->getMockBuilder(ContactsSearchProvider::class) + ->setConstructorArgs([ + $this->appManager, + $this->l10n, + $this->urlGenerator, + $this->backend, + ]) + ->setMethods([ + 'getDavUrlForContact', + 'getDeepLinkToContactsApp', + 'generateSubline', + ]) + ->getMock(); + + $provider->expects($this->once()) + ->method('getDavUrlForContact') + ->with('principals/users/john.doe', 'addressbook-uri-123', 'vcard1.vcf') + ->willReturn('absolute-thumbnail-url'); + + $provider->expects($this->exactly(2)) + ->method('generateSubline') + ->willReturn('subline'); + $provider->expects($this->exactly(2)) + ->method('getDeepLinkToContactsApp') + ->withConsecutive( + ['addressbook-uri-99', 'Test'], + ['addressbook-uri-123', 'Test2'] + ) + ->willReturn('deep-link-to-contacts'); + + $actual = $provider->search($user, $query); + $data = $actual->jsonSerialize(); + $this->assertInstanceOf(SearchResult::class, $actual); + $this->assertEquals('Contacts', $data['name']); + $this->assertCount(2, $data['entries']); + $this->assertTrue($data['isPaginated']); + $this->assertEquals(22, $data['cursor']); + + $result0 = $data['entries'][0]; + $result0Data = $result0->jsonSerialize(); + $result1 = $data['entries'][1]; + $result1Data = $result1->jsonSerialize(); + + $this->assertInstanceOf(ContactsSearchResultEntry::class, $result0); + $this->assertEquals('', $result0Data['thumbnailUrl']); + $this->assertEquals('FN of Test', $result0Data['title']); + $this->assertEquals('subline', $result0Data['subline']); + $this->assertEquals('deep-link-to-contacts', $result0Data['resourceUrl']); + // TODO: uncomment once rebased +// $this->assertEquals('icon-contacts-dark', $result0Data['iconClass']); +// $this->assertTrue($result0Data['rounded']); + + $this->assertInstanceOf(ContactsSearchResultEntry::class, $result0); + $this->assertEquals('absolute-thumbnail-url?photo', $result1Data['thumbnailUrl']); + $this->assertEquals('FN of Test2', $result1Data['title']); + $this->assertEquals('subline', $result1Data['subline']); + $this->assertEquals('deep-link-to-contacts', $result1Data['resourceUrl']); + // TODO: uncomment once rebased +// $this->assertEquals('icon-contacts-dark', $result1Data['iconClass']); +// $this->assertTrue($result1Data['rounded']); + } + + public function testGetDavUrlForContact(): void { + $this->urlGenerator->expects($this->once()) + ->method('linkTo') + ->with('', 'remote.php') + ->willReturn('link-to-remote.php'); + $this->urlGenerator->expects($this->once()) + ->method('getAbsoluteURL') + ->with('link-to-remote.php/dav/addressbooks/users/john.doe/foo/bar.vcf') + ->willReturn('absolute-url-link-to-remote.php/dav/addressbooks/users/john.doe/foo/bar.vcf'); + + $actual = self::invokePrivate($this->provider, 'getDavUrlForContact', ['principals/users/john.doe', 'foo', 'bar.vcf']); + + $this->assertEquals('absolute-url-link-to-remote.php/dav/addressbooks/users/john.doe/foo/bar.vcf', $actual); + } + + public function testGetDeepLinkToContactsApp(): void { + $this->urlGenerator->expects($this->once()) + ->method('linkToRoute') + ->with('contacts.page.index') + ->willReturn('link-to-route-contacts.page.index/'); + // TODO: fix All%20contacts here + $this->urlGenerator->expects($this->once()) + ->method('getAbsoluteURL') + ->with('link-to-route-contacts.page.index/All%20contacts/uid123~uri-john.doe') + ->willReturn('absolute-url-link-to-route-contacts.page.index/All%20contacts/uid123~uri-john.doe'); + + $actual = self::invokePrivate($this->provider, 'getDeepLinkToContactsApp', ['uri-john.doe', 'uid123']); + $this->assertEquals('absolute-url-link-to-route-contacts.page.index/All%20contacts/uid123~uri-john.doe', $actual); + } + + public function testGenerateSubline(): void { + $vCard0 = Reader::read($this->vcardTest0); + $vCard1 = Reader::read($this->vcardTest1); + + $actual1 = self::invokePrivate($this->provider, 'generateSubline', [$vCard0]); + $actual2 = self::invokePrivate($this->provider, 'generateSubline', [$vCard1]); + + $this->assertEquals('forrestgump@example.com', $actual1); + $this->assertEquals('', $actual2); + } +}