Browse Source
Implement Contacts Backend for Unified Search
Implement Contacts Backend for Unified Search
Signed-off-by: Georg Ehrke <developer@georgehrke.com>pull/22011/head
committed by
John Molakvoæ (skjnldsv)
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
7 changed files with 557 additions and 8 deletions
-
2apps/dav/composer/composer/autoload_classmap.php
-
2apps/dav/composer/composer/autoload_static.php
-
6apps/dav/lib/AppInfo/Application.php
-
61apps/dav/lib/CardDAV/CardDavBackend.php
-
189apps/dav/lib/Search/ContactsSearchProvider.php
-
30apps/dav/lib/Search/ContactsSearchResultEntry.php
-
275apps/dav/tests/unit/Search/ContactsSearchProviderTest.php
@ -0,0 +1,189 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
/** |
|||
* @copyright Copyright (c) 2020, Georg Ehrke |
|||
* |
|||
* @author Georg Ehrke <oc.list@georgehrke.com> |
|||
* |
|||
* @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 <http://www.gnu.org/licenses/> |
|||
* |
|||
*/ |
|||
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 'contacts-dav'; |
|||
} |
|||
|
|||
/** |
|||
* @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]; |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
/** |
|||
* @copyright Copyright (c) 2020, Georg Ehrke |
|||
* |
|||
* @author Georg Ehrke <oc.list@georgehrke.com> |
|||
* |
|||
* @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 <http://www.gnu.org/licenses/> |
|||
* |
|||
*/ |
|||
namespace OCA\DAV\Search; |
|||
|
|||
use OCP\Search\ASearchResultEntry; |
|||
|
|||
class ContactsSearchResultEntry extends ASearchResultEntry { |
|||
} |
|||
@ -0,0 +1,275 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
/** |
|||
* @copyright Copyright (c) 2020, Georg Ehrke |
|||
* |
|||
* @author Georg Ehrke <oc.list@georgehrke.com> |
|||
* |
|||
* @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 <http://www.gnu.org/licenses/> |
|||
* |
|||
*/ |
|||
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('contacts-dav', $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']); |
|||
$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']); |
|||
$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.contacts.direct', ['contact' => 'uid123~uri-john.doe']) |
|||
->willReturn('link-to-route-contacts.contacts.direct/direct/uid123~uri-john.doe'); |
|||
$this->urlGenerator->expects($this->once()) |
|||
->method('getAbsoluteURL') |
|||
->with('link-to-route-contacts.contacts.direct/direct/uid123~uri-john.doe') |
|||
->willReturn('absolute-url-link-to-route-contacts.contacts.direct/direct/uid123~uri-john.doe'); |
|||
|
|||
$actual = self::invokePrivate($this->provider, 'getDeepLinkToContactsApp', ['uri-john.doe', 'uid123']); |
|||
$this->assertEquals('absolute-url-link-to-route-contacts.contacts.direct/direct/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); |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue