From 4488e846a526ed8de37e9756621b7c008d7a9466 Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Mon, 11 May 2020 10:35:54 +0200 Subject: [PATCH 1/4] Add unified search API Signed-off-by: Christoph Wurst --- apps/files/lib/Search/FilesSearchProvider.php | 72 +++++++++++ core/Controller/UnifiedSearchController.php | 98 +++++++++++++++ core/routes.php | 2 + lib/composer/composer/autoload_classmap.php | 7 ++ lib/composer/composer/autoload_static.php | 7 ++ .../AppFramework/Bootstrap/Coordinator.php | 7 ++ .../Bootstrap/RegistrationContext.php | 35 ++++++ lib/private/Search/SearchComposer.php | 107 +++++++++++++++++ lib/private/Search/SearchQuery.php | 88 ++++++++++++++ .../Bootstrap/IRegistrationContext.php | 15 +++ lib/public/Search/ASearchResultEntry.php | 102 ++++++++++++++++ lib/public/Search/IProvider.php | 83 +++++++++++++ lib/public/Search/ISearchQuery.php | 79 ++++++++++++ lib/public/Search/SearchResult.php | 112 ++++++++++++++++++ .../Bootstrap/CoordinatorTest.php | 6 + 15 files changed, 820 insertions(+) create mode 100644 apps/files/lib/Search/FilesSearchProvider.php create mode 100644 core/Controller/UnifiedSearchController.php create mode 100644 lib/private/Search/SearchComposer.php create mode 100644 lib/private/Search/SearchQuery.php create mode 100644 lib/public/Search/ASearchResultEntry.php create mode 100644 lib/public/Search/IProvider.php create mode 100644 lib/public/Search/ISearchQuery.php create mode 100644 lib/public/Search/SearchResult.php 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 ); } From f8e08a74bac4b065edcade762e2ca3632ff76797 Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Wed, 17 Jun 2020 10:29:50 +0200 Subject: [PATCH 2/4] Implement unified search for Files Signed-off-by: Christoph Wurst --- .../composer/composer/autoload_classmap.php | 2 + .../composer/composer/autoload_static.php | 2 + apps/files/lib/AppInfo/Application.php | 3 ++ apps/files/lib/Search/FilesSearchProvider.php | 43 ++++++++-------- .../lib/Search/FilesSearchResultEntry.php | 37 ++++++++++++++ lib/private/Search/SearchComposer.php | 49 ++++++++++++++++++- lib/public/Search/IProvider.php | 2 +- 7 files changed, 115 insertions(+), 23 deletions(-) create mode 100644 apps/files/lib/Search/FilesSearchResultEntry.php diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index a4a72d59c13..04c24ee52d9 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -47,6 +47,8 @@ return array( 'OCA\\Files\\Listener\\LoadSidebarListener' => $baseDir . '/../lib/Listener/LoadSidebarListener.php', 'OCA\\Files\\Migration\\Version11301Date20191205150729' => $baseDir . '/../lib/Migration/Version11301Date20191205150729.php', 'OCA\\Files\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', + 'OCA\\Files\\Search\\FilesSearchProvider' => $baseDir . '/../lib/Search/FilesSearchProvider.php', + 'OCA\\Files\\Search\\FilesSearchResultEntry' => $baseDir . '/../lib/Search/FilesSearchResultEntry.php', 'OCA\\Files\\Service\\DirectEditingService' => $baseDir . '/../lib/Service/DirectEditingService.php', 'OCA\\Files\\Service\\OwnershipTransferService' => $baseDir . '/../lib/Service/OwnershipTransferService.php', 'OCA\\Files\\Service\\TagService' => $baseDir . '/../lib/Service/TagService.php', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 91e29fac487..f9050eca862 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -62,6 +62,8 @@ class ComposerStaticInitFiles 'OCA\\Files\\Listener\\LoadSidebarListener' => __DIR__ . '/..' . '/../lib/Listener/LoadSidebarListener.php', 'OCA\\Files\\Migration\\Version11301Date20191205150729' => __DIR__ . '/..' . '/../lib/Migration/Version11301Date20191205150729.php', 'OCA\\Files\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', + 'OCA\\Files\\Search\\FilesSearchProvider' => __DIR__ . '/..' . '/../lib/Search/FilesSearchProvider.php', + 'OCA\\Files\\Search\\FilesSearchResultEntry' => __DIR__ . '/..' . '/../lib/Search/FilesSearchResultEntry.php', 'OCA\\Files\\Service\\DirectEditingService' => __DIR__ . '/..' . '/../lib/Service/DirectEditingService.php', 'OCA\\Files\\Service\\OwnershipTransferService' => __DIR__ . '/..' . '/../lib/Service/OwnershipTransferService.php', 'OCA\\Files\\Service\\TagService' => __DIR__ . '/..' . '/../lib/Service/TagService.php', diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index 82562ffe9f0..5e473c411ee 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -44,6 +44,7 @@ use OCA\Files\Event\LoadSidebar; use OCA\Files\Listener\LegacyLoadAdditionalScriptsAdapter; use OCA\Files\Listener\LoadSidebarListener; use OCA\Files\Notification\Notifier; +use OCA\Files\Search\FilesSearchProvider; use OCA\Files\Service\TagService; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -106,6 +107,8 @@ class Application extends App implements IBootstrap { $context->registerEventListener(LoadAdditionalScriptsEvent::class, LegacyLoadAdditionalScriptsAdapter::class); $context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class); + + $context->registerSearchProvider(FilesSearchProvider::class); } public function boot(IBootContext $context): void { diff --git a/apps/files/lib/Search/FilesSearchProvider.php b/apps/files/lib/Search/FilesSearchProvider.php index 0a360d6f81c..3f1c4de0aa1 100644 --- a/apps/files/lib/Search/FilesSearchProvider.php +++ b/apps/files/lib/Search/FilesSearchProvider.php @@ -25,7 +25,10 @@ declare(strict_types=1); namespace OCA\Files\Search; +use OC\Search\Provider\File; +use OC\Search\Result\File as FileResult; use OCP\IL10N; +use OCP\IURLGenerator; use OCP\IUser; use OCP\Search\IProvider; use OCP\Search\ISearchQuery; @@ -33,11 +36,21 @@ use OCP\Search\SearchResult; class FilesSearchProvider implements IProvider { + /** @var File */ + private $fileSearch; + /** @var IL10N */ private $l10n; - public function __construct(IL10N $l10n) { + /** @var IURLGenerator */ + private $urlGenerator; + + public function __construct(File $fileSearch, + IL10N $l10n, + IURLGenerator $urlGenerator) { $this->l10n = $l10n; + $this->fileSearch = $fileSearch; + $this->urlGenerator = $urlGenerator; } public function getId(): string { @@ -47,26 +60,14 @@ class FilesSearchProvider implements IProvider { 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" - ), - ] + array_map(function (FileResult $result) { + return new FilesSearchResultEntry( + $this->urlGenerator->linkToRoute('core.Preview.getPreviewByFileId', ['x' => 32, 'y' => 32, 'fileId' => $result->id]), + $result->name, + $result->path, + $result->link + ); + }, $this->fileSearch->search($query->getTerm())) ); } } diff --git a/apps/files/lib/Search/FilesSearchResultEntry.php b/apps/files/lib/Search/FilesSearchResultEntry.php new file mode 100644 index 00000000000..c4f6e491d6f --- /dev/null +++ b/apps/files/lib/Search/FilesSearchResultEntry.php @@ -0,0 +1,37 @@ + + * + * @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\Search\ASearchResultEntry; + +class FilesSearchResultEntry extends ASearchResultEntry { + public function __construct(string $thumbnailUrl, + string $filename, + string $path, + string $url) { + parent::__construct($thumbnailUrl, $filename, $path, $url); + } +} diff --git a/lib/private/Search/SearchComposer.php b/lib/private/Search/SearchComposer.php index f8369292103..ae4350ca5cc 100644 --- a/lib/private/Search/SearchComposer.php +++ b/lib/private/Search/SearchComposer.php @@ -25,6 +25,8 @@ declare(strict_types=1); namespace OC\Search; +use InvalidArgumentException; +use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\QueryException; use OCP\ILogger; use OCP\IServerContainer; @@ -37,6 +39,21 @@ use function array_map; /** * Queries individual \OCP\Search\IProvider implementations and composes a * unified search result for the user's search term + * + * The search process is generally split into two steps + * + * 1. Get a list of provider (`getProviders`) + * 2. Get search results of each provider (`search`) + * + * The reasoning behind this is that the runtime complexity of a combined search + * result would be O(n) and linearly grow with each provider added. This comes + * from the nature of php where we can't concurrently fetch the search results. + * So we offload the concurrency the client application (e.g. JavaScript in the + * browser) and let it first get the list of providers to then fetch all results + * concurrently. The client is free to decide whether all concurrent search + * results are awaited or shown as they come in. + * + * @see IProvider::search() for the arguments of the individual search requests */ class SearchComposer { @@ -58,6 +75,17 @@ class SearchComposer { $this->logger = $logger; } + /** + * Register a search provider lazily + * + * Registers the fully-qualified class name of an implementation of an + * IProvider. The service will only be queried on demand. Apps will register + * the providers through the registration context object. + * + * @see IRegistrationContext::registerSearchProvider() + * + * @param string $class + */ public function registerProvider(string $class): void { $this->lazyProviders[] = $class; } @@ -85,6 +113,11 @@ class SearchComposer { $this->lazyProviders = []; } + /** + * Get a list of all provider IDs for the consecutive calls to `search` + * + * @return string[] + */ public function getProviders(): array { $this->loadLazyProviders(); @@ -97,11 +130,25 @@ class SearchComposer { }, $this->providers)); } + /** + * Query an individual search provider for results + * + * @param IUser $user + * @param string $providerId one of the IDs received by `getProviders` + * @param ISearchQuery $query + * + * @return SearchResult + * @throws InvalidArgumentException when the $providerId does not correspond to a registered provider + */ public function search(IUser $user, string $providerId, ISearchQuery $query): SearchResult { $this->loadLazyProviders(); - return $this->providers[$providerId]->search($user, $query); + $provider = $this->providers[$providerId] ?? null; + if ($provider === null) { + throw new InvalidArgumentException("Provider $providerId is unknown"); + } + return $provider->search($user, $query); } } diff --git a/lib/public/Search/IProvider.php b/lib/public/Search/IProvider.php index 57343eda0e5..080f5089f1f 100644 --- a/lib/public/Search/IProvider.php +++ b/lib/public/Search/IProvider.php @@ -28,7 +28,7 @@ namespace OCP\Search; use OCP\IUser; /** - * Interface for an app search providers + * Interface for 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 From 27b8637e3e2676e81df74891043c39530bdc1cda Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Mon, 22 Jun 2020 10:57:40 +0200 Subject: [PATCH 3/4] Deprecate \OCP\ISearch Signed-off-by: Christoph Wurst --- lib/private/Search/Provider/File.php | 2 ++ lib/private/Search/Result/Audio.php | 4 +++- lib/private/Search/Result/File.php | 10 ++++++++++ lib/private/Search/Result/Folder.php | 2 ++ lib/private/Search/Result/Image.php | 4 +++- lib/public/ISearch.php | 5 +++++ lib/public/IServerContainer.php | 1 + lib/public/Search/PagedProvider.php | 5 +++++ lib/public/Search/Provider.php | 7 +++++++ lib/public/Search/Result.php | 6 ++++++ 10 files changed, 44 insertions(+), 2 deletions(-) diff --git a/lib/private/Search/Provider/File.php b/lib/private/Search/Provider/File.php index 02521460d8c..9a41a46bd35 100644 --- a/lib/private/Search/Provider/File.php +++ b/lib/private/Search/Provider/File.php @@ -32,6 +32,7 @@ use OC\Files\Filesystem; /** * Provide search results from the 'files' app + * @deprecated 20.0.0 */ class File extends \OCP\Search\Provider { @@ -39,6 +40,7 @@ class File extends \OCP\Search\Provider { * Search for files and folders matching the given query * @param string $query * @return \OCP\Search\Result + * @deprecated 20.0.0 */ public function search($query) { $files = Filesystem::search($query); diff --git a/lib/private/Search/Result/Audio.php b/lib/private/Search/Result/Audio.php index ef0d3bf9d20..e3917b7e4b3 100644 --- a/lib/private/Search/Result/Audio.php +++ b/lib/private/Search/Result/Audio.php @@ -27,15 +27,17 @@ namespace OC\Search\Result; /** * A found audio file + * @deprecated 20.0.0 */ class Audio extends File { /** * Type name; translated in templates * @var string + * @deprecated 20.0.0 */ public $type = 'audio'; - + /** * @TODO add ID3 information */ diff --git a/lib/private/Search/Result/File.php b/lib/private/Search/Result/File.php index cfff54e0692..f93b033c07f 100644 --- a/lib/private/Search/Result/File.php +++ b/lib/private/Search/Result/File.php @@ -31,36 +31,42 @@ use OCP\Files\Folder; /** * A found file + * @deprecated 20.0.0 */ class File extends \OCP\Search\Result { /** * Type name; translated in templates * @var string + * @deprecated 20.0.0 */ public $type = 'file'; /** * Path to file * @var string + * @deprecated 20.0.0 */ public $path; /** * Size, in bytes * @var int + * @deprecated 20.0.0 */ public $size; /** * Date modified, in human readable form * @var string + * @deprecated 20.0.0 */ public $modified; /** * File mime type * @var string + * @deprecated 20.0.0 */ public $mime_type; @@ -68,12 +74,14 @@ class File extends \OCP\Search\Result { * File permissions: * * @var string + * @deprecated 20.0.0 */ public $permissions; /** * Create a new file search result * @param FileInfo $data file data given by provider + * @deprecated 20.0.0 */ public function __construct(FileInfo $data) { $path = $this->getRelativePath($data->getPath()); @@ -97,6 +105,7 @@ class File extends \OCP\Search\Result { /** * @var Folder $userFolderCache + * @deprecated 20.0.0 */ protected static $userFolderCache = null; @@ -105,6 +114,7 @@ class File extends \OCP\Search\Result { * eg /user/files/foo.txt -> /foo.txt * @param string $path * @return string relative path + * @deprecated 20.0.0 */ protected function getRelativePath($path) { if (!isset(self::$userFolderCache)) { diff --git a/lib/private/Search/Result/Folder.php b/lib/private/Search/Result/Folder.php index 8110d61bead..1268b1379b2 100644 --- a/lib/private/Search/Result/Folder.php +++ b/lib/private/Search/Result/Folder.php @@ -27,12 +27,14 @@ namespace OC\Search\Result; /** * A found folder + * @deprecated 20.0.0 */ class Folder extends File { /** * Type name; translated in templates * @var string + * @deprecated 20.0.0 */ public $type = 'folder'; } diff --git a/lib/private/Search/Result/Image.php b/lib/private/Search/Result/Image.php index e569c91ea02..5a46138f594 100644 --- a/lib/private/Search/Result/Image.php +++ b/lib/private/Search/Result/Image.php @@ -27,15 +27,17 @@ namespace OC\Search\Result; /** * A found image file + * @deprecated 20.0.0 */ class Image extends File { /** * Type name; translated in templates * @var string + * @deprecated 20.0.0 */ public $type = 'image'; - + /** * @TODO add EXIF information */ diff --git a/lib/public/ISearch.php b/lib/public/ISearch.php index 747b598e669..94338b3d20d 100644 --- a/lib/public/ISearch.php +++ b/lib/public/ISearch.php @@ -29,6 +29,7 @@ namespace OCP; /** * Small Interface for Search * @since 7.0.0 + * @deprecated 20.0.0 */ interface ISearch { @@ -40,6 +41,7 @@ interface ISearch { * @param int $size * @return array An array of OCP\Search\Result's * @since 8.0.0 + * @deprecated 20.0.0 */ public function searchPaged($query, array $inApps = [], $page = 1, $size = 30); @@ -48,6 +50,7 @@ interface ISearch { * @param string $class class name of a OCP\Search\Provider * @param array $options optional * @since 7.0.0 + * @deprecated 20.0.0 */ public function registerProvider($class, array $options = []); @@ -55,12 +58,14 @@ interface ISearch { * Remove one existing search provider * @param string $provider class name of a OCP\Search\Provider * @since 7.0.0 + * @deprecated 20.0.0 */ public function removeProvider($provider); /** * Remove all registered search providers * @since 7.0.0 + * @deprecated 20.0.0 */ public function clearProviders(); } diff --git a/lib/public/IServerContainer.php b/lib/public/IServerContainer.php index 45f2a1caf4e..f5a644bee04 100644 --- a/lib/public/IServerContainer.php +++ b/lib/public/IServerContainer.php @@ -359,6 +359,7 @@ interface IServerContainer extends IContainer { * * @return \OCP\ISearch * @since 7.0.0 + * @deprecated 20.0.0 */ public function getSearch(); diff --git a/lib/public/Search/PagedProvider.php b/lib/public/Search/PagedProvider.php index cbccc1abc0f..479214ad405 100644 --- a/lib/public/Search/PagedProvider.php +++ b/lib/public/Search/PagedProvider.php @@ -30,12 +30,14 @@ namespace OCP\Search; /** * Provides a template for search functionality throughout ownCloud; * @since 8.0.0 + * @deprecated 20.0.0 */ abstract class PagedProvider extends Provider { /** * show all results * @since 8.0.0 + * @deprecated 20.0.0 */ public const SIZE_ALL = 0; @@ -43,6 +45,7 @@ abstract class PagedProvider extends Provider { * Constructor * @param array $options * @since 8.0.0 + * @deprecated 20.0.0 */ public function __construct($options) { parent::__construct($options); @@ -53,6 +56,7 @@ abstract class PagedProvider extends Provider { * @param string $query * @return array An array of OCP\Search\Result's * @since 8.0.0 + * @deprecated 20.0.0 */ public function search($query) { // old apps might assume they get all results, so we use SIZE_ALL @@ -66,6 +70,7 @@ abstract class PagedProvider extends Provider { * @param int $size 0 = SIZE_ALL * @return array An array of OCP\Search\Result's * @since 8.0.0 + * @deprecated 20.0.0 */ abstract public function searchPaged($query, $page, $size); } diff --git a/lib/public/Search/Provider.php b/lib/public/Search/Provider.php index 18594eefb8f..275a63c0056 100644 --- a/lib/public/Search/Provider.php +++ b/lib/public/Search/Provider.php @@ -30,11 +30,13 @@ namespace OCP\Search; /** * Provides a template for search functionality throughout ownCloud; * @since 7.0.0 + * @deprecated 20.0.0 */ abstract class Provider { /** * @since 8.0.0 + * @deprecated 20.0.0 */ public const OPTION_APPS = 'apps'; @@ -42,6 +44,7 @@ abstract class Provider { * List of options * @var array * @since 7.0.0 + * @deprecated 20.0.0 */ protected $options; @@ -49,6 +52,7 @@ abstract class Provider { * Constructor * @param array $options as key => value * @since 7.0.0 - default value for $options was added in 8.0.0 + * @deprecated 20.0.0 */ public function __construct($options = []) { $this->options = $options; @@ -59,6 +63,7 @@ abstract class Provider { * @param string $key * @return mixed * @since 8.0.0 + * @deprecated 20.0.0 */ public function getOption($key) { if (is_array($this->options) && isset($this->options[$key])) { @@ -76,6 +81,7 @@ abstract class Provider { * @param string[] $apps * @return bool * @since 8.0.0 + * @deprecated 20.0.0 */ public function providesResultsFor(array $apps = []) { $forApps = $this->getOption(self::OPTION_APPS); @@ -87,6 +93,7 @@ abstract class Provider { * @param string $query * @return array An array of OCP\Search\Result's * @since 7.0.0 + * @deprecated 20.0.0 */ abstract public function search($query); } diff --git a/lib/public/Search/Result.php b/lib/public/Search/Result.php index 33748cff375..a3a58a38cde 100644 --- a/lib/public/Search/Result.php +++ b/lib/public/Search/Result.php @@ -29,6 +29,7 @@ namespace OCP\Search; /** * The generic result of a search * @since 7.0.0 + * @deprecated 20.0.0 */ class Result { @@ -37,6 +38,7 @@ class Result { * corresponding application. * @var string * @since 7.0.0 + * @deprecated 20.0.0 */ public $id; @@ -45,6 +47,7 @@ class Result { * results. * @var string * @since 7.0.0 + * @deprecated 20.0.0 */ public $name; @@ -52,6 +55,7 @@ class Result { * URL to the application item. * @var string * @since 7.0.0 + * @deprecated 20.0.0 */ public $link; @@ -60,6 +64,7 @@ class Result { * as the class name (e.g. \OC\Search\File -> 'file') in lowercase. * @var string * @since 7.0.0 + * @deprecated 20.0.0 */ public $type = 'generic'; @@ -69,6 +74,7 @@ class Result { * @param string $name displayed text of result * @param string $link URL to the result within its app * @since 7.0.0 + * @deprecated 20.0.0 */ public function __construct($id = null, $name = null, $link = null) { $this->id = $id; From 2c699e090179a2ca235d28540b5999e27c36b9de Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Mon, 22 Jun 2020 12:19:34 +0200 Subject: [PATCH 4/4] Migrate comments to unified search API Signed-off-by: Christoph Wurst --- .../composer/composer/autoload_classmap.php | 2 + .../composer/composer/autoload_static.php | 2 + apps/comments/lib/AppInfo/Application.php | 4 +- .../lib/Search/CommentsSearchResultEntry.php | 31 +++++ apps/comments/lib/Search/LegacyProvider.php | 113 ++++++++++++++++ apps/comments/lib/Search/Provider.php | 125 +++++++----------- apps/comments/lib/Search/Result.php | 22 +++ 7 files changed, 222 insertions(+), 77 deletions(-) create mode 100644 apps/comments/lib/Search/CommentsSearchResultEntry.php create mode 100644 apps/comments/lib/Search/LegacyProvider.php diff --git a/apps/comments/composer/composer/autoload_classmap.php b/apps/comments/composer/composer/autoload_classmap.php index d5d51c7a12c..5682d66468d 100644 --- a/apps/comments/composer/composer/autoload_classmap.php +++ b/apps/comments/composer/composer/autoload_classmap.php @@ -21,6 +21,8 @@ return array( 'OCA\\Comments\\Listener\\LoadSidebarScripts' => $baseDir . '/../lib/Listener/LoadSidebarScripts.php', 'OCA\\Comments\\Notification\\Listener' => $baseDir . '/../lib/Notification/Listener.php', 'OCA\\Comments\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', + 'OCA\\Comments\\Search\\CommentsSearchResultEntry' => $baseDir . '/../lib/Search/CommentsSearchResultEntry.php', + 'OCA\\Comments\\Search\\LegacyProvider' => $baseDir . '/../lib/Search/LegacyProvider.php', 'OCA\\Comments\\Search\\Provider' => $baseDir . '/../lib/Search/Provider.php', 'OCA\\Comments\\Search\\Result' => $baseDir . '/../lib/Search/Result.php', ); diff --git a/apps/comments/composer/composer/autoload_static.php b/apps/comments/composer/composer/autoload_static.php index 1292415290b..87a2127c144 100644 --- a/apps/comments/composer/composer/autoload_static.php +++ b/apps/comments/composer/composer/autoload_static.php @@ -36,6 +36,8 @@ class ComposerStaticInitComments 'OCA\\Comments\\Listener\\LoadSidebarScripts' => __DIR__ . '/..' . '/../lib/Listener/LoadSidebarScripts.php', 'OCA\\Comments\\Notification\\Listener' => __DIR__ . '/..' . '/../lib/Notification/Listener.php', 'OCA\\Comments\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', + 'OCA\\Comments\\Search\\CommentsSearchResultEntry' => __DIR__ . '/..' . '/../lib/Search/CommentsSearchResultEntry.php', + 'OCA\\Comments\\Search\\LegacyProvider' => __DIR__ . '/..' . '/../lib/Search/LegacyProvider.php', 'OCA\\Comments\\Search\\Provider' => __DIR__ . '/..' . '/../lib/Search/Provider.php', 'OCA\\Comments\\Search\\Result' => __DIR__ . '/..' . '/../lib/Search/Result.php', ); diff --git a/apps/comments/lib/AppInfo/Application.php b/apps/comments/lib/AppInfo/Application.php index 8bcf17b2afe..fafac0c6040 100644 --- a/apps/comments/lib/AppInfo/Application.php +++ b/apps/comments/lib/AppInfo/Application.php @@ -35,6 +35,7 @@ use OCA\Comments\Listener\CommentsEntityEventListener; use OCA\Comments\Listener\LoadAdditionalScripts; use OCA\Comments\Listener\LoadSidebarScripts; use OCA\Comments\Notification\Notifier; +use OCA\Comments\Search\LegacyProvider; use OCA\Comments\Search\Provider; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Files\Event\LoadSidebar; @@ -70,6 +71,7 @@ class Application extends App implements IBootstrap { CommentsEntityEvent::EVENT_ENTITY, CommentsEntityEventListener::class ); + $context->registerSearchProvider(Provider::class); } public function boot(IBootContext $context): void { @@ -79,7 +81,7 @@ class Application extends App implements IBootstrap { $jsSettingsHelper = new JSSettingsHelper($context->getServerContainer()); Util::connectHook('\OCP\Config', 'js', $jsSettingsHelper, 'extend'); - $context->getServerContainer()->getSearch()->registerProvider(Provider::class, ['apps' => ['files']]); + $context->getServerContainer()->getSearch()->registerProvider(LegacyProvider::class, ['apps' => ['files']]); } protected function registerNotifier(IServerContainer $container) { diff --git a/apps/comments/lib/Search/CommentsSearchResultEntry.php b/apps/comments/lib/Search/CommentsSearchResultEntry.php new file mode 100644 index 00000000000..de24cf9cc52 --- /dev/null +++ b/apps/comments/lib/Search/CommentsSearchResultEntry.php @@ -0,0 +1,31 @@ + + * + * @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\Comments\Search; + +use OCP\Search\ASearchResultEntry; + +class CommentsSearchResultEntry extends ASearchResultEntry { +} diff --git a/apps/comments/lib/Search/LegacyProvider.php b/apps/comments/lib/Search/LegacyProvider.php new file mode 100644 index 00000000000..abcb8535f5c --- /dev/null +++ b/apps/comments/lib/Search/LegacyProvider.php @@ -0,0 +1,113 @@ + + * + * @author Joas Schilling + * + * @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\Comments\Search; + +use OCP\Comments\IComment; +use OCP\Files\Folder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\IUser; +use OCP\Search\Provider; +use function count; + +class LegacyProvider extends Provider { + + /** + * Search for $query + * + * @param string $query + * @return array An array of OCP\Search\Result's + * @since 7.0.0 + */ + public function search($query): array { + $cm = \OC::$server->getCommentsManager(); + $us = \OC::$server->getUserSession(); + + $user = $us->getUser(); + if (!$user instanceof IUser) { + return []; + } + $uf = \OC::$server->getUserFolder($user->getUID()); + + if ($uf === null) { + return []; + } + + $result = []; + $numComments = 50; + $offset = 0; + + while (count($result) < $numComments) { + /** @var IComment[] $comments */ + $comments = $cm->search($query, 'files', '', 'comment', $offset, $numComments); + + foreach ($comments as $comment) { + if ($comment->getActorType() !== 'users') { + continue; + } + + $displayName = $cm->resolveDisplayName('user', $comment->getActorId()); + + try { + $file = $this->getFileForComment($uf, $comment); + $result[] = new Result($query, + $comment, + $displayName, + $file->getPath() + ); + } catch (NotFoundException $e) { + continue; + } + } + + if (count($comments) < $numComments) { + // Didn't find more comments when we tried to get, so there are no more comments. + return $result; + } + + $offset += $numComments; + $numComments = 50 - count($result); + } + + return $result; + } + + /** + * @param Folder $userFolder + * @param IComment $comment + * @return Node + * @throws NotFoundException + */ + protected function getFileForComment(Folder $userFolder, IComment $comment): Node { + $nodes = $userFolder->getById((int) $comment->getObjectId()); + if (empty($nodes)) { + throw new NotFoundException('File not found'); + } + + return array_shift($nodes); + } +} diff --git a/apps/comments/lib/Search/Provider.php b/apps/comments/lib/Search/Provider.php index ad3b63d46b5..0c0561c988c 100644 --- a/apps/comments/lib/Search/Provider.php +++ b/apps/comments/lib/Search/Provider.php @@ -1,8 +1,11 @@ + * @copyright 2020 Christoph Wurst * - * @author Joas Schilling + * @author 2020 Christoph Wurst * * @license GNU AGPL version 3 or any later version * @@ -17,92 +20,62 @@ * 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 . - * + * along with this program. If not, see . */ namespace OCA\Comments\Search; -use OCP\Comments\IComment; -use OCP\Files\Folder; -use OCP\Files\Node; -use OCP\Files\NotFoundException; +use OCP\IL10N; +use OCP\IURLGenerator; use OCP\IUser; +use OCP\Search\IProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; +use function array_map; +use function pathinfo; -class Provider extends \OCP\Search\Provider { - - /** - * Search for $query - * - * @param string $query - * @return array An array of OCP\Search\Result's - * @since 7.0.0 - */ - public function search($query): array { - $cm = \OC::$server->getCommentsManager(); - $us = \OC::$server->getUserSession(); - - $user = $us->getUser(); - if (!$user instanceof IUser) { - return []; - } - $uf = \OC::$server->getUserFolder($user->getUID()); - - if ($uf === null) { - return []; - } +class Provider implements IProvider { - $result = []; - $numComments = 50; - $offset = 0; + /** @var IL10N */ + private $l10n; - while (\count($result) < $numComments) { - /** @var IComment[] $comments */ - $comments = $cm->search($query, 'files', '', 'comment', $offset, $numComments); + /** @var IURLGenerator */ + private $urlGenerator; - foreach ($comments as $comment) { - if ($comment->getActorType() !== 'users') { - continue; - } + /** @var LegacyProvider */ + private $legacyProvider; - $displayName = $cm->resolveDisplayName('user', $comment->getActorId()); - - try { - $file = $this->getFileForComment($uf, $comment); - $result[] = new Result($query, - $comment, - $displayName, - $file->getPath() - ); - } catch (NotFoundException $e) { - continue; - } - } - - if (\count($comments) < $numComments) { - // Didn't find more comments when we tried to get, so there are no more comments. - return $result; - } - - $offset += $numComments; - $numComments = 50 - \count($result); - } - - return $result; + public function __construct(IL10N $l10n, + IURLGenerator $urlGenerator, + LegacyProvider $legacyProvider) { + $this->l10n = $l10n; + $this->urlGenerator = $urlGenerator; + $this->legacyProvider = $legacyProvider; } - /** - * @param Folder $userFolder - * @param IComment $comment - * @return Node - * @throws NotFoundException - */ - protected function getFileForComment(Folder $userFolder, IComment $comment): Node { - $nodes = $userFolder->getById((int) $comment->getObjectId()); - if (empty($nodes)) { - throw new NotFoundException('File not found'); - } + public function getId(): string { + return 'comments'; + } - return array_shift($nodes); + public function search(IUser $user, ISearchQuery $query): SearchResult { + return SearchResult::complete( + $this->l10n->t('Comments'), + array_map(function (Result $result) { + $path = $result->path; + $pathInfo = pathinfo($path); + return new CommentsSearchResultEntry( + $this->urlGenerator->linkToRoute('core.Preview.getPreviewByFileId', ['x' => 32, 'y' => 32, 'fileId' => $result->id]), + $result->name, + $path, + $this->urlGenerator->linkToRoute( + 'files.view.index', + [ + 'dir' => $pathInfo['dirname'], + 'scrollto' => $pathInfo['basename'], + ] + ) + ); + }, $this->legacyProvider->search($query->getTerm())) + ); } } diff --git a/apps/comments/lib/Search/Result.php b/apps/comments/lib/Search/Result.php index 9f0e6936320..d0e9902c1fb 100644 --- a/apps/comments/lib/Search/Result.php +++ b/apps/comments/lib/Search/Result.php @@ -28,12 +28,33 @@ use OCP\Comments\IComment; use OCP\Files\NotFoundException; use OCP\Search\Result as BaseResult; +/** + * @deprecated 20.0.0 + */ class Result extends BaseResult { + /** + * @deprecated 20.0.0 + */ public $type = 'comment'; + /** + * @deprecated 20.0.0 + */ public $comment; + /** + * @deprecated 20.0.0 + */ public $authorId; + /** + * @deprecated 20.0.0 + */ public $authorName; + /** + * @deprecated 20.0.0 + */ public $path; + /** + * @deprecated 20.0.0 + */ public $fileName; /** @@ -42,6 +63,7 @@ class Result extends BaseResult { * @param string $authorName * @param string $path * @throws NotFoundException + * @deprecated 20.0.0 */ public function __construct(string $search, IComment $comment,