diff --git a/appinfo/routes.php b/appinfo/routes.php index 5f8a0cdc4..9ffa71655 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -141,5 +141,7 @@ ['name' => 'comments_api#delete', 'url' => '/api/v{apiVersion}/cards/{cardId}/comments/{commentId}', 'verb' => 'DELETE'], ['name' => 'overview_api#upcomingCards', 'url' => '/api/v{apiVersion}/overview/upcoming', 'verb' => 'GET'], + + ['name' => 'search#search', 'url' => '/api/v{apiVersion}/search', 'verb' => 'GET'], ] ]; diff --git a/composer.json b/composer.json index d2e6dde53..2db95e7d1 100644 --- a/composer.json +++ b/composer.json @@ -1,34 +1,40 @@ { - "name": "nextcloud/deck", - "type": "project", - "license": "AGPLv3", - "authors": [ - { - "name": "Julius Härtl", - "email": "jus@bitgrid.net" - } - ], - "require": { - "cogpowered/finediff": "0.3.*" - }, - "require-dev": { - "roave/security-advisories": "dev-master", - "christophwurst/nextcloud": "^21@dev", - "phpunit/phpunit": "^8", - "nextcloud/coding-standard": "^0.5.0", - "symfony/event-dispatcher": "^4.0", - "vimeo/psalm": "^4.3", - "php-parallel-lint/php-parallel-lint": "^1.2" - }, - "config": { - "optimize-autoloader": true, - "classmap-authoritative": true - }, - "scripts": { - "lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l", - "cs:check": "php-cs-fixer fix --dry-run --diff", - "cs:fix": "php-cs-fixer fix", + "name": "nextcloud/deck", + "type": "project", + "license": "AGPLv3", + "authors": [ + { + "name": "Julius Härtl", + "email": "jus@bitgrid.net" + } + ], + "require": { + "cogpowered/finediff": "0.3.*" + }, + "require-dev": { + "roave/security-advisories": "dev-master", + "christophwurst/nextcloud": "^21@dev", + "phpunit/phpunit": "^8", + "nextcloud/coding-standard": "^0.5.0", + "symfony/event-dispatcher": "^4.0", + "vimeo/psalm": "^4.3", + "php-parallel-lint/php-parallel-lint": "^1.2" + }, + "config": { + "optimize-autoloader": true, + "classmap-authoritative": true + }, + "scripts": { + "lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l", + "cs:check": "php-cs-fixer fix --dry-run --diff", + "cs:fix": "php-cs-fixer fix", "psalm": "psalm", - "psalm:fix": "psalm --alter --issues=InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType,MismatchingDocblockReturnType,MissingParamType,InvalidFalsableReturnType" - } + "psalm:fix": "psalm --alter --issues=InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType,MismatchingDocblockReturnType,MissingParamType,InvalidFalsableReturnType", + "test": [ + "@test:unit", + "@test:integration" + ], + "test:unit": "phpunit -c tests/phpunit.xml", + "test:integration": "phpunit -c tests/phpunit.integration.xml && cd tests/integration && ./run.sh" + } } diff --git a/docs/User_documentation_en.md b/docs/User_documentation_en.md index 84a439508..25ea8f8ee 100644 --- a/docs/User_documentation_en.md +++ b/docs/User_documentation_en.md @@ -69,3 +69,25 @@ The **sharing tab** allows you to add users or even groups to your boards. **Deleted objects** allows you to return previously deleted stacks or cards. The **Timeline** allows you to see everything that happened in your boards. Everything! +## Search + +Deck provides a global search either through the unified search in the Nextcloud header or with the inline search next to the board controls. +This search allows advanced filtering of cards across all board of the logged in user. + +For example the search `project tag:ToDo assigned:alice assigned:bob` will return all cards where the card title or description contains project **and** the tag ToDo is set **and** the user alice is assigned **and** the user bob is assigned. + +### Supported search filters + +| Filter | Operators | Query | +| ----------- | ----------------- | ------------------------------------------------------------ | +| title | `:` | text token used for a case-insentitive search on the cards title | +| description | `:` | text token used for a case-insentitive search on the cards description | +| list | `:` | text token used for a case-insentitive search on the cards list name | +| tag | `:` | text token used for a case-insentitive search on the assigned tags | +| date | `:` | 'overdue', 'today', 'week', 'month', 'none' | +| | `>` `<` `>=` `<=` | Compare the card due date to the passed date (see [supported date formats](https://www.php.net/manual/de/datetime.formats.php)) Card due dates are always considered UTC for comparison | +| assigned | `:` | id or displayname of a user or group for a search on the assigned users or groups | + +Other text tokens will be used to perform a case-insensitive search on the card title and description + +In addition wuotes can be used to pass a query with spaces, e.g. `"Exact match with spaces"` or `title:"My card"`. diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1cef397ff..b7dbd9b5e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -47,6 +47,7 @@ use OCA\Deck\Middleware\DefaultBoardMiddleware; use OCA\Deck\Middleware\ExceptionMiddleware; use OCA\Deck\Notification\Notifier; +use OCA\Deck\Search\CardCommentProvider; use OCA\Deck\Search\DeckProvider; use OCA\Deck\Service\PermissionService; use OCA\Deck\Sharing\DeckShareProvider; @@ -95,9 +96,7 @@ public function boot(IBootContext $context): void { $context->injectFn(Closure::fromCallable([$this, 'registerCollaborationResources'])); $context->injectFn(function (IManager $shareManager) { - if (method_exists($shareManager, 'registerShareProvider')) { - $shareManager->registerShareProvider(DeckShareProvider::class); - } + $shareManager->registerShareProvider(DeckShareProvider::class); }); $context->injectFn(function (Listener $listener, IEventDispatcher $eventDispatcher) { @@ -122,6 +121,7 @@ public function register(IRegistrationContext $context): void { }); $context->registerSearchProvider(DeckProvider::class); + $context->registerSearchProvider(CardCommentProvider::class); $context->registerDashboardWidget(DeckWidget::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); diff --git a/lib/Controller/SearchController.php b/lib/Controller/SearchController.php new file mode 100644 index 000000000..154158454 --- /dev/null +++ b/lib/Controller/SearchController.php @@ -0,0 +1,59 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Controller; + +use OCA\Deck\Db\Card; +use OCA\Deck\Service\SearchService; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; + +class SearchController extends OCSController { + + /** + * @var SearchService + */ + private $searchService; + + public function __construct(string $appName, IRequest $request, SearchService $searchService) { + parent::__construct($appName, $request); + $this->searchService = $searchService; + } + + /** + * @NoAdminRequired + */ + public function search(string $term, ?int $limit = null, ?int $cursor = null): DataResponse { + $cards = $this->searchService->searchCards($term, $limit, $cursor); + return new DataResponse(array_map(function (Card $card) { + $json = $card->jsonSerialize(); + $json['relatedStack'] = $card->getRelatedStack(); + $json['relatedBoard'] = $card->getRelatedBoard(); + return $json; + }, $cards)); + } +} diff --git a/lib/Db/Card.php b/lib/Db/Card.php index 9a6954239..4168c7fe2 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -49,6 +49,9 @@ class Card extends RelationalEntity { protected $notified = false; protected $deletedAt = 0; protected $commentsUnread = 0; + + protected $relatedStack = null; + protected $relatedBoard = null; private $databaseType = 'sqlite'; @@ -73,6 +76,9 @@ public function __construct() { $this->addRelation('participants'); $this->addRelation('commentsUnread'); $this->addResolvable('owner'); + + $this->addRelation('relatedStack'); + $this->addRelation('relatedBoard'); } public function setDatabaseType($type) { @@ -119,6 +125,8 @@ public function jsonSerialize() { $json['duedate'] = $this->getDuedate(true); unset($json['notified']); unset($json['descriptionPrev']); + unset($json['relatedStack']); + unset($json['relatedBoard']); return $json; } diff --git a/lib/Db/CardMapper.php b/lib/Db/CardMapper.php index 563106472..d7ea8c2c6 100644 --- a/lib/Db/CardMapper.php +++ b/lib/Db/CardMapper.php @@ -23,11 +23,16 @@ namespace OCA\Deck\Db; +use DateTime; use Exception; +use OCA\Deck\AppInfo\Application; +use OCA\Deck\Search\Query\SearchQuery; use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IUser; use OCP\IUserManager; use OCP\Notification\IManager; @@ -37,6 +42,8 @@ class CardMapper extends QBMapper implements IPermissionMapper { private $labelMapper; /** @var IUserManager */ private $userManager; + /** @var IGroupManager */ + private $groupManager; /** @var IManager */ private $notificationManager; private $databaseType; @@ -46,13 +53,15 @@ public function __construct( IDBConnection $db, LabelMapper $labelMapper, IUserManager $userManager, + IGroupManager $groupManager, IManager $notificationManager, - $databaseType = 'sqlite', + $databaseType = 'sqlite3', $database4ByteSupport = true ) { parent::__construct($db, 'deck_cards', Card::class); $this->labelMapper = $labelMapper; $this->userManager = $userManager; + $this->groupManager = $groupManager; $this->notificationManager = $notificationManager; $this->databaseType = $databaseType; $this->database4ByteSupport = $database4ByteSupport; @@ -117,7 +126,7 @@ public function find($id): Card { ->addOrderBy('id'); /** @var Card $card */ $card = $this->findEntity($qb); - $labels = $this->labelMapper->findAssignedLabelsForCard($card->id); + $labels = $this->labelMapper->findAssignedLabelsForCard($card->getId()); $card->setLabels($labels); $this->mapOwner($card); return $card; @@ -260,24 +269,208 @@ public function findUnexposedDescriptionChances() { return $this->findEntities($qb); } - public function search($boardIds, $term, $limit = null, $offset = null) { + public function search(array $boardIds, SearchQuery $query, int $limit = null, int $offset = null): array { $qb = $this->queryCardsByBoards($boardIds); - $qb->andWhere($qb->expr()->eq('c.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); - $qb->andWhere($qb->expr()->eq('s.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + $this->extendQueryByFilter($qb, $query); + + if (count($query->getTextTokens()) > 0) { + $tokenMatching = $qb->expr()->andX( + ...array_map(function (string $token) use ($qb) { + return $qb->expr()->orX( + $qb->expr()->iLike( + 'c.title', + $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($token) . '%', IQueryBuilder::PARAM_STR), + IQueryBuilder::PARAM_STR + ), + $qb->expr()->iLike( + 'c.description', + $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($token) . '%', IQueryBuilder::PARAM_STR), + IQueryBuilder::PARAM_STR + ) + ); + }, $query->getTextTokens()) + ); + $qb->andWhere( + $tokenMatching + ); + } + + $qb->groupBy('c.id'); + $qb->orderBy('c.last_modified', 'DESC'); + if ($limit !== null) { + $qb->setMaxResults($limit); + } + if ($offset !== null) { + $qb->andWhere($qb->expr()->lt('c.last_modified', $qb->createNamedParameter($offset, IQueryBuilder::PARAM_INT))); + } + + $result = $qb->execute(); + $entities = []; + while ($row = $result->fetch()) { + $entities[] = Card::fromRow($row); + } + $result->closeCursor(); + return $entities; + } + + public function searchComments(array $boardIds, SearchQuery $query, int $limit = null, int $offset = null): array { + if (count($query->getTextTokens()) === 0) { + return []; + } + $qb = $this->queryCardsByBoards($boardIds); + $this->extendQueryByFilter($qb, $query); + + $qb->innerJoin('c', 'comments', 'comments', $qb->expr()->andX( + $qb->expr()->eq('comments.object_id', 'c.id', IQueryBuilder::PARAM_STR), + $qb->expr()->eq('comments.object_type', $qb->createNamedParameter(Application::COMMENT_ENTITY_TYPE, IQueryBuilder::PARAM_STR)) + )); + $qb->selectAlias('comments.id', 'comment_id'); + + $tokenMatching = $qb->expr()->andX( + ...array_map(function (string $token) use ($qb) { + return $qb->expr()->iLike( + 'comments.message', + $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($token) . '%', IQueryBuilder::PARAM_STR), + IQueryBuilder::PARAM_STR + ); + }, $query->getTextTokens()) + ); $qb->andWhere( - $qb->expr()->orX( - $qb->expr()->iLike('c.title', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($term) . '%')), - $qb->expr()->iLike('c.description', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($term) . '%')) - ) + $tokenMatching ); + + $qb->groupBy('comments.id'); + $qb->orderBy('comments.id', 'DESC'); if ($limit !== null) { $qb->setMaxResults($limit); } if ($offset !== null) { - $qb->setFirstResult($offset); + $qb->andWhere($qb->expr()->lt('comments.id', $qb->createNamedParameter($offset, IQueryBuilder::PARAM_INT))); } - return $this->findEntities($qb); + + $result = $qb->execute(); + $entities = $result->fetchAll(); + $result->closeCursor(); + return $entities; + } + + private function extendQueryByFilter(IQueryBuilder $qb, SearchQuery $query) { + $qb->andWhere($qb->expr()->eq('c.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + $qb->andWhere($qb->expr()->eq('s.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + $qb->innerJoin('s', 'deck_boards', 'b', $qb->expr()->eq('b.id', 's.board_id')); + $qb->andWhere($qb->expr()->eq('b.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + + foreach ($query->getTitle() as $title) { + $qb->andWhere($qb->expr()->iLike('c.title', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($title->getValue()) . '%', IQueryBuilder::PARAM_STR))); + } + + foreach ($query->getDescription() as $description) { + $qb->andWhere($qb->expr()->iLike('c.description', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($description->getValue()) . '%', IQueryBuilder::PARAM_STR))); + } + + foreach ($query->getStack() as $stack) { + $qb->andWhere($qb->expr()->iLike('s.title', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($stack->getValue()) . '%', IQueryBuilder::PARAM_STR))); + } + + if (count($query->getTag())) { + foreach ($query->getTag() as $index => $tag) { + $qb->innerJoin('c', 'deck_assigned_labels', 'al' . $index, $qb->expr()->eq('c.id', 'al' . $index . '.card_id')); + $qb->innerJoin('al'. $index, 'deck_labels', 'l' . $index, $qb->expr()->eq('al' . $index . '.label_id', 'l' . $index . '.id')); + $qb->andWhere($qb->expr()->iLike('l' . $index . '.title', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($tag->getValue()) . '%', IQueryBuilder::PARAM_STR))); + } + } + + foreach ($query->getDuedate() as $duedate) { + $dueDateColumn = $this->databaseType === 'sqlite3' ? $qb->createFunction('DATETIME(`c`.`duedate`)') : 'c.duedate'; + $date = $duedate->getValue(); + $supportedFilters = ['overdue', 'today', 'week', 'month', 'none']; + if (in_array($date, $supportedFilters, true)) { + $currentDate = new DateTime(); + $rangeDate = new DateTime(); + if ($date === 'overdue') { + $qb->andWhere($qb->expr()->lt($dueDateColumn, $this->dateTimeParameter($qb, $currentDate))); + } elseif ($date === 'today') { + $rangeDate = $rangeDate->add(new \DateInterval('P1D')); + $qb->andWhere($qb->expr()->gte($dueDateColumn, $this->dateTimeParameter($qb, $currentDate))); + $qb->andWhere($qb->expr()->lte($dueDateColumn, $this->dateTimeParameter($qb, $rangeDate))); + } elseif ($date === 'week') { + $rangeDate = $rangeDate->add(new \DateInterval('P7D')); + $qb->andWhere($qb->expr()->gte($dueDateColumn, $this->dateTimeParameter($qb, $currentDate))); + $qb->andWhere($qb->expr()->lte($dueDateColumn, $this->dateTimeParameter($qb, $rangeDate))); + } elseif ($date === 'month') { + $rangeDate = $rangeDate->add(new \DateInterval('P1M')); + $qb->andWhere($qb->expr()->gte($dueDateColumn, $this->dateTimeParameter($qb, $currentDate))); + $qb->andWhere($qb->expr()->lte($dueDateColumn, $this->dateTimeParameter($qb, $rangeDate))); + } else { + $qb->andWhere($qb->expr()->isNull('c.duedate')); + } + } else { + try { + $date = new DateTime($date); + if ($duedate->getComparator() === SearchQuery::COMPARATOR_LESS) { + $qb->andWhere($qb->expr()->lt($dueDateColumn, $this->dateTimeParameter($qb, $date))); + } elseif ($duedate->getComparator() === SearchQuery::COMPARATOR_LESS_EQUAL) { + // take the end of the day to include due dates at the same day (as datetime does't allow just setting the day) + $date->setTime(23, 59, 59); + $qb->andWhere($qb->expr()->lte($dueDateColumn, $this->dateTimeParameter($qb, $date))); + } elseif ($duedate->getComparator() === SearchQuery::COMPARATOR_MORE) { + // take the end of the day to exclude due dates at the same day (as datetime does't allow just setting the day) + $date->setTime(23, 59, 59); + $qb->andWhere($qb->expr()->gt($dueDateColumn, $this->dateTimeParameter($qb, $date))); + } elseif ($duedate->getComparator() === SearchQuery::COMPARATOR_MORE_EQUAL) { + $qb->andWhere($qb->expr()->gte($dueDateColumn, $this->dateTimeParameter($qb, $date))); + } + } catch (Exception $e) { + // Invalid date, ignoring + } + } + } + + if (count($query->getAssigned()) > 0) { + foreach ($query->getAssigned() as $index => $assignment) { + $qb->innerJoin('c', 'deck_assigned_users', 'au' . $index, $qb->expr()->eq('c.id', 'au' . $index . '.card_id')); + $assignedQueryValue = $assignment->getValue(); + $searchUsers = $this->userManager->searchDisplayName($assignment->getValue()); + $users = array_filter($searchUsers, function (IUser $user) use ($assignedQueryValue) { + return (mb_strtolower($user->getDisplayName()) === mb_strtolower($assignedQueryValue) || $user->getUID() === $assignedQueryValue); + }); + $groups = $this->groupManager->search($assignment->getValue()); + foreach ($searchUsers as $user) { + $groups = array_merge($groups, $this->groupManager->getUserIdGroups($user->getUID())); + } + + $assignmentSearches = []; + $hasAssignedMatches = false; + foreach ($users as $user) { + $hasAssignedMatches = true; + $assignmentSearches[] = $qb->expr()->andX( + $qb->expr()->eq('au' . $index . '.participant', $qb->createNamedParameter($user->getUID(), IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('au' . $index . '.type', $qb->createNamedParameter(Assignment::TYPE_USER, IQueryBuilder::PARAM_INT)) + ); + } + foreach ($groups as $group) { + $hasAssignedMatches = true; + $assignmentSearches[] = $qb->expr()->andX( + $qb->expr()->eq('au' . $index . '.participant', $qb->createNamedParameter($group->getGID(), IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('au' . $index . '.type', $qb->createNamedParameter(Assignment::TYPE_GROUP, IQueryBuilder::PARAM_INT)) + ); + } + if (!$hasAssignedMatches) { + return []; + } + $qb->andWhere($qb->expr()->orX(...$assignmentSearches)); + } + } + } + + private function dateTimeParameter(IQueryBuilder $qb, DateTime $dateTime) { + if ($this->databaseType === 'sqlite3') { + return $qb->createFunction('DATETIME("' . $dateTime->format('Y-m-d\TH:i:s') . '")'); + } + return $qb->createNamedParameter($dateTime, IQueryBuilder::PARAM_DATE); } + + public function searchRaw($boardIds, $term, $limit = null, $offset = null) { $qb = $this->queryCardsByBoards($boardIds) diff --git a/lib/Listeners/FullTextSearchEventListener.php b/lib/Listeners/FullTextSearchEventListener.php index 4759e4019..954ae72db 100644 --- a/lib/Listeners/FullTextSearchEventListener.php +++ b/lib/Listeners/FullTextSearchEventListener.php @@ -36,6 +36,7 @@ use OCA\Deck\Service\FullTextSearchService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\FullTextSearch\Exceptions\FullTextSearchAppNotAvailableException; use OCP\FullTextSearch\IFullTextSearchManager; use OCP\FullTextSearch\Model\IIndex; use Psr\Container\ContainerInterface; @@ -97,6 +98,8 @@ static function (Card $card) { DeckProvider::DECK_PROVIDER_ID, $cards, IIndex::INDEX_META ); } + } catch (FullTextSearchAppNotAvailableException $e) { + // Skip silently if no full text search app is available } catch (\Exception $e) { $this->logger->error('Error when handling deck full text search event', ['exception' => $e]); } diff --git a/lib/Search/CardCommentProvider.php b/lib/Search/CardCommentProvider.php new file mode 100644 index 000000000..802cc22c1 --- /dev/null +++ b/lib/Search/CardCommentProvider.php @@ -0,0 +1,84 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Search; + +use OCA\Deck\Service\SearchService; +use OCP\IL10N; +use OCP\IUser; +use OCP\Search\IProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; + +class CardCommentProvider implements IProvider { + + /** @var SearchService */ + private $searchService; + /** @var IL10N */ + private $l10n; + + public function __construct( + SearchService $searchService, + IL10N $l10n + ) { + $this->searchService = $searchService; + $this->l10n = $l10n; + } + + public function getId(): string { + return 'deck-comment'; + } + + public function getName(): string { + return $this->l10n->t('Card comments'); + } + + public function search(IUser $user, ISearchQuery $query): SearchResult { + $cursor = $query->getCursor() !== null ? (int)$query->getCursor() : null; + $results = $this->searchService->searchComments($query->getTerm(), $query->getLimit(), $cursor); + if (count($results) < $query->getLimit()) { + return SearchResult::complete( + $this->l10n->t('Card comments'), + $results + ); + } + + return SearchResult::paginated( + $this->l10n->t('Card comments'), + $results, + $results[count($results) - 1]->getCommentId() + ); + } + + public function getOrder(string $route, array $routeParameters): int { + // Negative value to force showing deck providers on first position if the app is opened + // This provider always has an order 1 higher than the default DeckProvider + if ($route === 'deck.Page.index') { + return -4; + } + return 11; + } +} diff --git a/lib/Search/CommentSearchResultEntry.php b/lib/Search/CommentSearchResultEntry.php new file mode 100644 index 000000000..d8a541413 --- /dev/null +++ b/lib/Search/CommentSearchResultEntry.php @@ -0,0 +1,51 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Search; + +use OCA\Deck\Db\Card; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Search\SearchResultEntry; + +class CommentSearchResultEntry extends SearchResultEntry { + private $commentId; + + public function __construct(string $commentId, string $commentMessage, string $commentAuthor, Card $card, IURLGenerator $urlGenerator, IL10N $l10n) { + parent::__construct( + '', + // TRANSLATORS This is describing the author and card title related to a comment e.g. "Jane on MyTask" + $l10n->t('%s on %s', [$commentAuthor, $card->getTitle()]), + $commentMessage, + $urlGenerator->linkToRouteAbsolute('deck.page.index') . '#/board/' . $card->getRelatedBoard()->getId() . '/card/' . $card->getId() . '/comments/' . $commentId, // $commentId + 'icon-comment'); + $this->commentId = $commentId; + } + + public function getCommentId(): string { + return $this->commentId; + } +} diff --git a/lib/Search/DeckProvider.php b/lib/Search/DeckProvider.php index fa961557c..5015ab070 100644 --- a/lib/Search/DeckProvider.php +++ b/lib/Search/DeckProvider.php @@ -28,9 +28,7 @@ use OCA\Deck\Db\Board; use OCA\Deck\Db\Card; -use OCA\Deck\Db\CardMapper; -use OCA\Deck\Db\StackMapper; -use OCA\Deck\Service\BoardService; +use OCA\Deck\Service\SearchService; use OCP\IURLGenerator; use OCP\IUser; use OCP\Search\IProvider; @@ -40,31 +38,19 @@ class DeckProvider implements IProvider { /** - * @var BoardService + * @var SearchService */ - private $boardService; - /** - * @var CardMapper - */ - private $cardMapper; - /** - * @var StackMapper - */ - private $stackMapper; + private $searchService; /** * @var IURLGenerator */ private $urlGenerator; public function __construct( - BoardService $boardService, - StackMapper $stackMapper, - CardMapper $cardMapper, + SearchService $searchService, IURLGenerator $urlGenerator ) { - $this->boardService = $boardService; - $this->stackMapper = $stackMapper; - $this->cardMapper = $cardMapper; + $this->searchService = $searchService; $this->urlGenerator = $urlGenerator; } @@ -77,37 +63,34 @@ public function getName(): string { } public function search(IUser $user, ISearchQuery $query): SearchResult { - $boards = $this->boardService->getUserBoards(); - - $matchedBoards = array_filter($this->boardService->getUserBoards(), static function (Board $board) use ($query) { - return mb_stripos($board->getTitle(), $query->getTerm()) > -1; - }); - - $matchedCards = $this->cardMapper->search(array_map(static function (Board $board) { - return $board->getId(); - }, $boards), $query->getTerm(), $query->getLimit(), $query->getCursor()); - - $self = $this; + $cursor = $query->getCursor() !== null ? (int)$query->getCursor() : null; + $boardResults = $this->searchService->searchBoards($query->getTerm(), $query->getLimit(), $cursor); + $cardResults = $this->searchService->searchCards($query->getTerm(), $query->getLimit(), $cursor); $results = array_merge( array_map(function (Board $board) { return new BoardSearchResultEntry($board, $this->urlGenerator); - }, $matchedBoards), - - array_map(function (Card $card) use ($self) { - $board = $self->boardService->find($self->cardMapper->findBoardId($card->getId())); - $stack = $self->stackMapper->find($card->getStackId()); - return new CardSearchResultEntry($board, $stack, $card, $this->urlGenerator); - }, $matchedCards) + }, $boardResults), + array_map(function (Card $card) { + return new CardSearchResultEntry($card->getRelatedBoard(), $card->getRelatedStack(), $card, $this->urlGenerator); + }, $cardResults) ); - return SearchResult::complete( + if (count($cardResults) < $query->getLimit()) { + return SearchResult::complete( + 'Deck', + $results + ); + } + + return SearchResult::paginated( 'Deck', - $results + $results, + $cardResults[count($results) - 1]->getLastModified() ); } public function getOrder(string $route, array $routeParameters): int { - if ($route === 'deck.page.index') { + if ($route === 'deck.Page.index') { return -5; } return 10; diff --git a/lib/Search/FilterStringParser.php b/lib/Search/FilterStringParser.php new file mode 100644 index 000000000..08f769082 --- /dev/null +++ b/lib/Search/FilterStringParser.php @@ -0,0 +1,125 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Search; + +use OCA\Deck\Search\Query\DateQueryParameter; +use OCA\Deck\Search\Query\SearchQuery; +use OCA\Deck\Search\Query\StringQueryParameter; +use OCP\IL10N; + +class FilterStringParser { + + /** + * @var IL10N + */ + private $l10n; + + public function __construct(IL10N $l10n) { + $this->l10n = $l10n; + } + + public function parse(?string $filter): SearchQuery { + $query = new SearchQuery(); + if (empty($filter)) { + return $query; + } + /** + * Match search tokens that are separated by spaces + * do not match spaces that are surrounded by single or double quotes + * in order to still match quotes + * e.g.: + * - test + * - test:query + * - test:<123 + * - test:"1 2 3" + * - test:>="2020-01-01" + */ + $searchQueryExpression = '/((\w+:(<|<=|>|>=)?)?("([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\')|[^\s]+)/'; + preg_match_all($searchQueryExpression, $filter, $matches, PREG_SET_ORDER, 0); + foreach ($matches as $match) { + $token = $match[0]; + if (!$this->parseFilterToken($query, $token)) { + $query->addTextToken($this->removeQuotes($token)); + } + } + + return $query; + } + + private function parseFilterToken(SearchQuery $query, string $token): bool { + if (strpos($token, ':') === false) { + return false; + } + + [$type, $param] = explode(':', $token, 2); + $type = strtolower($type); + + $qualifier = null; + + switch ($type) { + case 'date': + $comparator = SearchQuery::COMPARATOR_EQUAL; + $value = $param; + if ($param[0] === '<' || $param[0] === '>') { + $orEquals = $param[1] === '='; + $value = $orEquals ? substr($param, 2) : substr($param, 1); + $comparator = ( + ($param[0] === '<' ? SearchQuery::COMPARATOR_LESS : 0) | + ($param[0] === '>' ? SearchQuery::COMPARATOR_MORE : 0) | + ($orEquals ? SearchQuery::COMPARATOR_EQUAL : 0) + ); + } + $query->addDuedate(new DateQueryParameter('date', $comparator, $this->removeQuotes($value))); + return true; + case 'title': + $query->addTitle(new StringQueryParameter('title', SearchQuery::COMPARATOR_EQUAL, $this->removeQuotes($param))); + return true; + case 'description': + $query->addDescription(new StringQueryParameter('description', SearchQuery::COMPARATOR_EQUAL, $this->removeQuotes($param))); + return true; + case 'list': + $query->addStack(new StringQueryParameter('list', SearchQuery::COMPARATOR_EQUAL, $this->removeQuotes($param))); + return true; + case 'tag': + $query->addTag(new StringQueryParameter('tag', SearchQuery::COMPARATOR_EQUAL, $this->removeQuotes($param))); + return true; + case 'assigned': + $query->addAssigned(new StringQueryParameter('assigned', SearchQuery::COMPARATOR_EQUAL, $this->removeQuotes($param))); + return true; + } + + return false; + } + + protected function removeQuotes(string $token): string { + if (mb_strlen($token) > 1) { + $token = ($token[0] === '"' && $token[mb_strlen($token) - 1] === '"') ? mb_substr($token, 1, -1) : $token; + $token = ($token[0] === '\'' && $token[mb_strlen($token) - 1] === '\'') ? mb_substr($token, 1, -1) : $token; + } + return $token; + } +} diff --git a/lib/Search/Query/AQueryParameter.php b/lib/Search/Query/AQueryParameter.php new file mode 100644 index 000000000..00f3838a2 --- /dev/null +++ b/lib/Search/Query/AQueryParameter.php @@ -0,0 +1,49 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Search\Query; + +class AQueryParameter { + + /** @var string */ + protected $field; + /** @var int */ + protected $comparator; + /** @var mixed */ + protected $value; + + public function getValue() { + if (is_string($this->value) && mb_strlen($this->value) > 1) { + $param = ($this->value[0] === '"' && $this->value[mb_strlen($this->value) - 1] === '"') ? mb_substr($this->value, 1, -1): $this->value; + return $param; + } + return $this->value; + } + + public function getComparator(): int { + return $this->comparator; + } +} diff --git a/lib/Search/Query/DateQueryParameter.php b/lib/Search/Query/DateQueryParameter.php new file mode 100644 index 000000000..1e2ae36d4 --- /dev/null +++ b/lib/Search/Query/DateQueryParameter.php @@ -0,0 +1,38 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Search\Query; + +class DateQueryParameter extends AQueryParameter { + /** @var string|null */ + protected $value; + + public function __construct(string $field, int $comparator, ?string $value) { + $this->field = $field; + $this->comparator = $comparator; + $this->value = $value; + } +} diff --git a/lib/Search/Query/SearchQuery.php b/lib/Search/Query/SearchQuery.php new file mode 100644 index 000000000..3d9431008 --- /dev/null +++ b/lib/Search/Query/SearchQuery.php @@ -0,0 +1,109 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Search\Query; + +class SearchQuery { + public const COMPARATOR_EQUAL = 1; + + public const COMPARATOR_LESS = 2; + public const COMPARATOR_MORE = 4; + + public const COMPARATOR_LESS_EQUAL = 3; + public const COMPARATOR_MORE_EQUAL = 5; + + /** @var string[] */ + private $textTokens = []; + /** @var StringQueryParameter[] */ + private $title = []; + /** @var StringQueryParameter[] */ + private $description = []; + /** @var StringQueryParameter[] */ + private $stack = []; + /** @var StringQueryParameter[] */ + private $tag = []; + /** @var StringQueryParameter[] */ + private $assigned = []; + /** @var DateQueryParameter[] */ + private $duedate = []; + + + public function addTextToken(string $textToken): void { + $this->textTokens[] = $textToken; + } + + public function getTextTokens(): array { + return $this->textTokens; + } + + public function addTitle(StringQueryParameter $title): void { + $this->title[] = $title; + } + + public function getTitle(): array { + return $this->title; + } + + public function addDescription(StringQueryParameter $description): void { + $this->description[] = $description; + } + + public function getDescription(): array { + return $this->description; + } + + public function addStack(StringQueryParameter $stack): void { + $this->stack[] = $stack; + } + + public function getStack(): array { + return $this->stack; + } + + public function addTag(StringQueryParameter $tag): void { + $this->tag[] = $tag; + } + + public function getTag(): array { + return $this->tag; + } + + public function addAssigned(StringQueryParameter $assigned): void { + $this->assigned[] = $assigned; + } + + public function getAssigned(): array { + return $this->assigned; + } + + public function addDuedate(DateQueryParameter $date): void { + $this->duedate[] = $date; + } + + public function getDuedate(): array { + return $this->duedate; + } +} diff --git a/lib/Search/Query/StringQueryParameter.php b/lib/Search/Query/StringQueryParameter.php new file mode 100644 index 000000000..425c9e1c2 --- /dev/null +++ b/lib/Search/Query/StringQueryParameter.php @@ -0,0 +1,39 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Search\Query; + +class StringQueryParameter extends AQueryParameter { + + /** @var string */ + protected $value; + + public function __construct(string $field, int $comparator, string $value) { + $this->field = $field; + $this->comparator = $comparator; + $this->value = $value; + } +} diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index cc05ba474..20bb259d9 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -511,7 +511,6 @@ public function addAcl($boardId, $type, $participant, $edit, $share, $manage) { $acl->setPermissionShare($share); $acl->setPermissionManage($manage); - /* Notify users about the shared board */ $this->notificationHelper->sendBoardShared($boardId, $acl); $newAcl = $this->aclMapper->insert($acl); @@ -599,6 +598,9 @@ public function deleteAcl($id) { $this->assignedUsersMapper->delete($assignement); } } + + $this->notificationHelper->sendBoardShared($acl->getBoardId(), $acl); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $acl, ActivityManager::SUBJECT_BOARD_UNSHARE); $this->changeHelper->boardChanged($acl->getBoardId()); diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index c67172d50..abc6ba554 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -29,7 +29,6 @@ use OCA\Deck\Activity\ActivityManager; use OCA\Deck\Activity\ChangeSet; use OCA\Deck\Db\AssignmentMapper; -use OCA\Deck\Db\Board; use OCA\Deck\Db\Card; use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\Acl; @@ -108,6 +107,11 @@ public function enrich($card) { $lastRead = $this->commentsManager->getReadMark('deckCard', (string)$card->getId(), $user); $count = $this->commentsManager->getNumberOfCommentsForObject('deckCard', (string)$card->getId(), $lastRead); $card->setCommentsUnread($count); + + $stack = $this->stackMapper->find($card->getStackId()); + $board = $this->boardService->find($stack->getBoardId()); + $card->setRelatedStack($stack); + $card->setRelatedBoard($board); } public function fetchDeleted($boardId) { @@ -119,22 +123,6 @@ public function fetchDeleted($boardId) { return $cards; } - public function search(string $term, int $limit = null, int $offset = null): array { - $boards = $this->boardService->getUserBoards(); - $boardIds = array_map(static function (Board $board) { - return $board->getId(); - }, $boards); - return $this->cardMapper->search($boardIds, $term, $limit, $offset); - } - - public function searchRaw(string $term, int $limit = null, int $offset = null): array { - $boards = $this->boardService->getUserBoards(); - $boardIds = array_map(static function (Board $board) { - return $board->getId(); - }, $boards); - return $this->cardMapper->searchRaw($boardIds, $term, $limit, $offset); - } - /** * @param $cardId * @return \OCA\Deck\Db\RelationalEntity diff --git a/lib/Service/FilesAppService.php b/lib/Service/FilesAppService.php index aab5fb5cc..53acbaeac 100644 --- a/lib/Service/FilesAppService.php +++ b/lib/Service/FilesAppService.php @@ -35,7 +35,7 @@ use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; -use OCP\Share; +use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; use OCP\Share\IShare; @@ -140,9 +140,10 @@ public function extendData(Attachment $attachment) { } public function display(Attachment $attachment) { + /** @psalm-suppress InvalidCatch */ try { $share = $this->shareProvider->getShareById($attachment->getId()); - } catch (Share\Exceptions\ShareNotFound $e) { + } catch (ShareNotFound $e) { throw new NotFoundException('File not found'); } $file = $share->getNode(); diff --git a/lib/Service/SearchService.php b/lib/Service/SearchService.php new file mode 100644 index 000000000..d3398f204 --- /dev/null +++ b/lib/Service/SearchService.php @@ -0,0 +1,117 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Service; + +use OCA\Deck\Db\Board; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\CardMapper; +use OCA\Deck\Search\CommentSearchResultEntry; +use OCA\Deck\Search\FilterStringParser; +use OCP\Comments\ICommentsManager; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUserManager; + +class SearchService { + + /** @var BoardService */ + private $boardService; + /** @var CardMapper */ + private $cardMapper; + /** @var CardService */ + private $cardService; + /** @var ICommentsManager */ + private $commentsManager; + /** @var FilterStringParser */ + private $filterStringParser; + /** @var IUserManager */ + private $userManager; + /** @var IL10N */ + private $l10n; + /** @var IURLGenerator */ + private $urlGenerator; + + public function __construct( + BoardService $boardService, + CardMapper $cardMapper, + CardService $cardService, + ICommentsManager $commentsManager, + FilterStringParser $filterStringParser, + IUserManager $userManager, + IL10N $l10n, + IURLGenerator $urlGenerator + ) { + $this->boardService = $boardService; + $this->cardMapper = $cardMapper; + $this->cardService = $cardService; + $this->commentsManager = $commentsManager; + $this->filterStringParser = $filterStringParser; + $this->userManager = $userManager; + $this->l10n = $l10n; + $this->urlGenerator = $urlGenerator; + } + + public function searchCards(string $term, int $limit = null, ?int $cursor = null): array { + $boards = $this->boardService->getUserBoards(); + $boardIds = array_map(static function (Board $board) { + return $board->getId(); + }, $boards); + $matchedCards = $this->cardMapper->search($boardIds, $this->filterStringParser->parse($term), $limit, $cursor); + + $self = $this; + return array_map(function (Card $card) use ($self) { + $self->cardService->enrich($card); + return $card; + }, $matchedCards); + } + + public function searchBoards(string $term, ?int $limit, ?int $cursor): array { + $boards = $this->boardService->getUserBoards(); + return array_filter($boards, static function (Board $board) use ($term) { + return mb_stripos(mb_strtolower($board->getTitle()), mb_strtolower($term)) > -1; + }); + } + + public function searchComments(string $term, ?int $limit = null, ?int $cursor = null): array { + $boards = $this->boardService->getUserBoards(); + $boardIds = array_map(static function (Board $board) { + return $board->getId(); + }, $boards); + $matchedComments = $this->cardMapper->searchComments($boardIds, $this->filterStringParser->parse($term), $limit, $cursor); + + $self = $this; + return array_map(function ($cardRow) use ($self) { + $comment = $this->commentsManager->get($cardRow['comment_id']); + unset($cardRow['comment_id']); + $card = Card::fromRow($cardRow); + $self->cardService->enrich($card); + $user = $this->userManager->get($comment->getActorId()); + $displayName = $user ? $user->getDisplayName() : ''; + return new CommentSearchResultEntry($comment->getId(), $comment->getMessage(), $displayName, $card, $this->urlGenerator, $this->l10n); + }, $matchedComments); + } +} diff --git a/lib/Sharing/DeckShareProvider.php b/lib/Sharing/DeckShareProvider.php index 7a992d2af..d9ca09bda 100644 --- a/lib/Sharing/DeckShareProvider.php +++ b/lib/Sharing/DeckShareProvider.php @@ -562,6 +562,7 @@ public function getSharesBy($userId, $shareType, $node, $reshares, $limit, $offs /** * @inheritDoc + * @throws ShareNotFound */ public function getShareById($id, $recipientId = null) { $qb = $this->dbConnection->getQueryBuilder(); diff --git a/src/components/Controls.vue b/src/components/Controls.vue index c220c9dc8..bcfbdba49 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -33,8 +33,14 @@ ({{ t('deck', 'Archived cards') }})

