diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 249dbca9f71f7..5a55a9bbbeff8 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -208,36 +208,22 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription */ protected array $userDisplayNames; - private IDBConnection $db; private Backend $calendarSharingBackend; - private Principal $principalBackend; - private IUserManager $userManager; - private ISecureRandom $random; - private LoggerInterface $logger; - private IEventDispatcher $dispatcher; - private IConfig $config; - private bool $legacyEndpoint; private string $dbObjectPropertiesTable = 'calendarobjects_props'; private array $cachedObjects = []; - public function __construct(IDBConnection $db, - Principal $principalBackend, - IUserManager $userManager, - IGroupManager $groupManager, - ISecureRandom $random, - LoggerInterface $logger, - IEventDispatcher $dispatcher, - IConfig $config, - bool $legacyEndpoint = false) { - $this->db = $db; - $this->principalBackend = $principalBackend; - $this->userManager = $userManager; + public function __construct( + private IDBConnection $db, + private Principal $principalBackend, + private IUserManager $userManager, + IGroupManager $groupManager, + private ISecureRandom $random, + private LoggerInterface $logger, + private IEventDispatcher $dispatcher, + private IConfig $config, + private bool $legacyEndpoint = false, + ) { $this->calendarSharingBackend = new Backend($this->db, $this->userManager, $groupManager, $principalBackend, 'calendar'); - $this->random = $random; - $this->logger = $logger; - $this->dispatcher = $dispatcher; - $this->config = $config; - $this->legacyEndpoint = $legacyEndpoint; } /** @@ -1855,8 +1841,14 @@ public function calendarSearch($principalUri, array $filters, $limit = null, $of * * @return array */ - public function search(array $calendarInfo, $pattern, array $searchProperties, - array $options, $limit, $offset) { + public function search( + array $calendarInfo, + $pattern, + array $searchProperties, + array $options, + $limit, + $offset + ) { $outerQuery = $this->db->getQueryBuilder(); $innerQuery = $this->db->getQueryBuilder(); @@ -2074,11 +2066,12 @@ private function transformSearchProperty(Property $prop) { * @return array */ public function searchPrincipalUri(string $principalUri, - string $pattern, - array $componentTypes, - array $searchProperties, - array $searchParameters, - array $options = []): array { + string $pattern, + array $componentTypes, + array $searchProperties, + array $searchParameters, + array $options = [] + ): array { return $this->atomic(function () use ($principalUri, $pattern, $componentTypes, $searchProperties, $searchParameters, $options) { $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false; @@ -2160,6 +2153,20 @@ public function searchPrincipalUri(string $principalUri, if (isset($options['offset'])) { $calendarObjectIdQuery->setFirstResult($options['offset']); } + if (isset($options['timerange'])) { + if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt( + 'lastoccurence', + $calendarObjectIdQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()), + )); + } + if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt( + 'firstoccurence', + $calendarObjectIdQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()), + )); + } + } $result = $calendarObjectIdQuery->executeQuery(); $matches = []; @@ -3187,7 +3194,7 @@ public function pruneOutdatedSyncTokens(int $keep = 10_000): int { $maxId = (int) $result->fetchOne(); $result->closeCursor(); if (!$maxId || $maxId < $keep) { - return 0; + return 0; } $query = $this->db->getQueryBuilder(); diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php index e5ddeb9b4e198..613ec16921f16 100644 --- a/apps/dav/lib/CardDAV/CardDavBackend.php +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -53,6 +53,7 @@ use OCP\IDBConnection; use OCP\IGroupManager; use OCP\IUserManager; +use OC\Search\Filter\DateTimeFilter; use PDO; use Sabre\CardDAV\Backend\BackendInterface; use Sabre\CardDAV\Backend\SyncSupport; @@ -1109,7 +1110,15 @@ public function searchPrincipalUri(string $principalUri, * @param string $pattern * @param array $searchProperties * @param array $options - * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options + * @psalm-param array{ + * types?: bool, + * escape_like_param?: bool, + * limit?: int, + * offset?: int, + * wildcard?: bool, + * since?: DateTimeFilter|null, + * until?: DateTimeFilter|null, + * } $options * @return array */ private function searchByAddressBookIds(array $addressBookIds, @@ -1130,32 +1139,31 @@ private function searchByAddressBookIds(array $addressBookIds, return []; } - $propertyOr = $query2->expr()->orX(); - foreach ($searchProperties as $property) { - if ($escapePattern) { + if ($escapePattern) { + $searchProperties = array_filter($searchProperties, function ($property) use ($pattern) { if ($property === 'EMAIL' && str_contains($pattern, ' ')) { // There can be no spaces in emails - continue; + return false; } if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) { // There can be no chars in cloud ids which are not valid for user ids plus :/ // worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/ - continue; + return false; } - } - $propertyOr->add($query2->expr()->eq('cp.name', $query2->createNamedParameter($property))); + return true; + }); } - if ($propertyOr->count() === 0) { + if (empty($searchProperties)) { return []; } $query2->selectDistinct('cp.cardid') ->from($this->dbCardsPropertiesTable, 'cp') ->andWhere($addressBookOr) - ->andWhere($propertyOr); + ->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY))); // No need for like when the pattern is empty if ('' !== $pattern) { @@ -1167,7 +1175,6 @@ private function searchByAddressBookIds(array $addressBookIds, $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%'))); } } - if (isset($options['limit'])) { $query2->setMaxResults($options['limit']); } @@ -1175,6 +1182,29 @@ private function searchByAddressBookIds(array $addressBookIds, $query2->setFirstResult($options['offset']); } + if (isset($options['since']) || isset($options['until'])) { + $query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid'); + $query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY'))); + /** + * FIXME Find a way to match only 4 last digits + * BDAY can be --1018 without year or 20001019 with it + * $bDayOr = $query2->expr()->orX(); + * if ($options['since'] instanceof DateTimeFilter) { + * $bDayOr->add( + * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)', + * $query2->createNamedParameter($options['since']->get()->format('md'))) + * ); + * } + * if ($options['until'] instanceof DateTimeFilter) { + * $bDayOr->add( + * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)', + * $query2->createNamedParameter($options['until']->get()->format('md'))) + * ); + * } + * $query2->andWhere($bDayOr); + */ + } + $result = $query2->execute(); $matches = $result->fetchAll(); $result->closeCursor(); @@ -1410,7 +1440,7 @@ public function pruneOutdatedSyncTokens(int $keep = 10_000): int { $maxId = (int) $result->fetchOne(); $result->closeCursor(); if (!$maxId || $maxId < $keep) { - return 0; + return 0; } $query = $this->db->getQueryBuilder(); diff --git a/apps/dav/lib/Search/ContactsSearchProvider.php b/apps/dav/lib/Search/ContactsSearchProvider.php index a7c2969016b47..57e69c676e091 100644 --- a/apps/dav/lib/Search/ContactsSearchProvider.php +++ b/apps/dav/lib/Search/ContactsSearchProvider.php @@ -32,31 +32,23 @@ use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUser; -use OCP\Search\IProvider; +use OCP\Search\FilterDefinition; +use OCP\Search\IFilteringProvider; use OCP\Search\ISearchQuery; use OCP\Search\SearchResult; use OCP\Search\SearchResultEntry; use Sabre\VObject\Component\VCard; use Sabre\VObject\Reader; -class ContactsSearchProvider implements IProvider { - - /** @var IAppManager */ - private $appManager; - - /** @var IL10N */ - private $l10n; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var CardDavBackend */ - private $backend; +class ContactsSearchProvider implements IFilteringProvider { + private static array $searchPropertiesRestricted = [ + 'N', + 'FN', + 'NICKNAME', + 'EMAIL', + ]; - /** - * @var string[] - */ - private static $searchProperties = [ + private static array $searchProperties = [ 'N', 'FN', 'NICKNAME', @@ -68,22 +60,12 @@ class ContactsSearchProvider implements IProvider { 'NOTE', ]; - /** - * ContactsSearchProvider constructor. - * - * @param IAppManager $appManager - * @param IL10N $l10n - * @param IURLGenerator $urlGenerator - * @param CardDavBackend $backend - */ - public function __construct(IAppManager $appManager, - IL10N $l10n, - IURLGenerator $urlGenerator, - CardDavBackend $backend) { - $this->appManager = $appManager; - $this->l10n = $l10n; - $this->urlGenerator = $urlGenerator; - $this->backend = $backend; + public function __construct( + private IAppManager $appManager, + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private CardDavBackend $backend, + ) { } /** @@ -100,9 +82,6 @@ public function getName(): string { return $this->l10n->t('Contacts'); } - /** - * @inheritDoc - */ public function getOrder(string $route, array $routeParameters): int { if ($route === 'contacts.Page.index') { return -1; @@ -110,9 +89,6 @@ public function getOrder(string $route, array $routeParameters): int { return 25; } - /** - * @inheritDoc - */ public function search(IUser $user, ISearchQuery $query): SearchResult { if (!$this->appManager->isEnabledForUser('contacts', $user)) { return SearchResult::complete($this->getName(), []); @@ -127,12 +103,16 @@ public function search(IUser $user, ISearchQuery $query): SearchResult { $searchResults = $this->backend->searchPrincipalUri( $principalUri, - $query->getTerm(), - self::$searchProperties, + $query->getFilter('term')?->get() ?? '', + $query->getFilter('title-only')?->get() ? self::$searchPropertiesRestricted : self::$searchProperties, [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), - ] + 'since' => $query->getFilter('since'), + 'until' => $query->getFilter('until'), + 'person' => $query->getFilter('person'), + 'company' => $query->getFilter('company'), + ], ); $formattedResults = \array_map(function (array $contactRow) use ($addressBooksById):SearchResultEntry { $addressBook = $addressBooksById[$contactRow['addressbookid']]; @@ -158,15 +138,11 @@ public function search(IUser $user, ISearchQuery $query): SearchResult { ); } - /** - * @param string $principalUri - * @param string $addressBookUri - * @param string $contactsUri - * @return string - */ - protected function getDavUrlForContact(string $principalUri, - string $addressBookUri, - string $contactsUri): string { + protected function getDavUrlForContact( + string $principalUri, + string $addressBookUri, + string $contactsUri, + ): string { [, $principalType, $principalId] = explode('/', $principalUri, 3); return $this->urlGenerator->getAbsoluteURL( @@ -178,13 +154,10 @@ protected function getDavUrlForContact(string $principalUri, ); } - /** - * @param string $addressBookUri - * @param string $contactUid - * @return string - */ - protected function getDeepLinkToContactsApp(string $addressBookUri, - string $contactUid): string { + protected function getDeepLinkToContactsApp( + string $addressBookUri, + string $contactUid, + ): string { return $this->urlGenerator->getAbsoluteURL( $this->urlGenerator->linkToRoute('contacts.contacts.direct', [ 'contact' => $contactUid . '~' . $addressBookUri @@ -192,10 +165,6 @@ protected function getDeepLinkToContactsApp(string $addressBookUri, ); } - /** - * @param VCard $vCard - * @return string - */ protected function generateSubline(VCard $vCard): string { $emailAddresses = $vCard->select('EMAIL'); if (!is_array($emailAddresses) || empty($emailAddresses)) { @@ -204,4 +173,24 @@ protected function generateSubline(VCard $vCard): string { return (string)$emailAddresses[0]; } + + public function getSupportedFilters(): array { + return [ + 'term', + 'since', + 'until', + 'person', + 'title-only', + ]; + } + + public function getAlternateIds(): array { + return []; + } + + public function getCustomFilters(): array { + return [ + new FilterDefinition('company'), + ]; + } } diff --git a/apps/dav/lib/Search/EventsSearchProvider.php b/apps/dav/lib/Search/EventsSearchProvider.php index 07fc90397ed2d..22c87b1f854a9 100644 --- a/apps/dav/lib/Search/EventsSearchProvider.php +++ b/apps/dav/lib/Search/EventsSearchProvider.php @@ -42,7 +42,6 @@ * @package OCA\DAV\Search */ class EventsSearchProvider extends ACalendarSearchProvider { - /** * @var string[] */ @@ -95,8 +94,10 @@ public function getOrder(string $route, array $routeParameters): int { /** * @inheritDoc */ - public function search(IUser $user, - ISearchQuery $query): SearchResult { + public function search( + IUser $user, + ISearchQuery $query, + ): SearchResult { if (!$this->appManager->isEnabledForUser('calendar', $user)) { return SearchResult::complete($this->getName(), []); } @@ -107,13 +108,17 @@ public function search(IUser $user, $searchResults = $this->backend->searchPrincipalUri( $principalUri, - $query->getTerm(), + $query->getFilter('term')?->get() ?? '', [self::$componentType], self::$searchProperties, self::$searchParameters, [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), + 'timerange' => [ + 'start' => $query->getFilter('since')?->get(), + 'end' => $query->getFilter('until')?->get(), + ], ] ); $formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById):SearchResultEntry { @@ -138,15 +143,11 @@ public function search(IUser $user, ); } - /** - * @param string $principalUri - * @param string $calendarUri - * @param string $calendarObjectUri - * @return string - */ - protected function getDeepLinkToCalendarApp(string $principalUri, - string $calendarUri, - string $calendarObjectUri): string { + protected function getDeepLinkToCalendarApp( + string $principalUri, + string $calendarUri, + string $calendarObjectUri, + ): string { $davUrl = $this->getDavUrlForCalendarObject($principalUri, $calendarUri, $calendarObjectUri); // This route will automatically figure out what recurrence-id to open return $this->urlGenerator->getAbsoluteURL( @@ -156,15 +157,11 @@ protected function getDeepLinkToCalendarApp(string $principalUri, ); } - /** - * @param string $principalUri - * @param string $calendarUri - * @param string $calendarObjectUri - * @return string - */ - protected function getDavUrlForCalendarObject(string $principalUri, - string $calendarUri, - string $calendarObjectUri): string { + protected function getDavUrlForCalendarObject( + string $principalUri, + string $calendarUri, + string $calendarObjectUri + ): string { [,, $principalId] = explode('/', $principalUri, 3); return $this->urlGenerator->linkTo('', 'remote.php') . '/dav/calendars/' @@ -173,10 +170,6 @@ protected function getDavUrlForCalendarObject(string $principalUri, . $calendarObjectUri; } - /** - * @param Component $eventComponent - * @return string - */ protected function generateSubline(Component $eventComponent): string { $dtStart = $eventComponent->DTSTART; $dtEnd = $this->getDTEndForEvent($eventComponent); @@ -207,10 +200,6 @@ protected function generateSubline(Component $eventComponent): string { return "$formattedStartDate $formattedStartTime - $formattedEndDate $formattedEndTime"; } - /** - * @param Component $eventComponent - * @return Property - */ protected function getDTEndForEvent(Component $eventComponent):Property { if (isset($eventComponent->DTEND)) { $end = $eventComponent->DTEND; @@ -233,13 +222,10 @@ protected function getDTEndForEvent(Component $eventComponent):Property { return $end; } - /** - * @param \DateTime $dtStart - * @param \DateTime $dtEnd - * @return bool - */ - protected function isDayEqual(\DateTime $dtStart, - \DateTime $dtEnd) { + protected function isDayEqual( + \DateTime $dtStart, + \DateTime $dtEnd, + ): bool { return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); } } diff --git a/apps/dav/lib/Search/TasksSearchProvider.php b/apps/dav/lib/Search/TasksSearchProvider.php index 763720ee4ae0a..91692ea1c2ab7 100644 --- a/apps/dav/lib/Search/TasksSearchProvider.php +++ b/apps/dav/lib/Search/TasksSearchProvider.php @@ -41,7 +41,6 @@ * @package OCA\DAV\Search */ class TasksSearchProvider extends ACalendarSearchProvider { - /** * @var string[] */ @@ -88,8 +87,10 @@ public function getOrder(string $route, array $routeParameters): int { /** * @inheritDoc */ - public function search(IUser $user, - ISearchQuery $query): SearchResult { + public function search( + IUser $user, + ISearchQuery $query, + ): SearchResult { if (!$this->appManager->isEnabledForUser('tasks', $user)) { return SearchResult::complete($this->getName(), []); } @@ -100,13 +101,15 @@ public function search(IUser $user, $searchResults = $this->backend->searchPrincipalUri( $principalUri, - $query->getTerm(), + $query->getFilter('term')?->get() ?? '', [self::$componentType], self::$searchProperties, self::$searchParameters, [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), + 'since' => $query->getFilter('since'), + 'until' => $query->getFilter('until'), ] ); $formattedResults = \array_map(function (array $taskRow) use ($calendarsById, $subscriptionsById):SearchResultEntry { @@ -131,13 +134,10 @@ public function search(IUser $user, ); } - /** - * @param string $calendarUri - * @param string $taskUri - * @return string - */ - protected function getDeepLinkToTasksApp(string $calendarUri, - string $taskUri): string { + protected function getDeepLinkToTasksApp( + string $calendarUri, + string $taskUri, + ): string { return $this->urlGenerator->getAbsoluteURL( $this->urlGenerator->linkToRoute('tasks.page.index') . '#/calendars/' @@ -147,10 +147,6 @@ protected function getDeepLinkToTasksApp(string $calendarUri, ); } - /** - * @param Component $taskComponent - * @return string - */ protected function generateSubline(Component $taskComponent): string { if ($taskComponent->COMPLETED) { $completedDateTime = new \DateTime($taskComponent->COMPLETED->getDateTime()->format(\DateTimeInterface::ATOM)); diff --git a/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php b/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php index b91612ae5becc..f87a935f8b8b1 100644 --- a/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php @@ -159,7 +159,7 @@ public function testSearch(): void { ]); $this->backend->expects($this->once()) ->method('searchPrincipalUri') - ->with('principals/users/john.doe', 'search term', + ->with('principals/users/john.doe', '', [ 'N', 'FN', @@ -171,7 +171,7 @@ public function testSearch(): void { 'ORG', 'NOTE', ], - ['limit' => 5, 'offset' => 20]) + ['limit' => 5, 'offset' => 20, 'since' => null, 'until' => null, 'person' => null, 'company' => null]) ->willReturn([ [ 'addressbookid' => 99, diff --git a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php index 601c0567f745e..ef1288b085020 100644 --- a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php @@ -328,10 +328,10 @@ public function testSearch(): void { ]); $this->backend->expects($this->once()) ->method('searchPrincipalUri') - ->with('principals/users/john.doe', 'search term', ['VEVENT'], + ->with('principals/users/john.doe', '', ['VEVENT'], ['SUMMARY', 'LOCATION', 'DESCRIPTION', 'ATTENDEE', 'ORGANIZER', 'CATEGORIES'], ['ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN']], - ['limit' => 5, 'offset' => 20]) + ['limit' => 5, 'offset' => 20, 'timerange' => ['start' => null, 'end' => null]]) ->willReturn([ [ 'calendarid' => 99, diff --git a/apps/dav/tests/unit/Search/TasksSearchProviderTest.php b/apps/dav/tests/unit/Search/TasksSearchProviderTest.php index 13dc02bb2783f..b9ec598db0f9e 100644 --- a/apps/dav/tests/unit/Search/TasksSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/TasksSearchProviderTest.php @@ -213,10 +213,10 @@ public function testSearch(): void { ]); $this->backend->expects($this->once()) ->method('searchPrincipalUri') - ->with('principals/users/john.doe', 'search term', ['VTODO'], + ->with('principals/users/john.doe', '', ['VTODO'], ['SUMMARY', 'DESCRIPTION', 'CATEGORIES'], [], - ['limit' => 5, 'offset' => 20]) + ['limit' => 5, 'offset' => 20, 'since' => null, 'until' => null]) ->willReturn([ [ 'calendarid' => 99, diff --git a/apps/files/lib/Search/FilesSearchProvider.php b/apps/files/lib/Search/FilesSearchProvider.php index ba2d4bafa3062..35dd0e214639c 100644 --- a/apps/files/lib/Search/FilesSearchProvider.php +++ b/apps/files/lib/Search/FilesSearchProvider.php @@ -29,25 +29,32 @@ */ namespace OCA\Files\Search; +use InvalidArgumentException; +use OCP\Files\Search\ISearchOperator; +use OCP\Search\FilterDefinition; +use OCP\Search\IFilter; +use OCP\Search\IFilteringProvider; +use OCP\Share\IShare; +use OC\Files\Search\SearchBinaryOperator; use OC\Files\Search\SearchComparison; use OC\Files\Search\SearchOrder; use OC\Files\Search\SearchQuery; use OCP\Files\FileInfo; use OCP\Files\IMimeTypeDetector; use OCP\Files\IRootFolder; -use OCP\Files\Search\ISearchComparison; use OCP\Files\Node; +use OCP\Files\Search\ISearchComparison; use OCP\Files\Search\ISearchOrder; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUser; -use OCP\Search\IProvider; use OCP\Search\ISearchQuery; use OCP\Search\SearchResult; use OCP\Search\SearchResultEntry; +use OC\Search\Filter\GroupFilter; +use OC\Search\Filter\UserFilter; -class FilesSearchProvider implements IProvider { - +class FilesSearchProvider implements IFilteringProvider { /** @var IL10N */ private $l10n; @@ -97,21 +104,38 @@ public function getOrder(string $route, array $routeParameters): int { return 5; } - /** - * @inheritDoc - */ + public function getSupportedFilters(): array { + return [ + 'term', + 'since', + 'until', + 'person', + 'min-size', + 'max-size', + 'mime', + 'type', + 'is-favorite', + 'title-only', + ]; + } + + public function getAlternateIds(): array { + return []; + } + + public function getCustomFilters(): array { + return [ + new FilterDefinition('min-size', FilterDefinition::TYPE_INT), + new FilterDefinition('max-size', FilterDefinition::TYPE_INT), + new FilterDefinition('mime', FilterDefinition::TYPE_STRING), + new FilterDefinition('type', FilterDefinition::TYPE_STRING), + new FilterDefinition('is-favorite', FilterDefinition::TYPE_BOOL), + ]; + } + public function search(IUser $user, ISearchQuery $query): SearchResult { $userFolder = $this->rootFolder->getUserFolder($user->getUID()); - $fileQuery = new SearchQuery( - new SearchComparison(ISearchComparison::COMPARE_LIKE, 'name', '%' . $query->getTerm() . '%'), - $query->getLimit(), - (int)$query->getCursor(), - $query->getSortOrder() === ISearchQuery::SORT_DATE_DESC ? [ - new SearchOrder(ISearchOrder::DIRECTION_DESCENDING, 'mtime'), - ] : [], - $user - ); - + $fileQuery = $this->buildSearchQuery($query, $user); return SearchResult::paginated( $this->l10n->t('Files'), array_map(function (Node $result) use ($userFolder) { @@ -141,6 +165,53 @@ public function search(IUser $user, ISearchQuery $query): SearchResult { ); } + private function buildSearchQuery(ISearchQuery $query, IUser $user): SearchQuery { + $comparisons = []; + foreach ($query->getFilters() as $name => $filter) { + $comparisons[] = match ($name) { + 'term' => new SearchComparison(ISearchComparison::COMPARE_LIKE, 'name', '%' . $filter->get() . '%'), + 'since' => new SearchComparison(ISearchComparison::COMPARE_GREATER_THAN_EQUAL, 'mtime', $filter->get()->getTimestamp()), + 'until' => new SearchComparison(ISearchComparison::COMPARE_LESS_THAN_EQUAL, 'mtime', $filter->get()->getTimestamp()), + 'min-size' => new SearchComparison(ISearchComparison::COMPARE_GREATER_THAN_EQUAL, 'size', $filter->get()), + 'max-size' => new SearchComparison(ISearchComparison::COMPARE_LESS_THAN_EQUAL, 'size', $filter->get()), + 'mime' => new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', $filter->get()), + 'type' => new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mimetype', $filter->get() . '/%'), + 'person' => $this->buildPersonSearchQuery($filter), + default => throw new InvalidArgumentException('Unsupported comparison'), + }; + } + + return new SearchQuery( + new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, $comparisons), + $query->getLimit(), + (int) $query->getCursor(), + $query->getSortOrder() === ISearchQuery::SORT_DATE_DESC + ? [new SearchOrder(ISearchOrder::DIRECTION_DESCENDING, 'mtime')] + : [], + $user + ); + } + + private function buildPersonSearchQuery(IFilter $person): ISearchOperator { + if ($person instanceof UserFilter) { + return new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_OR, [ + new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, [ + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_with', $person->get()->getUID()), + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_type', IShare::TYPE_USER), + ]), + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'owner', $person->get()->getUID()), + ]); + } + if ($person instanceof GroupFilter) { + return new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, [ + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_with', $person->get()->getGID()), + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'share_type', IShare::TYPE_GROUP), + ]); + } + + throw new InvalidArgumentException('Unsupported filter type'); + } + /** * Format subline for files * diff --git a/core/Controller/UnifiedSearchController.php b/core/Controller/UnifiedSearchController.php index 9704850bb1f27..87aa84e1d91b6 100644 --- a/core/Controller/UnifiedSearchController.php +++ b/core/Controller/UnifiedSearchController.php @@ -31,14 +31,15 @@ use OC\Search\SearchComposer; use OC\Search\SearchQuery; use OCA\Core\ResponseDefinitions; -use OCP\AppFramework\OCSController; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserSession; use OCP\Route\IRouter; use OCP\Search\ISearchQuery; +use OC\Search\UnsupportedFilter; use Symfony\Component\Routing\Exception\ResourceNotFoundException; /** @@ -80,7 +81,10 @@ public function getProviders(string $from = ''): DataResponse { * @NoAdminRequired * @NoCSRFRequired * - * Search + * Launch a search for a specific search provider. + * + * Additional filters are available for each provider. + * Send a request to /providers endpoint to list providers with their available filters. * * @param string $providerId ID of the provider * @param string $term Term to search @@ -89,28 +93,33 @@ public function getProviders(string $from = ''): DataResponse { * @param int|string|null $cursor Offset for searching * @param string $from The current user URL * - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: Search entries returned * 400: Searching is not possible */ - public function search(string $providerId, - string $term = '', - ?int $sortOrder = null, - ?int $limit = null, - $cursor = null, - string $from = ''): DataResponse { - if (trim($term) === "") { - return new DataResponse(null, Http::STATUS_BAD_REQUEST); - } + public function search( + string $providerId, + // Unused parameter for OpenAPI spec generator + string $term = '', + ?int $sortOrder = null, + ?int $limit = null, + $cursor = null, + string $from = '', + ): DataResponse { [$route, $routeParameters] = $this->getRouteInformation($from); + try { + $filters = $this->composer->buildFilterList($providerId, $this->request->getParams()); + } catch (UnsupportedFilter $e) { + return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); + } return new DataResponse( $this->composer->search( $this->userSession->getUser(), $providerId, new SearchQuery( - $term, + $filters, $sortOrder ?? ISearchQuery::SORT_DATE_DESC, $limit ?? SearchQuery::LIMIT_DEFAULT, $cursor, diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index ca3f117051c4c..86eef1d252472 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -98,8 +98,12 @@ * * @psalm-type CoreUnifiedSearchProvider = array{ * id: string, + * appId: string, * name: string, + * icon: string, * order: int, + * triggers: string[], + * filters: array, * } * * @psalm-type CoreUnifiedSearchResultEntry = array{ diff --git a/core/openapi.json b/core/openapi.json index 7cb48b58f0a4c..a63d9380db7bc 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -501,19 +501,41 @@ "type": "object", "required": [ "id", + "appId", "name", - "order" + "icon", + "order", + "triggers", + "filters" ], "properties": { "id": { "type": "string" }, + "appId": { + "type": "string" + }, "name": { "type": "string" }, + "icon": { + "type": "string" + }, "order": { "type": "integer", "format": "int64" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + } + }, + "filters": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } }, @@ -4000,7 +4022,8 @@ "/ocs/v2.php/search/providers/{providerId}/search": { "get": { "operationId": "unified_search-search", - "summary": "Search", + "summary": "Launch a search for a specific search provider.", + "description": "Additional filters are available for each provider. Send a request to /providers endpoint to list providers with their available filters.", "tags": [ "unified_search" ], @@ -4132,7 +4155,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "nullable": true + "type": "string" } } } diff --git a/lib/composer/composer/InstalledVersions.php b/lib/composer/composer/InstalledVersions.php index c6b54af7ba2e1..51e734a774b3e 100644 --- a/lib/composer/composer/InstalledVersions.php +++ b/lib/composer/composer/InstalledVersions.php @@ -98,7 +98,7 @@ public static function isInstalled($packageName, $includeDevRequirements = true) { foreach (self::getInstalled() as $installed) { if (isset($installed['versions'][$packageName])) { - return $includeDevRequirements || empty($installed['versions'][$packageName]['dev_requirement']); + return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; } } @@ -119,7 +119,7 @@ public static function isInstalled($packageName, $includeDevRequirements = true) */ public static function satisfies(VersionParser $parser, $packageName, $constraint) { - $constraint = $parser->parseConstraints($constraint); + $constraint = $parser->parseConstraints((string) $constraint); $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); return $provided->matches($constraint); @@ -328,7 +328,9 @@ private static function getInstalled() if (isset(self::$installedByVendor[$vendorDir])) { $installed[] = self::$installedByVendor[$vendorDir]; } elseif (is_file($vendorDir.'/composer/installed.php')) { - $installed[] = self::$installedByVendor[$vendorDir] = require $vendorDir.'/composer/installed.php'; + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require $vendorDir.'/composer/installed.php'; + $installed[] = self::$installedByVendor[$vendorDir] = $required; if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) { self::$installed = $installed[count($installed) - 1]; } @@ -340,12 +342,17 @@ private static function getInstalled() // only require the installed.php file if this file is loaded from its dumped location, // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 if (substr(__DIR__, -8, 1) !== 'C') { - self::$installed = require __DIR__ . '/installed.php'; + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require __DIR__ . '/installed.php'; + self::$installed = $required; } else { self::$installed = array(); } } - $installed[] = self::$installed; + + if (self::$installed !== array()) { + $installed[] = self::$installed; + } return $installed; } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 8eaf0970494c1..cb94551e35e8b 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -589,6 +589,10 @@ '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\\FilterDefinition' => $baseDir . '/lib/public/Search/FilterDefinition.php', + 'OCP\\Search\\IFilter' => $baseDir . '/lib/public/Search/IFilter.php', + 'OCP\\Search\\IFilterCollection' => $baseDir . '/lib/public/Search/IFilterCollection.php', + 'OCP\\Search\\IFilteringProvider' => $baseDir . '/lib/public/Search/IFilteringProvider.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', @@ -1641,6 +1645,16 @@ 'OC\\Route\\Route' => $baseDir . '/lib/private/Route/Route.php', 'OC\\Route\\Router' => $baseDir . '/lib/private/Route/Router.php', 'OC\\Search' => $baseDir . '/lib/private/Search.php', + 'OC\\Search\\FilterCollection' => $baseDir . '/lib/private/Search/FilterCollection.php', + 'OC\\Search\\FilterFactory' => $baseDir . '/lib/private/Search/FilterFactory.php', + 'OC\\Search\\Filter\\BooleanFilter' => $baseDir . '/lib/private/Search/Filter/BooleanFilter.php', + 'OC\\Search\\Filter\\DateTimeFilter' => $baseDir . '/lib/private/Search/Filter/DateTimeFilter.php', + 'OC\\Search\\Filter\\FloatFilter' => $baseDir . '/lib/private/Search/Filter/FloatFilter.php', + 'OC\\Search\\Filter\\GroupFilter' => $baseDir . '/lib/private/Search/Filter/GroupFilter.php', + 'OC\\Search\\Filter\\IntegerFilter' => $baseDir . '/lib/private/Search/Filter/IntegerFilter.php', + 'OC\\Search\\Filter\\StringFilter' => $baseDir . '/lib/private/Search/Filter/StringFilter.php', + 'OC\\Search\\Filter\\StringsFilter' => $baseDir . '/lib/private/Search/Filter/StringsFilter.php', + 'OC\\Search\\Filter\\UserFilter' => $baseDir . '/lib/private/Search/Filter/UserFilter.php', 'OC\\Search\\Provider\\File' => $baseDir . '/lib/private/Search/Provider/File.php', 'OC\\Search\\Result\\Audio' => $baseDir . '/lib/private/Search/Result/Audio.php', 'OC\\Search\\Result\\File' => $baseDir . '/lib/private/Search/Result/File.php', @@ -1648,6 +1662,7 @@ '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\\Search\\UnsupportedFilter' => $baseDir . '/lib/private/Search/UnsupportedFilter.php', 'OC\\Security\\Bruteforce\\Backend\\DatabaseBackend' => $baseDir . '/lib/private/Security/Bruteforce/Backend/DatabaseBackend.php', 'OC\\Security\\Bruteforce\\Backend\\IBackend' => $baseDir . '/lib/private/Security/Bruteforce/Backend/IBackend.php', 'OC\\Security\\Bruteforce\\Backend\\MemoryCacheBackend' => $baseDir . '/lib/private/Security/Bruteforce/Backend/MemoryCacheBackend.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index bd674e317a29a..b19f568ac1f0d 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -622,6 +622,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 '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\\FilterDefinition' => __DIR__ . '/../../..' . '/lib/public/Search/FilterDefinition.php', + 'OCP\\Search\\IFilter' => __DIR__ . '/../../..' . '/lib/public/Search/IFilter.php', + 'OCP\\Search\\IFilterCollection' => __DIR__ . '/../../..' . '/lib/public/Search/IFilterCollection.php', + 'OCP\\Search\\IFilteringProvider' => __DIR__ . '/../../..' . '/lib/public/Search/IFilteringProvider.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', @@ -1674,6 +1678,16 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Route\\Route' => __DIR__ . '/../../..' . '/lib/private/Route/Route.php', 'OC\\Route\\Router' => __DIR__ . '/../../..' . '/lib/private/Route/Router.php', 'OC\\Search' => __DIR__ . '/../../..' . '/lib/private/Search.php', + 'OC\\Search\\FilterCollection' => __DIR__ . '/../../..' . '/lib/private/Search/FilterCollection.php', + 'OC\\Search\\FilterFactory' => __DIR__ . '/../../..' . '/lib/private/Search/FilterFactory.php', + 'OC\\Search\\Filter\\BooleanFilter' => __DIR__ . '/../../..' . '/lib/private/Search/Filter/BooleanFilter.php', + 'OC\\Search\\Filter\\DateTimeFilter' => __DIR__ . '/../../..' . '/lib/private/Search/Filter/DateTimeFilter.php', + 'OC\\Search\\Filter\\FloatFilter' => __DIR__ . '/../../..' . '/lib/private/Search/Filter/FloatFilter.php', + 'OC\\Search\\Filter\\GroupFilter' => __DIR__ . '/../../..' . '/lib/private/Search/Filter/GroupFilter.php', + 'OC\\Search\\Filter\\IntegerFilter' => __DIR__ . '/../../..' . '/lib/private/Search/Filter/IntegerFilter.php', + 'OC\\Search\\Filter\\StringFilter' => __DIR__ . '/../../..' . '/lib/private/Search/Filter/StringFilter.php', + 'OC\\Search\\Filter\\StringsFilter' => __DIR__ . '/../../..' . '/lib/private/Search/Filter/StringsFilter.php', + 'OC\\Search\\Filter\\UserFilter' => __DIR__ . '/../../..' . '/lib/private/Search/Filter/UserFilter.php', 'OC\\Search\\Provider\\File' => __DIR__ . '/../../..' . '/lib/private/Search/Provider/File.php', 'OC\\Search\\Result\\Audio' => __DIR__ . '/../../..' . '/lib/private/Search/Result/Audio.php', 'OC\\Search\\Result\\File' => __DIR__ . '/../../..' . '/lib/private/Search/Result/File.php', @@ -1681,6 +1695,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 '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\\Search\\UnsupportedFilter' => __DIR__ . '/../../..' . '/lib/private/Search/UnsupportedFilter.php', 'OC\\Security\\Bruteforce\\Backend\\DatabaseBackend' => __DIR__ . '/../../..' . '/lib/private/Security/Bruteforce/Backend/DatabaseBackend.php', 'OC\\Security\\Bruteforce\\Backend\\IBackend' => __DIR__ . '/../../..' . '/lib/private/Security/Bruteforce/Backend/IBackend.php', 'OC\\Security\\Bruteforce\\Backend\\MemoryCacheBackend' => __DIR__ . '/../../..' . '/lib/private/Security/Bruteforce/Backend/MemoryCacheBackend.php', diff --git a/lib/composer/composer/installed.php b/lib/composer/composer/installed.php index 1f382499aeb21..7115927cba0e9 100644 --- a/lib/composer/composer/installed.php +++ b/lib/composer/composer/installed.php @@ -1,22 +1,22 @@ array( - 'pretty_version' => '1.0.0+no-version-set', - 'version' => '1.0.0.0', + 'name' => '__root__', + 'pretty_version' => 'dev-master', + 'version' => 'dev-master', + 'reference' => '41d274cd58f168047eb6a7673a7e43fff69ac07f', 'type' => 'library', 'install_path' => __DIR__ . '/../../../', 'aliases' => array(), - 'reference' => NULL, - 'name' => '__root__', 'dev' => false, ), 'versions' => array( '__root__' => array( - 'pretty_version' => '1.0.0+no-version-set', - 'version' => '1.0.0.0', + 'pretty_version' => 'dev-master', + 'version' => 'dev-master', + 'reference' => '41d274cd58f168047eb6a7673a7e43fff69ac07f', 'type' => 'library', 'install_path' => __DIR__ . '/../../../', 'aliases' => array(), - 'reference' => NULL, 'dev_requirement' => false, ), ), diff --git a/lib/private/Files/Cache/CacheQueryBuilder.php b/lib/private/Files/Cache/CacheQueryBuilder.php index 27f66e63e7bf3..7971b5c630715 100644 --- a/lib/private/Files/Cache/CacheQueryBuilder.php +++ b/lib/private/Files/Cache/CacheQueryBuilder.php @@ -71,7 +71,7 @@ public function selectTagUsage(): self { public function selectFileCache(string $alias = null, bool $joinExtendedCache = true) { $name = $alias ?: 'filecache'; $this->select("$name.fileid", 'storage', 'path', 'path_hash', "$name.parent", "$name.name", 'mimetype', 'mimepart', 'size', 'mtime', - 'storage_mtime', 'encrypted', 'etag', 'permissions', 'checksum', 'unencrypted_size') + 'storage_mtime', 'encrypted', 'etag', "$name.permissions", 'checksum', 'unencrypted_size') ->from('filecache', $name); if ($joinExtendedCache) { diff --git a/lib/private/Files/Cache/QuerySearchHelper.php b/lib/private/Files/Cache/QuerySearchHelper.php index f8e5d1608f742..6aa230c842f2d 100644 --- a/lib/private/Files/Cache/QuerySearchHelper.php +++ b/lib/private/Files/Cache/QuerySearchHelper.php @@ -134,6 +134,11 @@ protected function equipQueryForDavTags(CacheQueryBuilder $query, IUser $user): )); } + + protected function equipQueryForShares(CacheQueryBuilder $query): void { + $query->join('file', 'share', 's', $query->expr()->eq('file.fileid', 's.file_source')); + } + /** * Perform a file system search in multiple caches * @@ -172,6 +177,9 @@ public function searchInCaches(ISearchQuery $searchQuery, array $caches): array if (in_array('tagname', $requestedFields) || in_array('favorite', $requestedFields)) { $this->equipQueryForDavTags($query, $this->requireUser($searchQuery)); } + if (in_array('owner', $requestedFields) || in_array('share_with', $requestedFields) || in_array('share_type', $requestedFields)) { + $this->equipQueryForShares($query); + } $metadataQuery = $query->selectMetadata(); diff --git a/lib/private/Files/Cache/SearchBuilder.php b/lib/private/Files/Cache/SearchBuilder.php index 1f9a6af931b2d..860d5e41d89a1 100644 --- a/lib/private/Files/Cache/SearchBuilder.php +++ b/lib/private/Files/Cache/SearchBuilder.php @@ -192,6 +192,8 @@ private function getOperatorFieldAndValue(ISearchComparison $operator) { } elseif ($field === 'path' && $type === ISearchComparison::COMPARE_EQUAL && $operator->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true)) { $field = 'path_hash'; $value = md5((string)$value); + } elseif ($field === 'owner') { + $field = 'uid_owner'; } return [$field, $value, $type]; } @@ -208,6 +210,9 @@ private function validateComparison(ISearchComparison $operator) { 'favorite' => 'boolean', 'fileid' => 'integer', 'storage' => 'integer', + 'share_with' => 'string', + 'share_type' => 'integer', + 'owner' => 'string', ]; $comparisons = [ 'mimetype' => ['eq', 'like'], @@ -220,6 +225,9 @@ private function validateComparison(ISearchComparison $operator) { 'favorite' => ['eq'], 'fileid' => ['eq'], 'storage' => ['eq'], + 'share_with' => ['eq'], + 'share_type' => ['eq'], + 'owner' => ['eq'], ]; if (!isset($types[$operator->getField()])) { diff --git a/lib/private/Search/Filter/BooleanFilter.php b/lib/private/Search/Filter/BooleanFilter.php new file mode 100644 index 0000000000000..a64bf17f31c20 --- /dev/null +++ b/lib/private/Search/Filter/BooleanFilter.php @@ -0,0 +1,46 @@ + + * + * @author Benjamin Gaussorgues + * + * @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\Filter; + +use InvalidArgumentException; +use OCP\Search\IFilter; + +class BooleanFilter implements IFilter { + private bool $value; + + public function __construct(string $value) { + $this->value = match ($value) { + 'true', 'yes', 'y', '1' => true, + 'false', 'no', 'n', '0', '' => false, + default => throw new InvalidArgumentException('Invalid boolean value '. $value), + }; + } + + public function get(): bool { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/DateTimeFilter.php b/lib/private/Search/Filter/DateTimeFilter.php new file mode 100644 index 0000000000000..79abf9ad542c0 --- /dev/null +++ b/lib/private/Search/Filter/DateTimeFilter.php @@ -0,0 +1,46 @@ + + * + * @author Benjamin Gaussorgues + * + * @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\Filter; + +use DateTimeImmutable; +use OCP\Search\IFilter; + +class DateTimeFilter implements IFilter { + private DateTimeImmutable $value; + + public function __construct(string $value) { + if (filter_var($value, FILTER_VALIDATE_INT)) { + $value = '@'.$value; + } + + $this->value = new DateTimeImmutable($value); + } + + public function get(): DateTimeImmutable { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/FloatFilter.php b/lib/private/Search/Filter/FloatFilter.php new file mode 100644 index 0000000000000..3db19ded59b43 --- /dev/null +++ b/lib/private/Search/Filter/FloatFilter.php @@ -0,0 +1,45 @@ + + * + * @author Benjamin Gaussorgues + * + * @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\Filter; + +use InvalidArgumentException; +use OCP\Search\IFilter; + +class FloatFilter implements IFilter { + private float $value; + + public function __construct(string $value) { + $this->value = filter_var($value, FILTER_VALIDATE_FLOAT); + if ($this->value === false) { + throw new InvalidArgumentException('Invalid float value '. $value); + } + } + + public function get(): float { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/GroupFilter.php b/lib/private/Search/Filter/GroupFilter.php new file mode 100644 index 0000000000000..6b42492824c4b --- /dev/null +++ b/lib/private/Search/Filter/GroupFilter.php @@ -0,0 +1,50 @@ + + * + * @author Benjamin Gaussorgues + * + * @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\Filter; + +use InvalidArgumentException; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\Search\IFilter; + +class GroupFilter implements IFilter { + private IGroup $group; + + public function __construct( + string $value, + IGroupManager $groupManager, + ) { + $this->group = $groupManager->get($value); + if ($this->group === null) { + throw new InvalidArgumentException('Group '.$value.' not found'); + } + } + + public function get(): IGroup { + return $this->group; + } +} diff --git a/lib/private/Search/Filter/IntegerFilter.php b/lib/private/Search/Filter/IntegerFilter.php new file mode 100644 index 0000000000000..b5b907b220e09 --- /dev/null +++ b/lib/private/Search/Filter/IntegerFilter.php @@ -0,0 +1,45 @@ + + * + * @author Benjamin Gaussorgues + * + * @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\Filter; + +use InvalidArgumentException; +use OCP\Search\IFilter; + +class IntegerFilter implements IFilter { + private int $value; + + public function __construct(string $value) { + $this->value = filter_var($value, FILTER_VALIDATE_INT); + if ($this->value === false) { + throw new InvalidArgumentException('Invalid integer value '. $value); + } + } + + public function get(): int { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/StringFilter.php b/lib/private/Search/Filter/StringFilter.php new file mode 100644 index 0000000000000..8f754d1205171 --- /dev/null +++ b/lib/private/Search/Filter/StringFilter.php @@ -0,0 +1,44 @@ + + * + * @author Benjamin Gaussorgues + * + * @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\Filter; + +use InvalidArgumentException; +use OCP\Search\IFilter; + +class StringFilter implements IFilter { + public function __construct( + private string $value, + ) { + if ($value === '') { + throw new InvalidArgumentException('String filter can’t be empty'); + } + } + + public function get(): string { + return $this->value; + } +} diff --git a/lib/private/Search/Filter/StringsFilter.php b/lib/private/Search/Filter/StringsFilter.php new file mode 100644 index 0000000000000..7a8d88768e8c0 --- /dev/null +++ b/lib/private/Search/Filter/StringsFilter.php @@ -0,0 +1,51 @@ + + * + * @author Benjamin Gaussorgues + * + * @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\Filter; + +use InvalidArgumentException; +use OCP\Search\IFilter; + +class StringsFilter implements IFilter { + /** + * @var string[] + */ + private array $values; + + public function __construct(string ...$values) { + $this->values = array_unique(array_filter($values)); + if (empty($this->values)) { + throw new InvalidArgumentException('Strings filter can’t be empty'); + } + } + + /** + * @return string[] + */ + public function get(): array { + return $this->values; + } +} diff --git a/lib/private/Search/Filter/UserFilter.php b/lib/private/Search/Filter/UserFilter.php new file mode 100644 index 0000000000000..1624b60d1a307 --- /dev/null +++ b/lib/private/Search/Filter/UserFilter.php @@ -0,0 +1,50 @@ + + * + * @author Benjamin Gaussorgues + * + * @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\Filter; + +use InvalidArgumentException; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Search\IFilter; + +class UserFilter implements IFilter { + private IUser $user; + + public function __construct( + string $value, + IUserManager $userManager, + ) { + $this->user = $userManager->get($value); + if ($this->user === null) { + throw new InvalidArgumentException('User '.$value.' not found'); + } + } + + public function get(): IUser { + return $this->user; + } +} diff --git a/lib/private/Search/FilterCollection.php b/lib/private/Search/FilterCollection.php new file mode 100644 index 0000000000000..8c23cc7c11069 --- /dev/null +++ b/lib/private/Search/FilterCollection.php @@ -0,0 +1,60 @@ + + * + * @author Benjamin Gaussorgues + * + * @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 Generator; +use OCP\Search\IFilterCollection; +use OCP\Search\IFilter; + +/** + * Interface for search filters + * + * @since 28.0.0 + */ +class FilterCollection implements IFilterCollection { + /** + * @var IFilter[] + */ + private array $filters; + + public function __construct(IFilter ...$filters) { + $this->filters = $filters; + } + + public function has(string $name): bool { + return isset($this->filters[$name]); + } + + public function get(string $name): ?IFilter { + return $this->filters[$name] ?? null; + } + + public function getIterator(): Generator { + foreach ($this->filters as $k => $v) { + yield $k => $v; + } + } +} diff --git a/lib/private/Search/FilterFactory.php b/lib/private/Search/FilterFactory.php new file mode 100644 index 0000000000000..19afb2c04be52 --- /dev/null +++ b/lib/private/Search/FilterFactory.php @@ -0,0 +1,58 @@ + + * + * @author Benjamin Gaussorgues + * + * @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\FilterDefinition; +use OCP\Search\IFilter; +use OCP\IGroupManager; +use OCP\IUserManager; +use RuntimeException; + +final class FilterFactory { + public static function get(string $type, string|array $filter): IFilter { + return match ($type) { + FilterDefinition::TYPE_BOOL => new Filter\BooleanFilter($filter), + FilterDefinition::TYPE_DATETIME => new Filter\DateTimeFilter($filter), + FilterDefinition::TYPE_FLOAT => new Filter\FloatFilter($filter), + FilterDefinition::TYPE_INT => new Filter\IntegerFilter($filter), + FilterDefinition::TYPE_NC_GROUP => new Filter\GroupFilter($filter, \OC::$server->get(IGroupManager::class)), + FilterDefinition::TYPE_NC_USER => new Filter\UserFilter($filter, \OC::$server->get(IUserManager::class)), + FilterDefinition::TYPE_PERSON => self::getPerson($filter), + FilterDefinition::TYPE_STRING => new Filter\StringFilter($filter), + FilterDefinition::TYPE_STRINGS => new Filter\StringsFilter(... (array) $filter), + default => throw new RuntimeException('Invalid filter type '. $type), + }; + } + + private static function getPerson(string $person): IFilter { + $parts = explode('_', $person, 2); + + return match (count($parts)) { + 1 => self::get(FilterDefinition::TYPE_NC_USER, $person), + 2 => self::get(... $parts), + }; + } +} diff --git a/lib/private/Search/SearchComposer.php b/lib/private/Search/SearchComposer.php index 4ec73ec54e976..41d969ca90bc5 100644 --- a/lib/private/Search/SearchComposer.php +++ b/lib/private/Search/SearchComposer.php @@ -28,14 +28,19 @@ namespace OC\Search; use InvalidArgumentException; -use OCP\AppFramework\QueryException; -use OCP\IServerContainer; +use OCP\IURLGenerator; +use OCP\Search\FilterDefinition; +use OCP\Search\IFilteringProvider; +use OC\AppFramework\Bootstrap\Coordinator; use OCP\IUser; +use OCP\Search\IFilter; use OCP\Search\IProvider; use OCP\Search\ISearchQuery; use OCP\Search\SearchResult; -use OC\AppFramework\Bootstrap\Coordinator; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use RuntimeException; use function array_map; /** @@ -58,31 +63,40 @@ * @see IProvider::search() for the arguments of the individual search requests */ class SearchComposer { - /** @var IProvider[] */ - private $providers = []; - - /** @var Coordinator */ - private $bootstrapCoordinator; + /** + * @var array + */ + private array $providers = []; - /** @var IServerContainer */ - private $container; + private array $commonFilters; + private array $customFilters = []; - private LoggerInterface $logger; + private array $handlers = []; - public function __construct(Coordinator $bootstrapCoordinator, - IServerContainer $container, - LoggerInterface $logger) { - $this->container = $container; - $this->logger = $logger; - $this->bootstrapCoordinator = $bootstrapCoordinator; + public function __construct( + private Coordinator $bootstrapCoordinator, + private ContainerInterface $container, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger + ) { + $this->commonFilters = [ + 'term' => new FilterDefinition('term', FilterDefinition::TYPE_STRING), + 'since' => new FilterDefinition('since', FilterDefinition::TYPE_DATETIME), + 'until' => new FilterDefinition('until', FilterDefinition::TYPE_DATETIME), + 'title-only' => new FilterDefinition('title-only', FilterDefinition::TYPE_BOOL, false), + 'person' => new FilterDefinition('person', FilterDefinition::TYPE_PERSON), + 'places' => new FilterDefinition('places', FilterDefinition::TYPE_STRINGS, false), + 'provider' => new FilterDefinition('provider', FilterDefinition::TYPE_STRING, false), + ]; } /** * Load all providers dynamically that were registered through `registerProvider` * + * If $targetProviderId is provided, only this provider is loaded * If a provider can't be loaded we log it but the operation continues nevertheless */ - private function loadLazyProviders(): void { + private function loadLazyProviders(?string $targetProviderId = null): void { $context = $this->bootstrapCoordinator->getRegistrationContext(); if ($context === null) { // Too early, nothing registered yet @@ -93,9 +107,20 @@ private function loadLazyProviders(): void { foreach ($registrations as $registration) { try { /** @var IProvider $provider */ - $provider = $this->container->query($registration->getService()); - $this->providers[$provider->getId()] = $provider; - } catch (QueryException $e) { + $provider = $this->container->get($registration->getService()); + $providerId = $provider->getId(); + if ($targetProviderId !== null && $targetProviderId !== $providerId) { + continue; + } + $this->providers[$providerId] = [ + 'appId' => $registration->getAppId(), + 'provider' => $provider, + ]; + $this->handlers[$providerId] = [$providerId]; + if ($targetProviderId !== null) { + break; + } + } catch (ContainerExceptionInterface $e) { // Log an continue. We can be fault tolerant here. $this->logger->error('Could not load search provider dynamically: ' . $e->getMessage(), [ 'exception' => $e, @@ -103,6 +128,43 @@ private function loadLazyProviders(): void { ]); } } + + $this->loadFilters(); + } + + private function loadFilters(): void { + foreach ($this->providers as $providerId => $providerData) { + $appId = $providerData['appId']; + $provider = $providerData['provider']; + if (!$provider instanceof IFilteringProvider) { + continue; + } + + foreach ($provider->getCustomFilters() as $filter) { + $this->registerCustomFilter($filter, $providerId); + } + foreach ($provider->getAlternateIds() as $alternateId) { + $this->handlers[$alternateId][] = $providerId; + } + foreach ($provider->getSupportedFilters() as $filterName) { + if ($this->getFilterDefinition($filterName, $providerId) === null) { + throw new InvalidArgumentException('Invalid filter '. $filterName); + } + } + } + } + + private function registerCustomFilter(FilterDefinition $filter, string $providerId): void { + $name = $filter->name(); + if (isset($this->commonFilters[$name])) { + throw new InvalidArgumentException('Filter name is already used'); + } + + if (isset($this->customFilters[$providerId])) { + $this->customFilters[$providerId][$name] = $filter; + } else { + $this->customFilters[$providerId] = [$name => $filter]; + } } /** @@ -117,26 +179,134 @@ private function loadLazyProviders(): void { public function getProviders(string $route, array $routeParameters): array { $this->loadLazyProviders(); - $providers = array_values( - array_map(function (IProvider $provider) use ($route, $routeParameters) { + $providers = array_map( + function (array $providerData) use ($route, $routeParameters) { + $appId = $providerData['appId']; + $provider = $providerData['provider']; + $triggers = [$provider->getId()]; + if ($provider instanceof IFilteringProvider) { + $triggers += $provider->getAlternateIds(); + $filters = $provider->getSupportedFilters(); + } else { + $filters = ['term']; + } + return [ 'id' => $provider->getId(), + 'appId' => $appId, 'name' => $provider->getName(), + 'icon' => $this->fetchIcon($appId, $provider->getId()), 'order' => $provider->getOrder($route, $routeParameters), + 'triggers' => $triggers, + 'filters' => $this->getFiltersType($filters, $provider->getId()), ]; - }, $this->providers) + }, + $this->providers, ); + // Sort providers by order and strip associative keys usort($providers, function ($provider1, $provider2) { return $provider1['order'] <=> $provider2['order']; }); - /** - * Return an array with the IDs, but strip the associative keys - */ return $providers; } + private function fetchIcon(string $appId, string $providerId): string { + $icons = [ + [$providerId, $providerId.'.svg'], + [$providerId, 'app.svg'], + [$appId, $providerId.'.svg'], + [$appId, $appId.'.svg'], + [$appId, 'app.svg'], + ['core', 'places/default-app-icon.svg'], + ]; + foreach ($icons as $i => $icon) { + try { + return $this->urlGenerator->imagePath(... $icon); + } catch (RuntimeException $e) { + // Ignore error + } + } + + return ''; + } + + /** + * @param $filters string[] + * @return array + */ + private function getFiltersType(array $filters, string $providerId): array { + $filterList = []; + foreach ($filters as $filter) { + $filterList[$filter] = $this->getFilterDefinition($filter, $providerId)->type(); + } + + return $filterList; + } + + private function getFilterDefinition(string $name, string $providerId): ?FilterDefinition { + if (isset($this->commonFilters[$name])) { + return $this->commonFilters[$name]; + } + if (isset($this->customFilters[$providerId][$name])) { + return $this->customFilters[$providerId][$name]; + } + + return null; + } + + /** + * @param array $parameters + */ + public function buildFilterList(string $providerId, array $parameters): FilterCollection { + $this->loadLazyProviders($providerId); + + $list = []; + foreach ($parameters as $name => $value) { + $filter = $this->buildFilter($name, $value, $providerId); + if ($filter === null) { + continue; + } + $list[$name] = $filter; + } + + return new FilterCollection(... $list); + } + + private function buildFilter(string $name, string $value, string $providerId): ?IFilter { + $filterDefinition = $this->getFilterDefinition($name, $providerId); + if ($filterDefinition === null) { + $this->logger->debug('Unable to find {name} definition', [ + 'name' => $name, + 'value' => $value, + ]); + + return null; + } + + if (!$this->filterSupportedByProvider($filterDefinition, $providerId)) { + // FIXME Use dedicated exception and handle it + throw new UnsupportedFilter($name, $providerId); + } + + return FilterFactory::get($filterDefinition->type(), $value); + } + + private function filterSupportedByProvider(FilterDefinition $filterDefinition, string $providerId): bool { + // Non exclusive filters can be ommited by apps + if (!$filterDefinition->exclusive()) { + return true; + } + + $provider = $this->providers[$providerId]['provider']; + $supportedFilters = $provider instanceof IFilteringProvider + ? $provider->getSupportedFilters() + : ['term']; + + return in_array($filterDefinition->name(), $supportedFilters, true); + } + /** * Query an individual search provider for results * @@ -147,15 +317,18 @@ public function getProviders(string $route, array $routeParameters): array { * @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(); + public function search( + IUser $user, + string $providerId, + ISearchQuery $query, + ): SearchResult { + $this->loadLazyProviders($providerId); - $provider = $this->providers[$providerId] ?? null; + $provider = $this->providers[$providerId]['provider'] ?? null; if ($provider === null) { throw new InvalidArgumentException("Provider $providerId is unknown"); } + return $provider->search($user, $query); } } diff --git a/lib/private/Search/SearchQuery.php b/lib/private/Search/SearchQuery.php index c89446d59703b..aae2044bcd65a 100644 --- a/lib/private/Search/SearchQuery.php +++ b/lib/private/Search/SearchQuery.php @@ -27,89 +27,57 @@ */ namespace OC\Search; +use OCP\Search\IFilterCollection; +use OCP\Search\IFilter; use OCP\Search\ISearchQuery; class SearchQuery implements ISearchQuery { public const LIMIT_DEFAULT = 5; - /** @var string */ - private $term; - - /** @var int */ - private $sortOrder; - - /** @var int */ - private $limit; - - /** @var int|string|null */ - private $cursor; - - /** @var string */ - private $route; - - /** @var array */ - private $routeParameters; - /** - * @param string $term - * @param int $sortOrder - * @param int $limit - * @param int|string|null $cursor - * @param string $route - * @param array $routeParameters + * @param string[] $params Request query + * @param string[] $routeParameters */ - public function __construct(string $term, - int $sortOrder = ISearchQuery::SORT_DATE_DESC, - int $limit = self::LIMIT_DEFAULT, - $cursor = null, - string $route = '', - array $routeParameters = []) { - $this->term = $term; - $this->sortOrder = $sortOrder; - $this->limit = $limit; - $this->cursor = $cursor; - $this->route = $route; - $this->routeParameters = $routeParameters; + public function __construct( + private IFilterCollection $filters, + private int $sortOrder = ISearchQuery::SORT_DATE_DESC, + private int $limit = self::LIMIT_DEFAULT, + private int|string|null $cursor = null, + private string $route = '', + private array $routeParameters = [], + ) { } - /** - * @inheritDoc - */ public function getTerm(): string { - return $this->term; + return $this->getFilter('term')?->get() ?? ''; + } + + public function getFilter(string $name): ?IFilter { + return $this->filters->has($name) + ? $this->filters->get($name) + : null; + } + + public function getFilters(): IFilterCollection { + return $this->filters; } - /** - * @inheritDoc - */ public function getSortOrder(): int { return $this->sortOrder; } - /** - * @inheritDoc - */ public function getLimit(): int { return $this->limit; } - /** - * @inheritDoc - */ - public function getCursor() { + public function getCursor(): int|string|null { return $this->cursor; } - /** - * @inheritDoc - */ public function getRoute(): string { return $this->route; } - /** - * @inheritDoc - */ public function getRouteParameters(): array { return $this->routeParameters; } diff --git a/lib/private/Search/UnsupportedFilter.php b/lib/private/Search/UnsupportedFilter.php new file mode 100644 index 0000000000000..84b6163d2fa04 --- /dev/null +++ b/lib/private/Search/UnsupportedFilter.php @@ -0,0 +1,34 @@ + + * + * @author Benjamin Gaussorgues + * + * @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 Exception; + +final class UnsupportedFilter extends Exception { + public function __construct(string $filerName, $providerId) { + parent::__construct('Provider '.$providerId.' doesn’t support filter '.$filerName.'.'); + } +} diff --git a/lib/public/Search/FilterDefinition.php b/lib/public/Search/FilterDefinition.php new file mode 100644 index 0000000000000..7e1538acedb18 --- /dev/null +++ b/lib/public/Search/FilterDefinition.php @@ -0,0 +1,101 @@ + + * + * @author Benjamin Gaussorgues + * + * @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 InvalidArgumentException; + +/** + * Filter definition + * + * Describe filter attributes + * + * @since 28.0.0 + */ +class FilterDefinition { + public const TYPE_BOOL = 'bool'; + public const TYPE_INT = 'int'; + public const TYPE_FLOAT = 'float'; + public const TYPE_STRING = 'string'; + public const TYPE_STRINGS = 'strings'; + public const TYPE_DATETIME = 'datetime'; + public const TYPE_PERSON = 'person'; + public const TYPE_NC_USER = 'nc-user'; + public const TYPE_NC_GROUP = 'nc-group'; + + /** + * Build filter definition + * + * @param self::TYPE_* $type + * @param bool $exclusive If true, all providers not supporting this filter will be ignored when this filter is provided + * @throw InvalidArgumentException in case of invalid name. Allowed characters are -, 0-9, a-z. + * @since 28.0.0 + */ + public function __construct( + private string $name, + private string $type = self::TYPE_STRING, + private bool $exclusive = true, + ) { + if (!preg_match('/[-0-9a-z]+/Au', $name)) { + throw new InvalidArgumentException('Invalid filter name. Allowed characters are [-0-9a-z]'); + } + } + + /** + * Filter name + * + * Name is used in query string and for advanced syntax `name: ` + * + * @since 28.0.0 + */ + public function name(): string { + return $this->name; + } + + /** + * Filter type + * + * Expected type of value for the filter + * + * @return self::TYPE_* + * @since 28.0.0 + */ + public function type(): string { + return $this->type; + } + + /** + * Is filter exclusive? + * + * If exclusive, only provider with support for this filter will receive the query. + * Example: if an exclusive filter `mimetype` is declared, a search with this term will not + * be send to providers like `settings` that doesn't support it. + * + * @since 28.0.0 + */ + public function exclusive(): bool { + return $this->exclusive; + } +} diff --git a/lib/public/Search/IFilter.php b/lib/public/Search/IFilter.php new file mode 100644 index 0000000000000..c4e444e806b1f --- /dev/null +++ b/lib/public/Search/IFilter.php @@ -0,0 +1,40 @@ + + * + * @author Benjamin Gaussorgues + * + * @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; + +/** + * Interface for search filters + * + * @since 28.0.0 + */ +interface IFilter { + /** + * Get filter value + * + * @since 28.0.0 + */ + public function get(): mixed; +} diff --git a/lib/public/Search/IFilterCollection.php b/lib/public/Search/IFilterCollection.php new file mode 100644 index 0000000000000..6ca53a1c628e5 --- /dev/null +++ b/lib/public/Search/IFilterCollection.php @@ -0,0 +1,57 @@ + + * + * @author Benjamin Gaussorgues + * + * @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 IteratorAggregate; + +/** + * Interface for search filters + * + * @since 28.0.0 + * @extends IteratorAggregate + */ +interface IFilterCollection extends IteratorAggregate { + /** + * Check if a filter exits + * + * @since 28.0.0 + */ + public function has(string $name): bool; + + /** + * Get a filter by name + * + * @since 28.0.0 + */ + public function get(string $name): ?IFilter; + + /** + * Return Iterator of filters + * + * @since 28.0.0 + */ + public function getIterator(): \Traversable; +} diff --git a/lib/public/Search/IFilteringProvider.php b/lib/public/Search/IFilteringProvider.php new file mode 100644 index 0000000000000..dbe1044a539de --- /dev/null +++ b/lib/public/Search/IFilteringProvider.php @@ -0,0 +1,72 @@ + + * + * @author Benjamin Gaussorgues + * + * @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; + +/** + * Interface for advanced 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 28.0.0 + */ +interface IFilteringProvider extends IProvider { + /** + * Return the names of filters supported by the application + * + * If a filter sent by client is not in this list, + * the current provider will be ignored. + * Example: + * array('term', 'since', 'custom-filter'); + * + * @since 28.0.0 + * @return string[] Name of supported filters (default or defined by application) + */ + public function getSupportedFilters(): array; + + /** + * Get alternate IDs handled by this provider + * + * A search provider can complete results from other search providers. + * For example, files and full-text-search can search in files. + * If you use `in:files` in a search, provider files will be invoked, + * with all other providers declaring `files` in this method + * + * @since 28.0.0 + * @return string[] IDs + */ + public function getAlternateIds(): array; + + /** + * Allows application to declare custom filters + * + * @since 28.0.0 + * @return list + */ + public function getCustomFilters(): array; +} diff --git a/lib/public/Search/ISearchQuery.php b/lib/public/Search/ISearchQuery.php index a545d1dbccbac..56f1f1f0faaf4 100644 --- a/lib/public/Search/ISearchQuery.php +++ b/lib/public/Search/ISearchQuery.php @@ -48,9 +48,24 @@ interface ISearchQuery { * * @return string the search term * @since 20.0.0 + * @deprecated 28.0.0 */ public function getTerm(): string; + /** + * Get a single request filter + * + * @since 28.0.0 + */ + public function getFilter(string $name): ?IFilter; + + /** + * Get request filters + * + * @since 28.0.0 + */ + public function getFilters(): IFilterCollection; + /** * Get the sort order of results as defined as SORT_* constants on this interface *