]> source.dussan.org Git - nextcloud-server.git/commitdiff
Add unified search API
authorChristoph Wurst <christoph@winzerhof-wurst.at>
Mon, 11 May 2020 08:35:54 +0000 (10:35 +0200)
committerChristoph Wurst <christoph@winzerhof-wurst.at>
Wed, 24 Jun 2020 12:20:25 +0000 (14:20 +0200)
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
15 files changed:
apps/files/lib/Search/FilesSearchProvider.php [new file with mode: 0644]
core/Controller/UnifiedSearchController.php [new file with mode: 0644]
core/routes.php
lib/composer/composer/autoload_classmap.php
lib/composer/composer/autoload_static.php
lib/private/AppFramework/Bootstrap/Coordinator.php
lib/private/AppFramework/Bootstrap/RegistrationContext.php
lib/private/Search/SearchComposer.php [new file with mode: 0644]
lib/private/Search/SearchQuery.php [new file with mode: 0644]
lib/public/AppFramework/Bootstrap/IRegistrationContext.php
lib/public/Search/ASearchResultEntry.php [new file with mode: 0644]
lib/public/Search/IProvider.php [new file with mode: 0644]
lib/public/Search/ISearchQuery.php [new file with mode: 0644]
lib/public/Search/SearchResult.php [new file with mode: 0644]
tests/lib/AppFramework/Bootstrap/CoordinatorTest.php

diff --git a/apps/files/lib/Search/FilesSearchProvider.php b/apps/files/lib/Search/FilesSearchProvider.php
new file mode 100644 (file)
index 0000000..0a360d6
--- /dev/null
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace OCA\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 (file)
index 0000000..ddb1745
--- /dev/null
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace 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
+                               )
+                       )
+               );
+       }
+}
index 3d30b12e3928bf67e19c9e1ea1ee21a8ace68270..5bb58f63e45c1b8406c3c427aed38f255b70b644 100644 (file)
@@ -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'],
index e405d25ee66247b815c3a7d1a5192963667919d1..b51c9876e5df8d2f8b7b91f429a5a5d77ff8f9bf 100644 (file)
@@ -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',
index 456fa81087dc4b6c29d145ece6a020128e2a161e..2f640e014f34facffd6713d31ee7d4c96499bab9 100644 (file)
@@ -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',
index 9b0f6a9188ce15a8d4a390d58921c32eef7ddccb..085e7460da6d5df2d4cd52f07711af767504ac1c 100644 (file)
@@ -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 {
index 340012c8b1b6e5c9f59aab84ca1b72fab278b38d..23eee9c6e33e063d909bc569f5d893ab7c27d96e 100644 (file)
@@ -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 (file)
index 0000000..f836929
--- /dev/null
@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace 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 (file)
index 0000000..2ed31fe
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace 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;
+       }
+}
index 5d3ffc8d4797a72aed856316412c3d100e8b6de6..12367e5ed05f60b7490f0878b53fe9d928ef2fb6 100644 (file)
@@ -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 (file)
index 0000000..45d6252
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace 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 (file)
index 0000000..57343ed
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace 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 (file)
index 0000000..00d5380
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace 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 (file)
index 0000000..7abb5b9
--- /dev/null
@@ -0,0 +1,112 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace 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,
+               ];
+       }
+}
index c12e5eeb15063f0e6b43af138f9327432865c70e..6909ad94e7fc84512db488f68421116c4d746e97 100644 (file)
@@ -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
                );
        }