diff --git a/appinfo/routes.php b/appinfo/routes.php index 186a7fef5..f3c58beb5 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -125,5 +125,8 @@ ['name' => 'ApiColumns#createTextColumn', 'url' => '/api/2/columns/text', 'verb' => 'POST'], ['name' => 'ApiColumns#createSelectionColumn', 'url' => '/api/2/columns/selection', 'verb' => 'POST'], ['name' => 'ApiColumns#createDatetimeColumn', 'url' => '/api/2/columns/datetime', 'verb' => 'POST'], + + ['name' => 'ApiFavorite#create', 'url' => '/api/2/favorites/{nodeType}/{nodeId}', 'verb' => 'POST', 'requirements' => ['nodeType' => '(\d+)', 'nodeId' => '(\d+)']], + ['name' => 'ApiFavorite#destroy', 'url' => '/api/2/favorites/{nodeType}/{nodeId}', 'verb' => 'DELETE', 'requirements' => ['nodeType' => '(\d+)', 'nodeId' => '(\d+)']], ] ]; diff --git a/composer.json b/composer.json index b8d66398a..7d7694377 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", "cs:check": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix", - "psalm": "./vendor/bin/psalm.phar --show-info=true --no-cache", + "psalm": "./vendor/bin/psalm.phar --show-info=false --no-cache", "psalm:update-baseline": "./vendor/bin/psalm.phar --update-baseline", "psalm:fix": "./vendor/bin/psalm.phar --no-cache --alter --issues=InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType,MismatchingDocblockReturnType,MissingParamType,InvalidFalsableReturnType", "psalm:fix:dry": "./vendor/bin/psalm.phar --no-cache --alter --issues=InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType,MismatchingDocblockReturnType,MissingParamType,InvalidFalsableReturnType --dry-run", diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 2d7573ad2..f80adc6b5 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -46,7 +46,7 @@ public function __construct(IAppManager $appManager, LoggerInterface $logger, IC /** * - * @return array{tables: array{enabled: bool, version: string, apiVersions: string[], column_types: string[]}} + * @return array{tables: array{enabled: bool, version: string, apiVersions: string[], features: string[], column_types: string[]}} * * @inheritDoc */ @@ -63,6 +63,10 @@ public function getCapabilities(): array { 'apiVersions' => [ '1.0' ], + 'features' => [ + 'favorite', + 'archive', + ], 'column_types' => [ 'text-line', $textColumnVariant, diff --git a/lib/Command/RenameTable.php b/lib/Command/RenameTable.php index 6e8c4f41b..3a41153b5 100644 --- a/lib/Command/RenameTable.php +++ b/lib/Command/RenameTable.php @@ -44,7 +44,8 @@ public function __construct(TableService $tableService, LoggerInterface $logger) protected function configure(): void { $this - ->setName('tables:rename') + ->setName('tables:update') + ->setAliases(['tables:rename']) ->setDescription('Rename a table.') ->addArgument( 'ID', @@ -62,6 +63,12 @@ protected function configure(): void { InputOption::VALUE_OPTIONAL, 'New emoji.' ) + ->addOption( + 'archived', + 'a', + InputOption::VALUE_NONE, + 'Archived' + ) ; } @@ -74,9 +81,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $id = $input->getArgument('ID'); $title = $input->getArgument('title'); $emoji = $input->getOption('emoji'); + $archived = $input->getOption('archived'); try { - $table = $this->tableService->update($id, $title, $emoji, ''); + $table = $this->tableService->update($id, $title, $emoji, $archived, ''); $arr = $table->jsonSerialize(); unset($arr['hasShares']); diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index a9d9a8566..65b6654f8 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -178,9 +178,9 @@ public function getTable(int $tableId): DataResponse { * 403: No permissions * 404: Not found */ - public function updateTable(int $tableId, string $title = null, string $emoji = null): DataResponse { + public function updateTable(int $tableId, string $title = null, string $emoji = null, ?bool $archived = false): DataResponse { try { - return new DataResponse($this->tableService->update($tableId, $title, $emoji, $this->userId)->jsonSerialize()); + return new DataResponse($this->tableService->update($tableId, $title, $emoji, $archived, $this->userId)->jsonSerialize()); } catch (PermissionError $e) { $this->logger->warning('A permission error occurred: ' . $e->getMessage()); $message = ['message' => $e->getMessage()]; diff --git a/lib/Controller/ApiFavoriteController.php b/lib/Controller/ApiFavoriteController.php new file mode 100644 index 000000000..372b45892 --- /dev/null +++ b/lib/Controller/ApiFavoriteController.php @@ -0,0 +1,86 @@ +service = $service; + } + + /** + * [api v2] Add a node (table or view) to user favorites + * + * @NoAdminRequired + * + * @param int $nodeType + * @param int $nodeId + * @return DataResponse|DataResponse + * + * 200: Tables returned + */ + public function create(int $nodeType, int $nodeId): DataResponse { + try { + $this->service->addFavorite($nodeType, $nodeId); + return new DataResponse(); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError|DBException|Exception $e) { + return $this->handleError($e); + } + } + + + /** + * [api v2] Remove a node (table or view) to from favorites + * + * @NoAdminRequired + * + * @param int $nodeType + * @param int $nodeId + * @return DataResponse|DataResponse + * + * 200: Deleted table returned + * 403: No permissions + * 404: Not found + */ + public function destroy(int $nodeType, int $nodeId): DataResponse { + try { + $this->service->removeFavorite($nodeType, $nodeId); + return new DataResponse(); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError|DBException|Exception $e) { + return $this->handleError($e); + } + } +} diff --git a/lib/Controller/ApiTablesController.php b/lib/Controller/ApiTablesController.php index 60db21cc9..9efe47b9f 100644 --- a/lib/Controller/ApiTablesController.php +++ b/lib/Controller/ApiTablesController.php @@ -106,9 +106,9 @@ public function create(string $title, ?string $emoji, string $template = 'custom * 403: No permissions * 404: Not found */ - public function update(int $id, string $title = null, string $emoji = null): DataResponse { + public function update(int $id, ?string $title = null, ?string $emoji = null, ?bool $archived = null): DataResponse { try { - return new DataResponse($this->service->update($id, $title, $emoji, $this->userId)->jsonSerialize()); + return new DataResponse($this->service->update($id, $title, $emoji, $archived, $this->userId)->jsonSerialize()); } catch (PermissionError $e) { return $this->handlePermissionError($e); } catch (InternalError $e) { diff --git a/lib/Controller/TableController.php b/lib/Controller/TableController.php index 44d5d1b46..65d4e7fd0 100644 --- a/lib/Controller/TableController.php +++ b/lib/Controller/TableController.php @@ -70,9 +70,9 @@ public function destroy(int $id): DataResponse { /** * @NoAdminRequired */ - public function update(int $id, string $title = null, string $emoji = null): DataResponse { - return $this->handleError(function () use ($id, $title, $emoji) { - return $this->service->update($id, $title, $emoji, $this->userId); + public function update(int $id, string $title = null, string $emoji = null, ?bool $archived = null): DataResponse { + return $this->handleError(function () use ($id, $title, $emoji, $archived) { + return $this->service->update($id, $title, $emoji, $archived, $this->userId); }); } } diff --git a/lib/Db/Table.php b/lib/Db/Table.php index 1807a73d6..242496fe7 100644 --- a/lib/Db/Table.php +++ b/lib/Db/Table.php @@ -16,6 +16,8 @@ * @method setTitle(string $title) * @method getEmoji(): string * @method setEmoji(string $emoji) + * @method getArchived(): bool + * @method setArchived(bool $archived) * @method getOwnership(): string * @method setOwnership(string $ownership) * @method getOwnerDisplayName(): string @@ -26,6 +28,8 @@ * @method setOnSharePermissions(array $onSharePermissions) * @method getHasShares(): bool * @method setHasShares(bool $hasShares) + * @method getFavorite(): bool + * @method setFavorite(bool $favorite) * @method getRowsCount(): int * @method setRowsCount(int $rowsCount) * @method getColumnsCount(): int @@ -52,10 +56,12 @@ class Table extends Entity implements JsonSerializable { protected ?string $createdAt = null; protected ?string $lastEditBy = null; protected ?string $lastEditAt = null; + protected bool $archived = false; protected ?bool $isShared = null; protected ?array $onSharePermissions = null; protected ?bool $hasShares = false; + protected ?bool $favorite = false; protected ?int $rowsCount = 0; protected ?int $columnsCount = 0; protected ?array $views = null; @@ -63,6 +69,7 @@ class Table extends Entity implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); + $this->addType('archived', 'boolean'); } /** @@ -79,7 +86,9 @@ public function jsonSerialize(): array { 'createdAt' => $this->createdAt ?: '', 'lastEditBy' => $this->lastEditBy ?: '', 'lastEditAt' => $this->lastEditAt ?: '', + 'archived' => $this->archived, 'isShared' => !!$this->isShared, + 'favorite' => $this->favorite, 'onSharePermissions' => $this->getSharePermissions(), 'hasShares' => !!$this->hasShares, 'rowsCount' => $this->rowsCount ?: 0, diff --git a/lib/Db/View.php b/lib/Db/View.php index 3e72d6240..73684b55b 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -35,6 +35,8 @@ * @method setOnSharePermissions(array $onSharePermissions) * @method getHasShares(): bool * @method setHasShares(bool $hasShares) + * @method getFavorite(): bool + * @method setFavorite(bool $favorite) * @method getRowsCount(): int * @method setRowsCount(int $rowCount) * @method getOwnership(): string @@ -57,6 +59,7 @@ class View extends Entity implements JsonSerializable { protected ?bool $isShared = null; protected ?array $onSharePermissions = null; protected ?bool $hasShares = false; + protected bool $favorite = false; protected ?int $rowsCount = 0; protected ?string $ownership = null; protected ?string $ownerDisplayName = null; @@ -137,6 +140,7 @@ public function jsonSerialize(): array { 'columns' => $this->getColumnsArray(), 'sort' => $this->getSortArray(), 'isShared' => !!$this->isShared, + 'favorite' => $this->favorite, 'onSharePermissions' => $this->getSharePermissions(), 'hasShares' => !!$this->hasShares, 'rowsCount' => $this->rowsCount ?: 0, diff --git a/lib/Migration/Version000800Date20240222000000.php b/lib/Migration/Version000800Date20240222000000.php new file mode 100644 index 000000000..9a8d8a4d0 --- /dev/null +++ b/lib/Migration/Version000800Date20240222000000.php @@ -0,0 +1,60 @@ +hasTable('tables_tables')) { + $table = $schema->getTable('tables_tables'); + $table->addColumn('archived', Types::BOOLEAN, [ + 'default' => false, + 'notnull' => true, + ]); + } + + if (!$schema->hasTable('tables_favorites')) { + $table = $schema->createTable('tables_favorites'); + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'autoincrement' => true, + 'unsigned' => true, + ]); + $table->addColumn('node_type', Types::SMALLINT, [ + 'notnull' => true, + ]); + $table->addColumn('node_id', Types::BIGINT, [ + 'notnull' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['user_id'], 'idx_tables_fav_uid'); + } + + return $schema; + } + +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 42668bce5..172b760a0 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -23,6 +23,7 @@ * sort: list, * filter: list>, * isShared: bool, + * favorite: bool, * onSharePermissions: ?array{ * read: bool, * create: bool, @@ -44,6 +45,8 @@ * createdAt: string, * lastEditBy: string, * lastEditAt: string, + * archived: bool, + * favorite: bool, * isShared: bool, * onSharePermissions: ?array{ * read: bool, diff --git a/lib/Service/FavoritesService.php b/lib/Service/FavoritesService.php new file mode 100644 index 000000000..d4737f4b4 --- /dev/null +++ b/lib/Service/FavoritesService.php @@ -0,0 +1,139 @@ + + * + * @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 . + */ + +namespace OCA\Tables\Service; + +use OCA\Tables\AppInfo\Application; +use OCA\Tables\Errors\InternalError; +use OCA\Tables\Errors\NotFoundError; +use OCA\Tables\Errors\PermissionError; +use OCP\Cache\CappedMemoryCache; +use OCP\DB\Exception; +use OCP\IDBConnection; + +class FavoritesService { + + private IDBConnection $connection; + private PermissionsService $permissionsService; + private ?string $userId; + private CappedMemoryCache $cache; + + public function __construct( + IDBConnection $connection, + PermissionsService $permissionsService, + ?string $userId + ) { + $this->connection = $connection; + $this->permissionsService = $permissionsService; + $this->userId = $userId; + // The cache usage is currently not unique to the user id as only a memory cache is used + $this->cache = new CappedMemoryCache(); + } + + public function isFavorite(int $nodeType, int $id): bool { + $cacheKey = $nodeType . '_' . $id; + if ($cached = $this->cache->get($cacheKey)) { + return $cached; + } + + $this->cache->clear(); + $qb = $this->connection->getQueryBuilder(); + $qb->select('*') + ->from('tables_favorites') + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($this->userId))); + + $result = $qb->executeQuery(); + while ($row = $result->fetch()) { + $this->cache->set($row['node_type'] . '_' . $row['node_id'], true); + } + + // Set the cache for not found matches still to avoid further queries + if (!$this->cache->get($cacheKey)) { + $this->cache->set($cacheKey, false); + } + + return $this->cache->get($cacheKey); + } + + /** + * @throws Exception + * @throws InternalError + * @throws NotFoundError + * @throws PermissionError + */ + public function addFavorite(int $nodeType, int $id): void { + $this->checkValidNodeType($nodeType); + $this->checkAccessToNode($nodeType, $id); + + $qb = $this->connection->getQueryBuilder(); + $qb->insert('tables_favorites') + ->values([ + 'user_id' => $qb->createNamedParameter($this->userId), + 'node_type' => $qb->createNamedParameter($nodeType), + 'node_id' => $qb->createNamedParameter($id), + ]); + $qb->executeStatement(); + $this->cache->set($nodeType . '_' . $id, true); + } + + /** + * @throws Exception + * @throws InternalError + * @throws NotFoundError + * @throws PermissionError + */ + public function removeFavorite(int $nodeType, int $id): void { + $this->checkValidNodeType($nodeType); + $this->checkAccessToNode($nodeType, $id); + + $qb = $this->connection->getQueryBuilder(); + $qb->delete('tables_favorites') + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($this->userId))) + ->andWhere($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType))) + ->andWhere($qb->expr()->eq('node_id', $qb->createNamedParameter($id))); + $qb->executeStatement(); + $this->cache->set($nodeType . '_' . $id, false); + } + + /** + * @throws InternalError + */ + private function checkValidNodeType(int $nodeType): void { + if (!in_array($nodeType, [Application::NODE_TYPE_TABLE, Application::NODE_TYPE_VIEW])) { + throw new InternalError('Invalid node type'); + } + } + + /** + * @throws PermissionError + */ + private function checkAccessToNode(int $nodeType, int $nodeId): void { + if ($this->permissionsService->canAccessNodeById($nodeType, $nodeId)) { + return; + } + + throw new PermissionError('Invalid node type and id'); + } + +} diff --git a/lib/Service/TableService.php b/lib/Service/TableService.php index 988462a7a..e28fe5fcb 100644 --- a/lib/Service/TableService.php +++ b/lib/Service/TableService.php @@ -6,6 +6,7 @@ use DateTime; +use OCA\Tables\AppInfo\Application; use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Errors\InternalError; @@ -38,6 +39,9 @@ class TableService extends SuperService { protected UserHelper $userHelper; + protected FavoritesService $favoritesService; + + protected IL10N $l; public function __construct( @@ -51,6 +55,7 @@ public function __construct( ViewService $viewService, ShareService $shareService, UserHelper $userHelper, + FavoritesService $favoritesService, IL10N $l ) { parent::__construct($logger, $userId, $permissionsService); @@ -61,6 +66,7 @@ public function __construct( $this->viewService = $viewService; $this->shareService = $shareService; $this->userHelper = $userHelper; + $this->favoritesService = $favoritesService; $this->l = $l; } @@ -197,6 +203,11 @@ private function enhanceTable(Table $table, string $userId): void { $table->setViews($this->viewService->findAll($table)); } + if ($this->favoritesService->isFavorite(Application::NODE_TYPE_TABLE, $table->getId())) { + $table->setFavorite(true); + } + + } @@ -420,7 +431,7 @@ public function delete(int $id, ?string $userId = null): Table { * @throws NotFoundError * @throws PermissionError */ - public function update(int $id, ?string $title, ?string $emoji, ?string $userId = null): Table { + public function update(int $id, ?string $title, ?string $emoji, ?bool $archived = null, ?string $userId = null): Table { $userId = $this->permissionsService->preCheckUserId($userId); try { @@ -445,6 +456,9 @@ public function update(int $id, ?string $title, ?string $emoji, ?string $userId if ($emoji !== null) { $table->setEmoji($emoji); } + if ($archived !== null) { + $table->setArchived($archived); + } $table->setLastEditBy($userId); $table->setLastEditAt($time->format('Y-m-d H:i:s')); try { diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index 5dd6297fd..4593086a0 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -7,6 +7,7 @@ use DateTime; use Exception; +use OCA\Tables\AppInfo\Application; use OCA\Tables\Db\Table; use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; @@ -34,6 +35,8 @@ class ViewService extends SuperService { protected UserHelper $userHelper; + protected FavoritesService $favoritesService; + protected IL10N $l; public function __construct( @@ -44,6 +47,7 @@ public function __construct( ShareService $shareService, RowService $rowService, UserHelper $userHelper, + FavoritesService $favoritesService, IL10N $l ) { parent::__construct($logger, $userId, $permissionsService); @@ -52,6 +56,7 @@ public function __construct( $this->shareService = $shareService; $this->rowService = $rowService; $this->userHelper = $userHelper; + $this->favoritesService = $favoritesService; } @@ -395,6 +400,10 @@ private function enhanceView(View $view, string $userId): void { } } } + + if ($this->favoritesService->isFavorite(Application::NODE_TYPE_VIEW, $view->getId())) { + $view->setFavorite(true); + } } /** diff --git a/src/modules/navigation/partials/NavigationTableItem.vue b/src/modules/navigation/partials/NavigationTableItem.vue index bca3c05dd..9ec1263eb 100644 --- a/src/modules/navigation/partials/NavigationTableItem.vue +++ b/src/modules/navigation/partials/NavigationTableItem.vue @@ -12,10 +12,12 @@ + + {{ t('tables', 'Edit table') }} + + @@ -43,12 +48,16 @@ {{ t('tables', 'Create view') }} + + {{ t('tables', 'Share') }} + + @@ -57,6 +66,8 @@ + + @@ -65,6 +76,48 @@ + + + + {{ t('tables', 'Add to favorites') }} + + + + + + {{ t('tables', 'Remove from favorites') }} + + + + + + {{ t('tables', 'Archive table') }} + + + + + + {{ t('tables', 'Unarchive table') }} + + + + +