-
-
+ +
@@ -57,7 +63,7 @@ value="">
-
+
@@ -237,6 +243,7 @@ export default { ]), ...mapState({ compactMode: state => state.compactMode, + searchQuery: state => state.searchQuery, }), detailsRoute() { return { @@ -374,6 +381,13 @@ export default { } } + .deck-search { + input[type=search] { + background-position: 5px; + padding-left: 24px; + } + } + .filter--item { input + label { display: block; diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue index 7a3d49efb..d47ea1a38 100644 --- a/src/components/board/Board.vue +++ b/src/components/board/Board.vue @@ -65,6 +65,7 @@

+
@@ -75,10 +76,12 @@ import { mapState, mapGetters } from 'vuex' import Controls from '../Controls' import Stack from './Stack' import { EmptyContent } from '@nextcloud/vue' +import GlobalSearchResults from '../search/GlobalSearchResults' export default { name: 'Board', components: { + GlobalSearchResults, Controls, Container, Draggable, @@ -178,13 +181,17 @@ export default { width: 100%; height: 100%; max-height: calc(100vh - 50px); + display: flex; + flex-direction: column; } .board { padding-left: $board-spacing; position: relative; - height: calc(100% - 44px); - overflow-x: scroll; + max-height: calc(100% - 44px); + overflow: hidden; + overflow-x: auto; + flex-grow: 1; } /** diff --git a/src/components/card/CardSidebar.vue b/src/components/card/CardSidebar.vue index 2060c0c0a..8eb22fd4f 100644 --- a/src/components/card/CardSidebar.vue +++ b/src/components/card/CardSidebar.vue @@ -22,6 +22,7 @@