From: Christoph Wurst Date: Mon, 11 May 2020 08:35:54 +0000 (+0200) Subject: Add unified search API X-Git-Tag: v20.0.0beta1~371^2~3 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=4488e846a526ed8de37e9756621b7c008d7a9466;p=nextcloud-server.git Add unified search API Signed-off-by: Christoph Wurst --- diff --git a/apps/files/lib/Search/FilesSearchProvider.php b/apps/files/lib/Search/FilesSearchProvider.php new file mode 100644 index 00000000000..0a360d6f81c --- /dev/null +++ b/apps/files/lib/Search/FilesSearchProvider.php @@ -0,0 +1,72 @@ + + * + * @author 2020 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\Files\Search; + +use OCP\IL10N; +use OCP\IUser; +use OCP\Search\IProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; + +class FilesSearchProvider implements IProvider { + + /** @var IL10N */ + private $l10n; + + public function __construct(IL10N $l10n) { + $this->l10n = $l10n; + } + + public function getId(): string { + return 'files'; + } + + public function search(IUser $user, ISearchQuery $query): SearchResult { + return SearchResult::complete( + $this->l10n->t('Files'), + [ + new FilesSearchResultEntry( + "path/to/icon.png", + "cute cats.jpg", + "/Cats", + "/f/21156" + ), + new FilesSearchResultEntry( + "path/to/icon.png", + "cat 1.jpg", + "/Cats", + "/f/21192" + ), + new FilesSearchResultEntry( + "path/to/icon.png", + "cat 2.jpg", + "/Cats", + "/f/25942" + ), + ] + ); + } +} diff --git a/core/Controller/UnifiedSearchController.php b/core/Controller/UnifiedSearchController.php new file mode 100644 index 00000000000..ddb1745dea5 --- /dev/null +++ b/core/Controller/UnifiedSearchController.php @@ -0,0 +1,98 @@ + + * + * @author 2020 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 OC\Core\Controller; + +use OC\Search\SearchComposer; +use OC\Search\SearchQuery; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\Search\ISearchQuery; + +class UnifiedSearchController extends Controller { + + /** @var SearchComposer */ + private $composer; + + /** @var IUserSession */ + private $userSession; + + public function __construct(IRequest $request, + IUserSession $userSession, + SearchComposer $composer) { + parent::__construct('core', $request); + + $this->composer = $composer; + $this->userSession = $userSession; + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function getProviders(): JSONResponse { + return new JSONResponse( + $this->composer->getProviders() + ); + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $providerId + * @param string $term + * @param int|null $sortOrder + * @param int|null $limit + * @param int|string|null $cursor + * + * @return JSONResponse + */ + public function search(string $providerId, + string $term = '', + ?int $sortOrder = null, + ?int $limit = null, + $cursor = null): JSONResponse { + if (empty($term)) { + return new JSONResponse(null, Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse( + $this->composer->search( + $this->userSession->getUser(), + $providerId, + new SearchQuery( + $term, + $sortOrder ?? ISearchQuery::SORT_DATE_DESC, + $limit ?? SearchQuery::LIMIT_DEFAULT, + $cursor + ) + ) + ); + } +} diff --git a/core/routes.php b/core/routes.php index 3d30b12e392..5bb58f63e45 100644 --- a/core/routes.php +++ b/core/routes.php @@ -77,6 +77,8 @@ $application->registerRoutes($this, [ ['name' => 'RecommendedApps#index', 'url' => '/core/apps/recommended', 'verb' => 'GET'], ['name' => 'Svg#getSvgFromCore', 'url' => '/svg/core/{folder}/{fileName}', 'verb' => 'GET'], ['name' => 'Svg#getSvgFromApp', 'url' => '/svg/{app}/{fileName}', 'verb' => 'GET'], + ['name' => 'UnifiedSearch#getProviders', 'url' => '/search/providers', 'verb' => 'GET'], + ['name' => 'UnifiedSearch#search', 'url' => '/search/providers/{providerId}/search', 'verb' => 'GET'], ['name' => 'Css#getCss', 'url' => '/css/{appName}/{fileName}', 'verb' => 'GET'], ['name' => 'Js#getJs', 'url' => '/js/{appName}/{fileName}', 'verb' => 'GET'], ['name' => 'contactsMenu#index', 'url' => '/contactsmenu/contacts', 'verb' => 'POST'], diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index e405d25ee66..b51c9876e5d 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -432,9 +432,13 @@ return array( 'OCP\\Route\\IRouter' => $baseDir . '/lib/public/Route/IRouter.php', 'OCP\\SabrePluginEvent' => $baseDir . '/lib/public/SabrePluginEvent.php', 'OCP\\SabrePluginException' => $baseDir . '/lib/public/SabrePluginException.php', + 'OCP\\Search\\ASearchResultEntry' => $baseDir . '/lib/public/Search/ASearchResultEntry.php', + 'OCP\\Search\\IProvider' => $baseDir . '/lib/public/Search/IProvider.php', + 'OCP\\Search\\ISearchQuery' => $baseDir . '/lib/public/Search/ISearchQuery.php', 'OCP\\Search\\PagedProvider' => $baseDir . '/lib/public/Search/PagedProvider.php', 'OCP\\Search\\Provider' => $baseDir . '/lib/public/Search/Provider.php', 'OCP\\Search\\Result' => $baseDir . '/lib/public/Search/Result.php', + 'OCP\\Search\\SearchResult' => $baseDir . '/lib/public/Search/SearchResult.php', 'OCP\\Security\\CSP\\AddContentSecurityPolicyEvent' => $baseDir . '/lib/public/Security/CSP/AddContentSecurityPolicyEvent.php', 'OCP\\Security\\Events\\GenerateSecurePasswordEvent' => $baseDir . '/lib/public/Security/Events/GenerateSecurePasswordEvent.php', 'OCP\\Security\\Events\\ValidatePasswordPolicyEvent' => $baseDir . '/lib/public/Security/Events/ValidatePasswordPolicyEvent.php', @@ -853,6 +857,7 @@ return array( 'OC\\Core\\Controller\\SetupController' => $baseDir . '/core/Controller/SetupController.php', 'OC\\Core\\Controller\\SvgController' => $baseDir . '/core/Controller/SvgController.php', 'OC\\Core\\Controller\\TwoFactorChallengeController' => $baseDir . '/core/Controller/TwoFactorChallengeController.php', + 'OC\\Core\\Controller\\UnifiedSearchController' => $baseDir . '/core/Controller/UnifiedSearchController.php', 'OC\\Core\\Controller\\UserController' => $baseDir . '/core/Controller/UserController.php', 'OC\\Core\\Controller\\WalledGardenController' => $baseDir . '/core/Controller/WalledGardenController.php', 'OC\\Core\\Controller\\WebAuthnController' => $baseDir . '/core/Controller/WebAuthnController.php', @@ -1233,6 +1238,8 @@ return array( 'OC\\Search\\Result\\File' => $baseDir . '/lib/private/Search/Result/File.php', 'OC\\Search\\Result\\Folder' => $baseDir . '/lib/private/Search/Result/Folder.php', 'OC\\Search\\Result\\Image' => $baseDir . '/lib/private/Search/Result/Image.php', + 'OC\\Search\\SearchComposer' => $baseDir . '/lib/private/Search/SearchComposer.php', + 'OC\\Search\\SearchQuery' => $baseDir . '/lib/private/Search/SearchQuery.php', 'OC\\Security\\Bruteforce\\Capabilities' => $baseDir . '/lib/private/Security/Bruteforce/Capabilities.php', 'OC\\Security\\Bruteforce\\Throttler' => $baseDir . '/lib/private/Security/Bruteforce/Throttler.php', 'OC\\Security\\CSP\\ContentSecurityPolicy' => $baseDir . '/lib/private/Security/CSP/ContentSecurityPolicy.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 456fa81087d..2f640e014f3 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -461,9 +461,13 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OCP\\Route\\IRouter' => __DIR__ . '/../../..' . '/lib/public/Route/IRouter.php', 'OCP\\SabrePluginEvent' => __DIR__ . '/../../..' . '/lib/public/SabrePluginEvent.php', 'OCP\\SabrePluginException' => __DIR__ . '/../../..' . '/lib/public/SabrePluginException.php', + 'OCP\\Search\\ASearchResultEntry' => __DIR__ . '/../../..' . '/lib/public/Search/ASearchResultEntry.php', + 'OCP\\Search\\IProvider' => __DIR__ . '/../../..' . '/lib/public/Search/IProvider.php', + 'OCP\\Search\\ISearchQuery' => __DIR__ . '/../../..' . '/lib/public/Search/ISearchQuery.php', 'OCP\\Search\\PagedProvider' => __DIR__ . '/../../..' . '/lib/public/Search/PagedProvider.php', 'OCP\\Search\\Provider' => __DIR__ . '/../../..' . '/lib/public/Search/Provider.php', 'OCP\\Search\\Result' => __DIR__ . '/../../..' . '/lib/public/Search/Result.php', + 'OCP\\Search\\SearchResult' => __DIR__ . '/../../..' . '/lib/public/Search/SearchResult.php', 'OCP\\Security\\CSP\\AddContentSecurityPolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/CSP/AddContentSecurityPolicyEvent.php', 'OCP\\Security\\Events\\GenerateSecurePasswordEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/GenerateSecurePasswordEvent.php', 'OCP\\Security\\Events\\ValidatePasswordPolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/ValidatePasswordPolicyEvent.php', @@ -882,6 +886,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Core\\Controller\\SetupController' => __DIR__ . '/../../..' . '/core/Controller/SetupController.php', 'OC\\Core\\Controller\\SvgController' => __DIR__ . '/../../..' . '/core/Controller/SvgController.php', 'OC\\Core\\Controller\\TwoFactorChallengeController' => __DIR__ . '/../../..' . '/core/Controller/TwoFactorChallengeController.php', + 'OC\\Core\\Controller\\UnifiedSearchController' => __DIR__ . '/../../..' . '/core/Controller/UnifiedSearchController.php', 'OC\\Core\\Controller\\UserController' => __DIR__ . '/../../..' . '/core/Controller/UserController.php', 'OC\\Core\\Controller\\WalledGardenController' => __DIR__ . '/../../..' . '/core/Controller/WalledGardenController.php', 'OC\\Core\\Controller\\WebAuthnController' => __DIR__ . '/../../..' . '/core/Controller/WebAuthnController.php', @@ -1262,6 +1267,8 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Search\\Result\\File' => __DIR__ . '/../../..' . '/lib/private/Search/Result/File.php', 'OC\\Search\\Result\\Folder' => __DIR__ . '/../../..' . '/lib/private/Search/Result/Folder.php', 'OC\\Search\\Result\\Image' => __DIR__ . '/../../..' . '/lib/private/Search/Result/Image.php', + 'OC\\Search\\SearchComposer' => __DIR__ . '/../../..' . '/lib/private/Search/SearchComposer.php', + 'OC\\Search\\SearchQuery' => __DIR__ . '/../../..' . '/lib/private/Search/SearchQuery.php', 'OC\\Security\\Bruteforce\\Capabilities' => __DIR__ . '/../../..' . '/lib/private/Security/Bruteforce/Capabilities.php', 'OC\\Security\\Bruteforce\\Throttler' => __DIR__ . '/../../..' . '/lib/private/Security/Bruteforce/Throttler.php', 'OC\\Security\\CSP\\ContentSecurityPolicy' => __DIR__ . '/../../..' . '/lib/private/Security/CSP/ContentSecurityPolicy.php', diff --git a/lib/private/AppFramework/Bootstrap/Coordinator.php b/lib/private/AppFramework/Bootstrap/Coordinator.php index 9b0f6a9188c..085e7460da6 100644 --- a/lib/private/AppFramework/Bootstrap/Coordinator.php +++ b/lib/private/AppFramework/Bootstrap/Coordinator.php @@ -25,6 +25,7 @@ declare(strict_types=1); namespace OC\AppFramework\Bootstrap; +use OC\Search\SearchComposer; use OC\Support\CrashReport\Registry; use OC_App; use OCP\AppFramework\App; @@ -49,16 +50,21 @@ class Coordinator { /** @var IEventDispatcher */ private $eventDispatcher; + /** @var SearchComposer */ + private $searchComposer; + /** @var ILogger */ private $logger; public function __construct(IServerContainer $container, Registry $registry, IEventDispatcher $eventListener, + SearchComposer $searchComposer, ILogger $logger) { $this->serverContainer = $container; $this->registry = $registry; $this->eventDispatcher = $eventListener; + $this->searchComposer = $searchComposer; $this->logger = $logger; } @@ -112,6 +118,7 @@ class Coordinator { $context->delegateEventListenerRegistrations($this->eventDispatcher); $context->delegateContainerRegistrations($apps); $context->delegateMiddlewareRegistrations($apps); + $context->delegateSearchProviderRegistration($apps, $this->searchComposer); } public function bootApp(string $appId): void { diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index 340012c8b1b..23eee9c6e33 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -26,6 +26,7 @@ declare(strict_types=1); namespace OC\AppFramework\Bootstrap; use Closure; +use OC\Search\SearchComposer; use OC\Support\CrashReport\Registry; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IRegistrationContext; @@ -56,6 +57,9 @@ class RegistrationContext { /** @var array[] */ private $middlewares = []; + /** @var array[] */ + private $searchProviders = []; + /** @var ILogger */ private $logger; @@ -130,6 +134,13 @@ class RegistrationContext { $class ); } + + public function registerSearchProvider(string $class): void { + $this->context->registerSearchProvider( + $this->appId, + $class + ); + } }; } @@ -188,6 +199,13 @@ class RegistrationContext { ]; } + public function registerSearchProvider(string $appId, string $class) { + $this->searchProviders[] = [ + 'appId' => $appId, + 'class' => $class, + ]; + } + /** * @param App[] $apps */ @@ -327,4 +345,21 @@ class RegistrationContext { } } } + + /** + * @param App[] $apps + */ + public function delegateSearchProviderRegistration(array $apps, SearchComposer $searchComposer): void { + foreach ($this->searchProviders as $registration) { + try { + $searchComposer->registerProvider($registration['class']); + } catch (Throwable $e) { + $appId = $registration['appId']; + $this->logger->logException($e, [ + 'message' => "Error during search provider registration of $appId: " . $e->getMessage(), + 'level' => ILogger::ERROR, + ]); + } + } + } } diff --git a/lib/private/Search/SearchComposer.php b/lib/private/Search/SearchComposer.php new file mode 100644 index 00000000000..f8369292103 --- /dev/null +++ b/lib/private/Search/SearchComposer.php @@ -0,0 +1,107 @@ + + * + * @author 2020 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 OC\Search; + +use OCP\AppFramework\QueryException; +use OCP\ILogger; +use OCP\IServerContainer; +use OCP\IUser; +use OCP\Search\IProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; +use function array_map; + +/** + * Queries individual \OCP\Search\IProvider implementations and composes a + * unified search result for the user's search term + */ +class SearchComposer { + + /** @var string[] */ + private $lazyProviders = []; + + /** @var IProvider[] */ + private $providers = []; + + /** @var IServerContainer */ + private $container; + + /** @var ILogger */ + private $logger; + + public function __construct(IServerContainer $container, + ILogger $logger) { + $this->container = $container; + $this->logger = $logger; + } + + public function registerProvider(string $class): void { + $this->lazyProviders[] = $class; + } + + /** + * Load all providers dynamically that were registered through `registerProvider` + * + * If a provider can't be loaded we log it but the operation continues nevertheless + */ + private function loadLazyProviders(): void { + $classes = $this->lazyProviders; + foreach ($classes as $class) { + try { + /** @var IProvider $provider */ + $provider = $this->container->query($class); + $this->providers[$provider->getId()] = $provider; + } catch (QueryException $e) { + // Log an continue. We can be fault tolerant here. + $this->logger->logException($e, [ + 'message' => 'Could not load search provider dynamically: ' . $e->getMessage(), + 'level' => ILogger::ERROR, + ]); + } + } + $this->lazyProviders = []; + } + + public function getProviders(): array { + $this->loadLazyProviders(); + + /** + * Return an array with the IDs, but strip the associative keys + */ + return array_values( + array_map(function (IProvider $provider) { + return $provider->getId(); + }, $this->providers)); + } + + public function search(IUser $user, + string $providerId, + ISearchQuery $query): SearchResult { + $this->loadLazyProviders(); + + return $this->providers[$providerId]->search($user, $query); + } +} diff --git a/lib/private/Search/SearchQuery.php b/lib/private/Search/SearchQuery.php new file mode 100644 index 00000000000..2ed31fed441 --- /dev/null +++ b/lib/private/Search/SearchQuery.php @@ -0,0 +1,88 @@ + + * + * @author 2020 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 OC\Search; + +use OCP\Search\ISearchQuery; + +class SearchQuery implements ISearchQuery { + public const LIMIT_DEFAULT = 20; + + /** @var string */ + private $term; + + /** @var int */ + private $sortOrder; + + /** @var int */ + private $limit; + + /** @var int|string|null */ + private $cursor; + + /** + * @param string $term + * @param int $sortOrder + * @param int $limit + * @param int|string|null $cursor + */ + public function __construct(string $term, + int $sortOrder = ISearchQuery::SORT_DATE_DESC, + int $limit = self::LIMIT_DEFAULT, + $cursor = null) { + $this->term = $term; + $this->sortOrder = $sortOrder; + $this->limit = $limit; + $this->cursor = $cursor; + } + + /** + * @inheritDoc + */ + public function getTerm(): string { + return $this->term; + } + + /** + * @inheritDoc + */ + public function getSortOrder(): int { + return $this->sortOrder; + } + + /** + * @inheritDoc + */ + public function getLimit(): int { + return $this->limit; + } + + /** + * @inheritDoc + */ + public function getCursor() { + return $this->cursor; + } +} diff --git a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php index 5d3ffc8d479..12367e5ed05 100644 --- a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php +++ b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php @@ -115,4 +115,19 @@ interface IRegistrationContext { * @since 20.0.0 */ public function registerMiddleware(string $class): void; + + /** + * Register a search provider for the unified search + * + * It is allowed to register more than one provider per app as the search + * results can go into distinct sections, e.g. "Files" and "Files shared + * with you" in the Files app. + * + * @param string $class + * + * @return void + * + * @since 20.0.0 + */ + public function registerSearchProvider(string $class): void; } diff --git a/lib/public/Search/ASearchResultEntry.php b/lib/public/Search/ASearchResultEntry.php new file mode 100644 index 00000000000..45d62525abd --- /dev/null +++ b/lib/public/Search/ASearchResultEntry.php @@ -0,0 +1,102 @@ + + * + * @author 2020 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 OCP\Search; + +use JsonSerializable; + +/** + * Represents an entry in a list of results an app returns for a unified search + * query. + * + * The app providing the results has to extend this class for customization. In + * most cases apps do not have to add any additional code. + * + * @example ``class MailResultEntry extends ASearchResultEntry {}` + * + * This approach was chosen over a final class as it allows Nextcloud to later + * add new optional properties of an entry without having to break the usage of + * this class in apps. + * + * @since 20.0.0 + */ +abstract class ASearchResultEntry implements JsonSerializable { + + /** + * @var string + * @since 20.0.0 + */ + protected $thumbnailUrl; + + /** + * @var string + * @since 20.0.0 + */ + protected $title; + + /** + * @var string + * @since 20.0.0 + */ + protected $subline; + + /** + * @var string + * @since 20.0.0 + */ + protected $resourceUrl; + + /** + * @param string $thumbnailUrl a relative or absolute URL to the thumbnail or icon of the entry + * @param string $title a main title of the entry + * @param string $subline the secondary line of the entry + * @param string $resourceUrl the URL where the user can find the detail, like a deep link inside the app + * + * @since 20.0.0 + */ + public function __construct(string $thumbnailUrl, + string $title, + string $subline, + string $resourceUrl) { + $this->thumbnailUrl = $thumbnailUrl; + $this->title = $title; + $this->subline = $subline; + $this->resourceUrl = $resourceUrl; + } + + /** + * @return array + * + * @since 20.0.0 + */ + public function jsonSerialize(): array { + return [ + 'thumbnailUrl' => $this->thumbnailUrl, + 'title' => $this->title, + 'subline' => $this->subline, + 'resourceUrl' => $this->resourceUrl, + ]; + } +} diff --git a/lib/public/Search/IProvider.php b/lib/public/Search/IProvider.php new file mode 100644 index 00000000000..57343eda0e5 --- /dev/null +++ b/lib/public/Search/IProvider.php @@ -0,0 +1,83 @@ + + * + * @author 2020 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 OCP\Search; + +use OCP\IUser; + +/** + * Interface for an app search providers + * + * These providers will be implemented in apps, so they can participate in the + * global search results of Nextcloud. If an app provides more than one type of + * resource, e.g. contacts and address books in Nextcloud Contacts, it should + * register one provider per group. + * + * @since 20.0.0 + */ +interface IProvider { + + /** + * Get the unique ID of this search provider + * + * Ideally this should be the app name or an identifier identified with the + * app name, especially if the app registers more than one provider. + * + * Example: 'mail', 'mail_recipients', 'files_sharing' + * + * @return string + * + * @since 20.0.0 + */ + public function getId(): string; + + /** + * Find matching search entries in an app + * + * Search results can either be a complete list of all the matches the app can + * find, or ideally a paginated result set where more data can be fetched on + * demand. To be able to tell where the next offset starts the search uses + * "cursors" which are a property of the last result entry. E.g. search results + * that show most recent entries first can look for entries older than the last + * one of the first result set. This approach was chosen over a numeric limit/ + * offset approach as the offset moves as new data comes in. The cursor is + * resistant to these changes and will still show results without overlaps or + * gaps. + * + * See https://dev.to/jackmarchant/offset-and-cursor-pagination-explained-b89 + * for the concept of cursors. + * + * Implementations that return result pages have to adhere to the limit + * property of a search query. + * + * @param IUser $user + * @param ISearchQuery $query + * + * @return SearchResult + * + * @since 20.0.0 + */ + public function search(IUser $user, ISearchQuery $query): SearchResult; +} diff --git a/lib/public/Search/ISearchQuery.php b/lib/public/Search/ISearchQuery.php new file mode 100644 index 00000000000..00d538050d4 --- /dev/null +++ b/lib/public/Search/ISearchQuery.php @@ -0,0 +1,79 @@ + + * + * @author 2020 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 OCP\Search; + +/** + * The query objected passed into \OCP\Search\IProvider::search + * + * This mainly wraps the search term, but will ensure that Nextcloud can add new + * optional properties to a search request without having break the interface of + * \OCP\Search\IProvider::search. + * + * @see \OCP\Search\IProvider::search + * + * @since 20.0.0 + */ +interface ISearchQuery { + + /** + * @since 20.0.0 + */ + public const SORT_DATE_DESC = 1; + + /** + * Get the user-entered search term to find matches for + * + * @return string the search term + * @since 20.0.0 + */ + public function getTerm(): string; + + /** + * Get the sort order of results as defined as SORT_* constants on this interface + * + * @return int + * @since 20.0.0 + */ + public function getSortOrder(): int; + + /** + * Get the number of items to return for a paginated result + * + * @return int + * @see \OCP\Search\IProvider for details + * @since 20.0.0 + */ + public function getLimit(): int; + + /** + * Get the app-specific cursor of the tail of the previous result entries + * + * @return int|string|null + * @see \OCP\Search\IProvider for details + * @since 20.0.0 + */ + public function getCursor(); +} diff --git a/lib/public/Search/SearchResult.php b/lib/public/Search/SearchResult.php new file mode 100644 index 00000000000..7abb5b9f188 --- /dev/null +++ b/lib/public/Search/SearchResult.php @@ -0,0 +1,112 @@ + + * + * @author 2020 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 OCP\Search; + +use JsonSerializable; + +/** + * @since 20.0.0 + */ +final class SearchResult implements JsonSerializable { + + /** @var string */ + private $name; + + /** @var bool */ + private $isPaginated; + + /** @var ASearchResultEntry[] */ + private $entries; + + /** @var int|string|null */ + private $cursor; + + /** + * @param string $name the translated name of the result section or group, e.g. "Mail" + * @param bool $isPaginated + * @param ASearchResultEntry[] $entries + * @param null $cursor + * + * @since 20.0.0 + */ + private function __construct(string $name, + bool $isPaginated, + array $entries, + $cursor = null) { + $this->name = $name; + $this->isPaginated = $isPaginated; + $this->entries = $entries; + $this->cursor = $cursor; + } + + /** + * @param ASearchResultEntry[] $entries + * + * @return static + * + * @since 20.0.0 + */ + public static function complete(string $name, array $entries): self { + return new self( + $name, + false, + $entries + ); + } + + /** + * @param ASearchResultEntry[] $entries + * @param int|string $cursor + * + * @return static + * + * @since 20.0.0 + */ + public static function paginated(string $name, + array $entries, + $cursor): self { + return new self( + $name, + true, + $entries, + $cursor + ); + } + + /** + * @return array + * + * @since 20.0.0 + */ + public function jsonSerialize(): array { + return [ + 'name' => $this->name, + 'isPaginated' => $this->isPaginated, + 'entries' => $this->entries, + 'cursor' => $this->cursor, + ]; + } +} diff --git a/tests/lib/AppFramework/Bootstrap/CoordinatorTest.php b/tests/lib/AppFramework/Bootstrap/CoordinatorTest.php index c12e5eeb150..6909ad94e7f 100644 --- a/tests/lib/AppFramework/Bootstrap/CoordinatorTest.php +++ b/tests/lib/AppFramework/Bootstrap/CoordinatorTest.php @@ -26,6 +26,7 @@ declare(strict_types=1); namespace lib\AppFramework\Bootstrap; use OC\AppFramework\Bootstrap\Coordinator; +use OC\Search\SearchComposer; use OC\Support\CrashReport\Registry; use OCP\App\IAppManager; use OCP\AppFramework\App; @@ -53,6 +54,9 @@ class CoordinatorTest extends TestCase { /** @var IEventDispatcher|MockObject */ private $eventDispatcher; + /** @var SearchComposer|MockObject */ + private $searchComposer; + /** @var ILogger|MockObject */ private $logger; @@ -66,12 +70,14 @@ class CoordinatorTest extends TestCase { $this->serverContainer = $this->createMock(IServerContainer::class); $this->crashReporterRegistry = $this->createMock(Registry::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->searchComposer = $this->createMock(SearchComposer::class); $this->logger = $this->createMock(ILogger::class); $this->coordinator = new Coordinator( $this->serverContainer, $this->crashReporterRegistry, $this->eventDispatcher, + $this->searchComposer, $this->logger ); }