SearchComposer.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OC\Search;
  8. use InvalidArgumentException;
  9. use OC\AppFramework\Bootstrap\Coordinator;
  10. use OC\Core\ResponseDefinitions;
  11. use OCP\IAppConfig;
  12. use OCP\IURLGenerator;
  13. use OCP\IUser;
  14. use OCP\Search\FilterDefinition;
  15. use OCP\Search\IFilter;
  16. use OCP\Search\IFilteringProvider;
  17. use OCP\Search\IInAppSearch;
  18. use OCP\Search\IProvider;
  19. use OCP\Search\ISearchQuery;
  20. use OCP\Search\SearchResult;
  21. use Psr\Container\ContainerExceptionInterface;
  22. use Psr\Container\ContainerInterface;
  23. use Psr\Log\LoggerInterface;
  24. use RuntimeException;
  25. use function array_filter;
  26. use function array_map;
  27. use function array_values;
  28. use function in_array;
  29. /**
  30. * Queries individual \OCP\Search\IProvider implementations and composes a
  31. * unified search result for the user's search term
  32. *
  33. * The search process is generally split into two steps
  34. *
  35. * 1. Get a list of provider (`getProviders`)
  36. * 2. Get search results of each provider (`search`)
  37. *
  38. * The reasoning behind this is that the runtime complexity of a combined search
  39. * result would be O(n) and linearly grow with each provider added. This comes
  40. * from the nature of php where we can't concurrently fetch the search results.
  41. * So we offload the concurrency the client application (e.g. JavaScript in the
  42. * browser) and let it first get the list of providers to then fetch all results
  43. * concurrently. The client is free to decide whether all concurrent search
  44. * results are awaited or shown as they come in.
  45. *
  46. * @see IProvider::search() for the arguments of the individual search requests
  47. * @psalm-import-type CoreUnifiedSearchProvider from ResponseDefinitions
  48. */
  49. class SearchComposer {
  50. /**
  51. * @var array<string, array{appId: string, provider: IProvider}>
  52. */
  53. private array $providers = [];
  54. private array $commonFilters;
  55. private array $customFilters = [];
  56. private array $handlers = [];
  57. public function __construct(
  58. private Coordinator $bootstrapCoordinator,
  59. private ContainerInterface $container,
  60. private IURLGenerator $urlGenerator,
  61. private LoggerInterface $logger,
  62. private IAppConfig $appConfig,
  63. ) {
  64. $this->commonFilters = [
  65. IFilter::BUILTIN_TERM => new FilterDefinition(IFilter::BUILTIN_TERM, FilterDefinition::TYPE_STRING),
  66. IFilter::BUILTIN_SINCE => new FilterDefinition(IFilter::BUILTIN_SINCE, FilterDefinition::TYPE_DATETIME),
  67. IFilter::BUILTIN_UNTIL => new FilterDefinition(IFilter::BUILTIN_UNTIL, FilterDefinition::TYPE_DATETIME),
  68. IFilter::BUILTIN_TITLE_ONLY => new FilterDefinition(IFilter::BUILTIN_TITLE_ONLY, FilterDefinition::TYPE_BOOL, false),
  69. IFilter::BUILTIN_PERSON => new FilterDefinition(IFilter::BUILTIN_PERSON, FilterDefinition::TYPE_PERSON),
  70. IFilter::BUILTIN_PLACES => new FilterDefinition(IFilter::BUILTIN_PLACES, FilterDefinition::TYPE_STRINGS, false),
  71. IFilter::BUILTIN_PROVIDER => new FilterDefinition(IFilter::BUILTIN_PROVIDER, FilterDefinition::TYPE_STRING, false),
  72. ];
  73. }
  74. /**
  75. * Load all providers dynamically that were registered through `registerProvider`
  76. *
  77. * If $targetProviderId is provided, only this provider is loaded
  78. * If a provider can't be loaded we log it but the operation continues nevertheless
  79. */
  80. private function loadLazyProviders(?string $targetProviderId = null): void {
  81. $context = $this->bootstrapCoordinator->getRegistrationContext();
  82. if ($context === null) {
  83. // Too early, nothing registered yet
  84. return;
  85. }
  86. $registrations = $context->getSearchProviders();
  87. foreach ($registrations as $registration) {
  88. try {
  89. /** @var IProvider $provider */
  90. $provider = $this->container->get($registration->getService());
  91. $providerId = $provider->getId();
  92. if ($targetProviderId !== null && $targetProviderId !== $providerId) {
  93. continue;
  94. }
  95. $this->providers[$providerId] = [
  96. 'appId' => $registration->getAppId(),
  97. 'provider' => $provider,
  98. ];
  99. $this->handlers[$providerId] = [$providerId];
  100. if ($targetProviderId !== null) {
  101. break;
  102. }
  103. } catch (ContainerExceptionInterface $e) {
  104. // Log an continue. We can be fault tolerant here.
  105. $this->logger->error('Could not load search provider dynamically: ' . $e->getMessage(), [
  106. 'exception' => $e,
  107. 'app' => $registration->getAppId(),
  108. ]);
  109. }
  110. }
  111. $this->providers = $this->filterProviders($this->providers);
  112. $this->loadFilters();
  113. }
  114. private function loadFilters(): void {
  115. foreach ($this->providers as $providerId => $providerData) {
  116. $appId = $providerData['appId'];
  117. $provider = $providerData['provider'];
  118. if (!$provider instanceof IFilteringProvider) {
  119. continue;
  120. }
  121. foreach ($provider->getCustomFilters() as $filter) {
  122. $this->registerCustomFilter($filter, $providerId);
  123. }
  124. foreach ($provider->getAlternateIds() as $alternateId) {
  125. $this->handlers[$alternateId][] = $providerId;
  126. }
  127. foreach ($provider->getSupportedFilters() as $filterName) {
  128. if ($this->getFilterDefinition($filterName, $providerId) === null) {
  129. throw new InvalidArgumentException('Invalid filter ' . $filterName);
  130. }
  131. }
  132. }
  133. }
  134. private function registerCustomFilter(FilterDefinition $filter, string $providerId): void {
  135. $name = $filter->name();
  136. if (isset($this->commonFilters[$name])) {
  137. throw new InvalidArgumentException('Filter name is already used');
  138. }
  139. if (isset($this->customFilters[$providerId])) {
  140. $this->customFilters[$providerId][$name] = $filter;
  141. } else {
  142. $this->customFilters[$providerId] = [$name => $filter];
  143. }
  144. }
  145. /**
  146. * Get a list of all provider IDs & Names for the consecutive calls to `search`
  147. * Sort the list by the order property
  148. *
  149. * @param string $route the route the user is currently at
  150. * @param array $routeParameters the parameters of the route the user is currently at
  151. *
  152. * @return list<CoreUnifiedSearchProvider>
  153. */
  154. public function getProviders(string $route, array $routeParameters): array {
  155. $this->loadLazyProviders();
  156. $providers = array_map(
  157. function (array $providerData) use ($route, $routeParameters) {
  158. $appId = $providerData['appId'];
  159. $provider = $providerData['provider'];
  160. $order = $provider->getOrder($route, $routeParameters);
  161. if ($order === null) {
  162. return;
  163. }
  164. $triggers = [$provider->getId()];
  165. if ($provider instanceof IFilteringProvider) {
  166. $triggers += $provider->getAlternateIds();
  167. $filters = $provider->getSupportedFilters();
  168. } else {
  169. $filters = [IFilter::BUILTIN_TERM];
  170. }
  171. return [
  172. 'id' => $provider->getId(),
  173. 'appId' => $appId,
  174. 'name' => $provider->getName(),
  175. 'icon' => $this->fetchIcon($appId, $provider->getId()),
  176. 'order' => $order,
  177. 'triggers' => array_values($triggers),
  178. 'filters' => $this->getFiltersType($filters, $provider->getId()),
  179. 'inAppSearch' => $provider instanceof IInAppSearch,
  180. ];
  181. },
  182. $this->providers,
  183. );
  184. $providers = array_filter($providers);
  185. // Sort providers by order and strip associative keys
  186. usort($providers, function ($provider1, $provider2) {
  187. return $provider1['order'] <=> $provider2['order'];
  188. });
  189. return $providers;
  190. }
  191. /**
  192. * Filter providers based on 'unified_search.providers_allowed' core app config array
  193. * @param array $providers
  194. * @return array
  195. */
  196. private function filterProviders(array $providers): array {
  197. $allowedProviders = $this->appConfig->getValueArray('core', 'unified_search.providers_allowed');
  198. if (empty($allowedProviders)) {
  199. return $providers;
  200. }
  201. return array_values(array_filter($providers, function ($p) use ($allowedProviders) {
  202. return in_array($p['id'], $allowedProviders);
  203. }));
  204. }
  205. private function fetchIcon(string $appId, string $providerId): string {
  206. $icons = [
  207. [$providerId, $providerId . '.svg'],
  208. [$providerId, 'app.svg'],
  209. [$appId, $providerId . '.svg'],
  210. [$appId, $appId . '.svg'],
  211. [$appId, 'app.svg'],
  212. ['core', 'places/default-app-icon.svg'],
  213. ];
  214. if ($appId === 'settings' && $providerId === 'users') {
  215. // Conflict:
  216. // the file /apps/settings/users.svg is already used in black version by top right user menu
  217. // Override icon name here
  218. $icons = [['settings', 'users-white.svg']];
  219. }
  220. foreach ($icons as $i => $icon) {
  221. try {
  222. return $this->urlGenerator->imagePath(... $icon);
  223. } catch (RuntimeException $e) {
  224. // Ignore error
  225. }
  226. }
  227. return '';
  228. }
  229. /**
  230. * @param $filters string[]
  231. * @return array<string, string>
  232. */
  233. private function getFiltersType(array $filters, string $providerId): array {
  234. $filterList = [];
  235. foreach ($filters as $filter) {
  236. $filterList[$filter] = $this->getFilterDefinition($filter, $providerId)->type();
  237. }
  238. return $filterList;
  239. }
  240. private function getFilterDefinition(string $name, string $providerId): ?FilterDefinition {
  241. if (isset($this->commonFilters[$name])) {
  242. return $this->commonFilters[$name];
  243. }
  244. if (isset($this->customFilters[$providerId][$name])) {
  245. return $this->customFilters[$providerId][$name];
  246. }
  247. return null;
  248. }
  249. /**
  250. * @param array<string, string> $parameters
  251. */
  252. public function buildFilterList(string $providerId, array $parameters): FilterCollection {
  253. $this->loadLazyProviders($providerId);
  254. $list = [];
  255. foreach ($parameters as $name => $value) {
  256. $filter = $this->buildFilter($name, $value, $providerId);
  257. if ($filter === null) {
  258. continue;
  259. }
  260. $list[$name] = $filter;
  261. }
  262. return new FilterCollection(... $list);
  263. }
  264. private function buildFilter(string $name, string $value, string $providerId): ?IFilter {
  265. $filterDefinition = $this->getFilterDefinition($name, $providerId);
  266. if ($filterDefinition === null) {
  267. $this->logger->debug('Unable to find {name} definition', [
  268. 'name' => $name,
  269. 'value' => $value,
  270. ]);
  271. return null;
  272. }
  273. if (!$this->filterSupportedByProvider($filterDefinition, $providerId)) {
  274. // FIXME Use dedicated exception and handle it
  275. throw new UnsupportedFilter($name, $providerId);
  276. }
  277. return FilterFactory::get($filterDefinition->type(), $value);
  278. }
  279. private function filterSupportedByProvider(FilterDefinition $filterDefinition, string $providerId): bool {
  280. // Non exclusive filters can be ommited by apps
  281. if (!$filterDefinition->exclusive()) {
  282. return true;
  283. }
  284. $provider = $this->providers[$providerId]['provider'];
  285. $supportedFilters = $provider instanceof IFilteringProvider
  286. ? $provider->getSupportedFilters()
  287. : [IFilter::BUILTIN_TERM];
  288. return in_array($filterDefinition->name(), $supportedFilters, true);
  289. }
  290. /**
  291. * Query an individual search provider for results
  292. *
  293. * @param IUser $user
  294. * @param string $providerId one of the IDs received by `getProviders`
  295. * @param ISearchQuery $query
  296. *
  297. * @return SearchResult
  298. * @throws InvalidArgumentException when the $providerId does not correspond to a registered provider
  299. */
  300. public function search(
  301. IUser $user,
  302. string $providerId,
  303. ISearchQuery $query,
  304. ): SearchResult {
  305. $this->loadLazyProviders($providerId);
  306. $provider = $this->providers[$providerId]['provider'] ?? null;
  307. if ($provider === null) {
  308. throw new InvalidArgumentException("Provider $providerId is unknown");
  309. }
  310. return $provider->search($user, $query);
  311. }
  312. }