diff --git a/appinfo/routes.php b/appinfo/routes.php index 050db4d07d..f1929d5955 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -127,6 +127,8 @@ ['name' => 'ApiTables#update', 'url' => '/api/2/tables/{id}', 'verb' => 'PUT'], ['name' => 'ApiTables#destroy', 'url' => '/api/2/tables/{id}', 'verb' => 'DELETE'], ['name' => 'ApiTables#transfer', 'url' => '/api/2/tables/{id}/transfer', 'verb' => 'PUT'], + ['name' => 'ApiTables#archiveTable', 'url' => '/api/2/tables/{id}/archive', 'verb' => 'POST'], + ['name' => 'ApiTables#unarchiveTable', 'url' => '/api/2/tables/{id}/archive', 'verb' => 'DELETE'], ['name' => 'ApiColumns#index', 'url' => '/api/2/columns/{nodeType}/{nodeId}', 'verb' => 'GET'], ['name' => 'ApiColumns#show', 'url' => '/api/2/columns/{id}', 'verb' => 'GET'], @@ -145,6 +147,8 @@ ['name' => 'Context#update', 'url' => '/api/2/contexts/{contextId}', 'verb' => 'PUT'], ['name' => 'Context#destroy', 'url' => '/api/2/contexts/{contextId}', 'verb' => 'DELETE'], ['name' => 'Context#transfer', 'url' => '/api/2/contexts/{contextId}/transfer', 'verb' => 'PUT'], + ['name' => 'Context#archiveContext', 'url' => '/api/2/contexts/{contextId}/archive', 'verb' => 'POST'], + ['name' => 'Context#unarchiveContext', 'url' => '/api/2/contexts/{contextId}/archive', 'verb' => 'DELETE'], ['name' => 'Context#updateContentOrder', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PUT'], ['name' => 'RowOCS#createRow', 'url' => '/api/2/{nodeCollection}/{nodeId}/rows', 'verb' => 'POST', 'requirements' => ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)']], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index f0ade8bc35..47ebd25ea7 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -47,6 +47,7 @@ class Application extends App implements IBootstrap { public const NODE_TYPE_TABLE = 0; public const NODE_TYPE_VIEW = 1; + public const NODE_TYPE_CONTEXT = 2; public const OWNER_TYPE_USER = 0; diff --git a/lib/Controller/ApiTablesController.php b/lib/Controller/ApiTablesController.php index 3ccc8115f3..0070a3a927 100644 --- a/lib/Controller/ApiTablesController.php +++ b/lib/Controller/ApiTablesController.php @@ -22,6 +22,7 @@ use OCP\App\IAppManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\IDBConnection; use OCP\IL10N; @@ -306,6 +307,62 @@ public function destroy(int $id): DataResponse { } } + /** + * [api v2] Archive a table for the requesting user + * + * Owners archive the table for all users (clears per-user overrides). + * Non-owners archive only for themselves. + * + * @param int $id Table ID + * @return DataResponse|DataResponse + * + * 200: Table returned with updated archived state + * 403: No permissions + * 404: Not found + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'id')] + public function archiveTable(int $id): DataResponse { + try { + return new DataResponse($this->service->archiveTable($id, $this->userId)->jsonSerialize()); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError $e) { + return $this->handleError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } + } + + /** + * [api v2] Unarchive a table for the requesting user + * + * Owners unarchive the table for all users (clears per-user overrides). + * Non-owners remove only their personal archive override. + * + * @param int $id Table ID + * @return DataResponse|DataResponse + * + * 200: Table returned with updated archived state + * 403: No permissions + * 404: Not found + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'id')] + public function unarchiveTable(int $id): DataResponse { + try { + return new DataResponse($this->service->unarchiveTable($id, $this->userId)->jsonSerialize()); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError $e) { + return $this->handleError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } + } + /** * [api v2] Transfer table * diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index 704ca5e1bc..66ba9e3a5c 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -19,6 +19,7 @@ use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\DB\Exception; use OCP\IL10N; @@ -246,6 +247,54 @@ public function transfer(int $contextId, string $newOwnerId, int $newOwnerType = } } + /** + * [api v2] Archive a context for the requesting user + * + * Owners archive the context for all users (clears per-user overrides). + * Non-owners archive only for themselves. + * + * @param int $contextId ID of the context + * @return DataResponse|DataResponse + * + * 200: Context returned with updated archived state + * 404: Context not found or not available + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + public function archiveContext(int $contextId): DataResponse { + try { + return new DataResponse($this->contextService->archiveContext($contextId, $this->userId)->jsonSerialize()); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (InternalError|Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Unarchive a context for the requesting user + * + * Owners unarchive the context for all users (clears per-user overrides). + * Non-owners remove only their personal archive override. + * + * @param int $contextId ID of the context + * @return DataResponse|DataResponse + * + * 200: Context returned with updated archived state + * 404: Context not found or not available + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + public function unarchiveContext(int $contextId): DataResponse { + try { + return new DataResponse($this->contextService->unarchiveContext($contextId, $this->userId)->jsonSerialize()); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (InternalError|Exception $e) { + return $this->handleError($e); + } + } + /** * [api v2] Update the order on a page of a context * diff --git a/lib/Db/Context.php b/lib/Db/Context.php index 671e4be10e..db5840f0b1 100644 --- a/lib/Db/Context.php +++ b/lib/Db/Context.php @@ -20,6 +20,7 @@ * @method setOwnerId(string $value): void * @method getOwnerType(): int * @method setOwnerType(int $value): void + * @method setArchived(bool $value): void * * @method getSharing(): array * @method setSharing(array $value): void @@ -34,6 +35,7 @@ class Context extends EntitySuper implements JsonSerializable { protected ?string $description = null; protected ?string $ownerId = null; protected ?int $ownerType = null; + protected bool $archived = false; // virtual properties protected ?array $sharing = null; @@ -45,6 +47,11 @@ class Context extends EntitySuper implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); $this->addType('owner_type', 'integer'); + $this->addType('archived', 'boolean'); + } + + public function isArchived(): bool { + return $this->archived; } public function jsonSerialize(): array { @@ -55,7 +62,8 @@ public function jsonSerialize(): array { 'iconName' => $this->getIcon(), 'description' => $this->getDescription(), 'owner' => $this->getOwnerId(), - 'ownerType' => $this->getOwnerType() + 'ownerType' => $this->getOwnerType(), + 'archived' => $this->isArchived(), ]; // extended data diff --git a/lib/Db/ContextMapper.php b/lib/Db/ContextMapper.php index 25bb809ddc..6b4757c794 100644 --- a/lib/Db/ContextMapper.php +++ b/lib/Db/ContextMapper.php @@ -81,6 +81,7 @@ protected function formatResultRows(array $rows, ?string $userId) { 'description' => $rows[0]['description'], 'owner_id' => $rows[0]['owner_id'], 'owner_type' => $rows[0]['owner_type'], + 'archived' => (bool)($rows[0]['archived'] ?? false), ]; $formatted['sharing'] = array_reduce($rows, function (array $carry, array $item) use ($userId) { diff --git a/lib/Db/Table.php b/lib/Db/Table.php index 2d91b5a09e..611fe5456a 100644 --- a/lib/Db/Table.php +++ b/lib/Db/Table.php @@ -84,6 +84,10 @@ public function __construct() { $this->addType('archived', 'boolean'); } + public function isArchived(): bool { + return $this->archived; + } + /** * @psalm-return TablesTable */ diff --git a/lib/Db/UserArchive.php b/lib/Db/UserArchive.php new file mode 100644 index 0000000000..2805ebb6d1 --- /dev/null +++ b/lib/Db/UserArchive.php @@ -0,0 +1,35 @@ +addType('id', 'integer'); + $this->addType('node_type', 'integer'); + $this->addType('node_id', 'integer'); + $this->addType('archived', 'boolean'); + } + + public function isArchived(): bool { + return $this->archived; + } +} diff --git a/lib/Db/UserArchiveMapper.php b/lib/Db/UserArchiveMapper.php new file mode 100644 index 0000000000..7aac2c7dc7 --- /dev/null +++ b/lib/Db/UserArchiveMapper.php @@ -0,0 +1,141 @@ + */ +class UserArchiveMapper extends QBMapper { + protected string $table = 'tables_archive_user'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, UserArchive::class); + } + + /** + * Look up a single per-user archive override. + * + * @throws Exception + */ + public function findForUser(string $userId, int $nodeType, int $nodeId): ?UserArchive { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))); + + $entities = $this->findEntities($qb); + return $entities[0] ?? null; + } + + /** + * Fetch all per-user archive overrides for a given node. + * + * @return UserArchive[] + * @throws Exception + */ + public function findAllForNode(int $nodeType, int $nodeId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))); + + return $this->findEntities($qb); + } + + /** + * Fetch all per-user archive overrides for a given user and node type, + * filtered to a specific set of node IDs. + * + * Oracle enforces a hard limit of 1000 items per IN clause. The method + * chunks $nodeIds into batches of 997 (matching the existing + * ShareMapper::findAllSharesFor() pattern) and merges results in PHP to + * stay within this limit transparently. + * + * @param int[] $nodeIds IDs to restrict the lookup to + * @return array Keyed by node_id for O(1) map lookup + * @throws Exception + */ + public function findAllOverridesForUser(string $userId, int $nodeType, array $nodeIds): array { + if (empty($nodeIds)) { + return []; + } + + $results = []; + foreach (array_chunk($nodeIds, 997) as $chunk) { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('node_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))); + + foreach ($this->findEntities($qb) as $entity) { + $results[$entity->getNodeId()] = $entity; + } + } + + return $results; + } + + /** + * Insert or update a per-user archive override. + * + * @throws Exception + */ + public function upsert(string $userId, int $nodeType, int $nodeId, bool $archived): void { + $existing = $this->findForUser($userId, $nodeType, $nodeId); + + if ($existing !== null) { + $existing->setArchived($archived); + $this->update($existing); + } else { + $entity = new UserArchive(); + $entity->setUserId($userId); + $entity->setNodeType($nodeType); + $entity->setNodeId($nodeId); + $entity->setArchived($archived); + $this->insert($entity); + } + } + + /** + * Remove the per-user archive override for a single user. + * + * @throws Exception + */ + public function deleteForUser(string $userId, int $nodeType, int $nodeId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->table) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } + + /** + * Remove all per-user archive overrides for a node (used when an owner + * archives/unarchives or when the node is permanently deleted). + * + * @throws Exception + */ + public function deleteAllForNode(int $nodeType, int $nodeId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->table) + ->where($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } +} diff --git a/lib/Migration/Version1000Date20260411000000.php b/lib/Migration/Version1000Date20260411000000.php new file mode 100644 index 0000000000..56caf97124 --- /dev/null +++ b/lib/Migration/Version1000Date20260411000000.php @@ -0,0 +1,190 @@ +hasTable('tables_contexts_context')) { + $table = $schema->getTable('tables_contexts_context'); + if (!$table->hasColumn('archived')) { + $table->addColumn('archived', Types::BOOLEAN, [ + 'default' => false, + 'notnull' => false, + ]); + } + } + + // Step 2: Create `tables_archive_user` table for per-user archive overrides + if (!$schema->hasTable('tables_archive_user')) { + $table = $schema->createTable('tables_archive_user'); + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'autoincrement' => true, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('node_type', Types::SMALLINT, [ + 'notnull' => true, + ]); + $table->addColumn('node_id', Types::BIGINT, [ + 'notnull' => true, + ]); + // `archived` = true means the user archived this node; + // `archived` = false means the user explicitly unarchived an owner-archived node. + // No index on the `archived` column: it is a low-cardinality boolean evaluated + // after a higher-selectivity filter (ownership / join) is already applied. + // Adding an index here would waste write overhead without measurable read benefit. + $table->addColumn('archived', Types::BOOLEAN, [ + 'notnull' => true, + 'default' => true, + ]); + $table->setPrimaryKey(['id']); + + // Unique index: one override row per (user, node_type, node_id) triple + $table->addUniqueIndex(['user_id', 'node_type', 'node_id'], 'archive_user_unique_idx'); + + // Secondary index to support deleteAllForNode() / findAllForNode() queries + // that filter on (node_type, node_id) without a leading user_id. + $table->addIndex(['node_type', 'node_id'], 'archive_user_node_idx'); + } + + return $schema; + } + + /** + * Migrate existing archived tables to per-user records. + * + * For every row in `tables_tables` where `archived = true`, insert one + * `tables_archive_user` record for the owner and one for each direct + * user-share recipient. + * + * Group and circle share recipients cannot be enumerated in a pure SQL + * migration. They inherit the archived state from the entity flag fallback + * on their first request after the migration. + * + * @param IOutput $output + * @param Closure $schemaClosure + * @param array $options + * @throws Exception + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $output->info('Migrating existing archived tables to per-user archive records...'); + + $qb = $this->connection->getQueryBuilder(); + // Fetch all tables that are currently archived + $qb->select('id', 'ownership') + ->from('tables_tables') + ->where($qb->expr()->eq('archived', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))); + + $result = $qb->executeQuery(); + $archivedTables = $result->fetchAll(); + $result->closeCursor(); + + if (empty($archivedTables)) { + $output->info('No archived tables found, skipping data migration.'); + return; + } + + $inserted = 0; + + foreach ($archivedTables as $tableRow) { + $tableId = (int)$tableRow['id']; + $ownerId = $tableRow['ownership']; + + // Insert owner record + $inserted += $this->upsertArchiveRecord($ownerId, 0, $tableId, true); + + // Insert direct user-share recipient records + $shareQb = $this->connection->getQueryBuilder(); + $shareQb->select('receiver') + ->from('tables_shares') + ->where($shareQb->expr()->eq('node_id', $shareQb->createNamedParameter($tableId, IQueryBuilder::PARAM_INT))) + ->andWhere($shareQb->expr()->eq('node_type', $shareQb->createNamedParameter(0, IQueryBuilder::PARAM_INT))) + ->andWhere($shareQb->expr()->eq('receiver_type', $shareQb->createNamedParameter('user'))); + + $shareResult = $shareQb->executeQuery(); + while ($shareRow = $shareResult->fetch()) { + $receiverId = $shareRow['receiver']; + if ($receiverId !== $ownerId) { + $inserted += $this->upsertArchiveRecord($receiverId, 0, $tableId, true); + } + } + $shareResult->closeCursor(); + } + + $output->info(sprintf('Inserted %d per-user archive records.', $inserted)); + } + + /** + * Insert a `tables_archive_user` record if it does not already exist. + * Returns 1 if a new record was inserted, 0 if it already existed. + * + * @throws Exception + */ + private function upsertArchiveRecord(string $userId, int $nodeType, int $nodeId, bool $archived): int { + // Check for existing record first to avoid unique-index violation + $checkQb = $this->connection->getQueryBuilder(); + $checkQb->select('id') + ->from('tables_archive_user') + ->where($checkQb->expr()->eq('user_id', $checkQb->createNamedParameter($userId))) + ->andWhere($checkQb->expr()->eq('node_type', $checkQb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT))) + ->andWhere($checkQb->expr()->eq('node_id', $checkQb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))); + + $existing = $checkQb->executeQuery()->fetchOne(); + if ($existing !== false) { + return 0; + } + + $insertQb = $this->connection->getQueryBuilder(); + $insertQb->insert('tables_archive_user') + ->values([ + 'user_id' => $insertQb->createNamedParameter($userId), + 'node_type' => $insertQb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT), + 'node_id' => $insertQb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT), + 'archived' => $insertQb->createNamedParameter($archived, IQueryBuilder::PARAM_BOOL), + ]); + $insertQb->executeStatement(); + return 1; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 4ab08bdcd0..f9eb53acc7 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -214,6 +214,7 @@ * description: string, * owner: string, * ownerType: int, + * archived: bool, * } * * @psalm-type TablesContextNavigation = array{ diff --git a/lib/Service/ArchiveService.php b/lib/Service/ArchiveService.php new file mode 100644 index 0000000000..d571f96ba2 --- /dev/null +++ b/lib/Service/ArchiveService.php @@ -0,0 +1,213 @@ +setEntityArchived($nodeType, $nodeId, true); + $this->userArchiveMapper->deleteAllForNode($nodeType, $nodeId); + } else { + $this->userArchiveMapper->upsert($userId, $nodeType, $nodeId, true); + } + } + + /** + * Unarchive a table or context for a user. + * + * If the user is the owner, the entity-level flag is set to false and all + * per-user overrides are cleared (resets everyone to "not archived"). + * If the user is not the owner and the entity is not owner-archived, the + * personal override is simply removed. + * If the user is not the owner but the entity is owner-archived, an + * explicit unarchive override is upserted so the user sees the item in + * their active list while the owner's state is preserved for others. + * + * @throws Exception + * @throws InternalError + */ + public function unarchiveForUser(string $userId, int $nodeType, int $nodeId, bool $isOwner, bool $entityArchived): void { + if ($isOwner) { + $this->setEntityArchived($nodeType, $nodeId, false); + $this->userArchiveMapper->deleteAllForNode($nodeType, $nodeId); + } elseif (!$entityArchived) { + // Entity is not owner-archived — just remove the personal override. + $this->userArchiveMapper->deleteForUser($userId, $nodeType, $nodeId); + } else { + // Entity is owner-archived — store an explicit unarchive override so + // the user's active list shows the item while the owner's state is + // preserved for everyone else. + $this->userArchiveMapper->upsert($userId, $nodeType, $nodeId, false); + } + } + + /** + * Resolve the effective archive state for a user. + * + * A personal override (if present) takes precedence over the entity flag. + * + * @throws Exception + */ + public function isArchivedForUser(string $userId, int $nodeType, int $nodeId, bool $entityArchived): bool { + $override = $this->userArchiveMapper->findForUser($userId, $nodeType, $nodeId); + return $override !== null ? $override->isArchived() : $entityArchived; + } + + /** + * Overwrite the `archived` property on each table with the per-user + * resolved value for $userId. + * + * Uses a single bulk DB query (chunked for Oracle compatibility) regardless + * of how many tables are in the array. + * + * @param Table[] $tables + * @return Table[] + * @throws Exception + */ + public function enrichTablesWithArchiveState(array $tables, string $userId): array { + $nodeIds = array_map(fn (Table $t) => $t->getId(), $tables); + $overrides = $this->userArchiveMapper->findAllOverridesForUser($userId, Application::NODE_TYPE_TABLE, $nodeIds); + + foreach ($tables as $table) { + $override = $overrides[$table->getId()] ?? null; + $archived = $override !== null ? $override->isArchived() : $table->isArchived(); + $table->setArchived($archived); + } + + return $tables; + } + + /** + * Overwrite the `archived` property on each context with the per-user + * resolved value for $userId. + * + * Uses a single bulk DB query (chunked for Oracle compatibility) regardless + * of how many contexts are in the array. + * + * @param Context[] $contexts + * @return Context[] + * @throws Exception + */ + public function enrichContextsWithArchiveState(array $contexts, string $userId): array { + $nodeIds = array_map(fn (Context $c) => $c->getId(), $contexts); + $overrides = $this->userArchiveMapper->findAllOverridesForUser($userId, Application::NODE_TYPE_CONTEXT, $nodeIds); + + foreach ($contexts as $context) { + $override = $overrides[$context->getId()] ?? null; + $archived = $override !== null ? $override->isArchived() : $context->isArchived(); + $context->setArchived($archived); + } + + return $contexts; + } + + /** + * Migrate per-user archive overrides when ownership is transferred. + * + * Two invariants are maintained: + * 1. If the incoming owner held a personal override, that value is promoted + * to the entity-level flag and their override row is deleted. + * 2. If the entity flag changes as a result, the outgoing owner receives a + * preservation record so their view is unchanged after the transfer. + * + * Must be called inside the same atomic block as the ownership-column + * update so that archive state and ownership are always consistent. + * + * Returns the new entity-level archived value. The caller must call + * `$entity->setArchived($newArchived)` when the returned value differs + * from $entityArchived, before persisting the entity. + * + * @throws Exception + */ + public function prepareOwnershipTransfer( + string $oldOwnerId, + string $newOwnerId, + int $nodeType, + int $nodeId, + bool $entityArchived, + ): bool { + $newOwnerOverride = $this->userArchiveMapper->findForUser($newOwnerId, $nodeType, $nodeId); + + if ($newOwnerOverride !== null) { + $newArchived = $newOwnerOverride->isArchived(); + // Remove the override — entity flag becomes authoritative for the new owner + $this->userArchiveMapper->deleteForUser($newOwnerId, $nodeType, $nodeId); + } else { + $newArchived = $entityArchived; + } + + // Preserve the outgoing owner's view if the entity flag will change + if ($newArchived !== $entityArchived) { + $this->userArchiveMapper->upsert($oldOwnerId, $nodeType, $nodeId, $entityArchived); + } + + return $newArchived; + } + + /** + * Remove all per-user archive overrides for a node. + * + * Called when a table or context is permanently deleted so that + * `tables_archive_user` does not accumulate orphaned rows. + * + * @throws Exception + */ + public function deleteNodeArchiveOverrides(int $nodeType, int $nodeId): void { + $this->userArchiveMapper->deleteAllForNode($nodeType, $nodeId); + } + + /** + * Directly set the entity-level `archived` flag for a table or context row. + * + * Intentionally bypasses TableService / ContextService to avoid a circular + * dependency: those services will eventually call ArchiveService, so + * ArchiveService must not call them back for this low-level write. + * + * @throws Exception + * @throws InternalError + */ + private function setEntityArchived(int $nodeType, int $nodeId, bool $archived): void { + $tableName = match ($nodeType) { + Application::NODE_TYPE_TABLE => 'tables_tables', + Application::NODE_TYPE_CONTEXT => 'tables_contexts_context', + default => throw new InternalError('Unsupported node type for archiving: ' . $nodeType), + }; + + $qb = $this->connection->getQueryBuilder(); + $qb->update($tableName) + ->set('archived', $qb->createNamedParameter($archived, IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } +} diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index ae925b44b7..7c38065da0 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -50,6 +50,7 @@ public function __construct( private bool $isCLI, protected INavigationManager $navigationManager, protected IURLGenerator $urlGenerator, + private ArchiveService $archiveService, ) { } @@ -69,7 +70,15 @@ public function findAll(?string $userId): array { $this->logger->warning($error); throw new InternalError($error); } - return $this->contextMapper->findAll($userId); + $contexts = $this->contextMapper->findAll($userId); + if ($userId !== null) { + try { + $this->archiveService->enrichContextsWithArchiveState($contexts, $userId); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } + return $contexts; } public function findForNavigation(string $userId): array { @@ -116,7 +125,55 @@ public function findById(int $id, ?string $userId): Context { throw new InternalError($error); } - return $this->contextMapper->findById($id, $userId); + $context = $this->contextMapper->findById($id, $userId); + if ($userId !== null) { + try { + $this->archiveService->enrichContextsWithArchiveState([$context], $userId); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } + return $context; + } + + /** + * Archive a context for the given user. + * + * If the user is the owner the entity flag is set and all per-user + * overrides are cleared; otherwise a personal override is stored. + * Access is validated by the mapper (NotFoundError if no access). + * + * @throws Exception + * @throws NotFoundError + * @throws InternalError + */ + public function archiveContext(int $contextId, string $userId): Context { + // Load directly from mapper to get entity-level archived (bypasses per-user enrichment) + $context = $this->contextMapper->findById($contextId, $userId); + $isOwner = $context->getOwnerId() === $userId; + $this->archiveService->archiveForUser($userId, Application::NODE_TYPE_CONTEXT, $contextId, $isOwner); + return $this->findById($contextId, $userId); + } + + /** + * Unarchive a context for the given user. + * + * If the user is the owner the entity flag is cleared and all per-user + * overrides are reset; otherwise the personal override is removed or + * set to false if the owner has archived the context. + * Access is validated by the mapper (NotFoundError if no access). + * + * @throws Exception + * @throws NotFoundError + * @throws InternalError + */ + public function unarchiveContext(int $contextId, string $userId): Context { + // Load directly from mapper to get entity-level archived (bypasses per-user enrichment) + $context = $this->contextMapper->findById($contextId, $userId); + $isOwner = $context->getOwnerId() === $userId; + $entityArchived = $context->isArchived(); // entity-level flag, not per-user + $this->archiveService->unarchiveForUser($userId, Application::NODE_TYPE_CONTEXT, $contextId, $isOwner, $entityArchived); + return $this->findById($contextId, $userId); } /** @@ -265,6 +322,7 @@ public function delete(int $contextId, string $userId): Context { $this->pageMapper->deleteByPageId($pageId); } $this->contextMapper->delete($context); + $this->archiveService->deleteNodeArchiveOverrides(Application::NODE_TYPE_CONTEXT, $context->getId()); }, $this->dbc); return $context; } @@ -301,11 +359,18 @@ public function transfer(int $contextId, string $newOwnerId, int $newOwnerType): } $oldOwnerId = $context->getOwnerId(); + $oldArchived = $context->isArchived(); $context->setOwnerId($newOwnerId); $context->setOwnerType($newOwnerType); try { - $context = $this->atomic(function () use ($context, $contextId, $newOwnerId, $oldOwnerId) { + $context = $this->atomic(function () use ($context, $contextId, $newOwnerId, $oldOwnerId, $oldArchived) { + $newArchived = $this->archiveService->prepareOwnershipTransfer( + $oldOwnerId, $newOwnerId, Application::NODE_TYPE_CONTEXT, $contextId, $oldArchived + ); + if ($newArchived !== $oldArchived) { + $context->setArchived($newArchived); + } $context = $this->contextMapper->update($context); $this->shareService->transferSharesForContext($contextId, $newOwnerId, $oldOwnerId); return $context; diff --git a/lib/Service/TableService.php b/lib/Service/TableService.php index afd8c7d6ef..2aacf045e3 100644 --- a/lib/Service/TableService.php +++ b/lib/Service/TableService.php @@ -60,6 +60,7 @@ public function __construct( protected IL10N $l, protected Defaults $themingDefaults, private ActivityManager $activityManager, + private ArchiveService $archiveService, ) { parent::__construct($logger, $userId, $permissionsService); } @@ -145,6 +146,14 @@ public function findAll(?string $userId = null, bool $skipTableEnhancement = fal } } + if ($userId !== '') { + try { + $this->archiveService->enrichTablesWithArchiveState(array_values($allTables), $userId); + } catch (OcpDbException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } + return array_values($allTables); } @@ -262,6 +271,72 @@ public function find(int $id, bool $skipTableEnhancement = false, ?string $userI } } + /** + * Fetch a single table and resolve the per-user `archived` flag. + * + * Use this instead of `find()` when the caller needs the correct per-user + * archive state (e.g. GET /tables/{id} API endpoints). + * + * @throws InternalError + * @throws NotFoundError + * @throws PermissionError + */ + public function getTableForUser(int $id, string $userId): Table { + $table = $this->find($id, false, $userId); + try { + $this->archiveService->enrichTablesWithArchiveState([$table], $userId); + } catch (OcpDbException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + return $table; + } + + /** + * Archive a table for the given user. + * + * If the user is the owner the entity flag is set and all per-user + * overrides are cleared; otherwise a personal override is stored. + * + * @throws InternalError + * @throws NotFoundError + * @throws PermissionError + */ + public function archiveTable(int $id, string $userId): Table { + $table = $this->find($id, true, $userId); + $isOwner = $table->getOwnership() === $userId; + try { + $this->archiveService->archiveForUser($userId, Application::NODE_TYPE_TABLE, $id, $isOwner); + } catch (OcpDbException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + return $this->getTableForUser($id, $userId); + } + + /** + * Unarchive a table for the given user. + * + * If the user is the owner the entity flag is cleared and all per-user + * overrides are reset; otherwise the personal override is removed or + * set to false if the owner has archived the table. + * + * @throws InternalError + * @throws NotFoundError + * @throws PermissionError + */ + public function unarchiveTable(int $id, string $userId): Table { + $table = $this->find($id, true, $userId); + $isOwner = $table->getOwnership() === $userId; + $entityArchived = $table->isArchived(); // entity-level flag, not per-user + try { + $this->archiveService->unarchiveForUser($userId, Application::NODE_TYPE_TABLE, $id, $isOwner, $entityArchived); + } catch (OcpDbException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + return $this->getTableForUser($id, $userId); + } + /** * @param string $title * @param string $template @@ -350,10 +425,18 @@ public function setOwner(int $id, string $newOwnerUserId, ?string $userId = null throw new PermissionError('PermissionError: can not change table owner with table id ' . $id); } + $oldOwnerId = $table->getOwnership(); + $oldArchived = $table->isArchived(); $table->setOwnership($newOwnerUserId); try { - $table = $this->atomic(function () use ($table, $id, $newOwnerUserId, $userId) { + $table = $this->atomic(function () use ($table, $id, $newOwnerUserId, $userId, $oldOwnerId, $oldArchived) { + $newArchived = $this->archiveService->prepareOwnershipTransfer( + $oldOwnerId, $newOwnerUserId, Application::NODE_TYPE_TABLE, $id, $oldArchived + ); + if ($newArchived !== $oldArchived) { + $table->setArchived($newArchived); + } $table = $this->mapper->update($table); $this->shareService->changeSenderForNode('table', $id, $newOwnerUserId, $userId); return $table; @@ -449,6 +532,13 @@ public function delete(int $id, ?string $userId = null): Table { throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + // remove per-user archive overrides for this table + try { + $this->archiveService->deleteNodeArchiveOverrides(Application::NODE_TYPE_TABLE, $id); + } catch (OcpDbException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + $event = new TableDeletedEvent(table: $item); $this->eventDispatcher->dispatchTyped($event); diff --git a/openapi.json b/openapi.json index 0f452fb652..82fdd52678 100644 --- a/openapi.json +++ b/openapi.json @@ -264,7 +264,8 @@ "iconName", "description", "owner", - "ownerType" + "ownerType", + "archived" ], "properties": { "id": { @@ -286,6 +287,9 @@ "ownerType": { "type": "integer", "format": "int64" + }, + "archived": { + "type": "boolean" } } }, @@ -7946,13 +7950,13 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/columns/{nodeType}/{nodeId}": { - "get": { - "operationId": "api_columns-index", - "summary": "[api v2] Get all columns for a table or a view", - "description": "Return an empty array if no columns were found", + "/ocs/v2.php/apps/tables/api/2/tables/{id}/archive": { + "post": { + "operationId": "api_tables-archive-table", + "summary": "[api v2] Archive a table for the requesting user", + "description": "Owners archive the table for all users (clears per-user overrides). Non-owners archive only for themselves.", "tags": [ - "api_columns" + "api_tables" ], "security": [ { @@ -7964,22 +7968,9 @@ ], "parameters": [ { - "name": "nodeType", - "in": "path", - "description": "Node type", - "required": true, - "schema": { - "type": "string", - "enum": [ - "table", - "view" - ] - } - }, - { - "name": "nodeId", + "name": "id", "in": "path", - "description": "Node ID", + "description": "Table ID", "required": true, "schema": { "type": "integer", @@ -7999,40 +7990,7 @@ ], "responses": { "200": { - "description": "View deleted", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Column" - } - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid input arguments", + "description": "Table returned with updated archived state", "content": { "application/json": { "schema": { @@ -8052,15 +8010,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } + "$ref": "#/components/schemas/Table" } } } @@ -8212,14 +8162,13 @@ } } } - } - }, - "/ocs/v2.php/apps/tables/api/2/columns/{id}": { - "get": { - "operationId": "api_columns-show", - "summary": "[api v2] Get a column object", + }, + "delete": { + "operationId": "api_tables-unarchive-table", + "summary": "[api v2] Unarchive a table for the requesting user", + "description": "Owners unarchive the table for all users (clears per-user overrides). Non-owners remove only their personal archive override.", "tags": [ - "api_columns" + "api_tables" ], "security": [ { @@ -8233,7 +8182,7 @@ { "name": "id", "in": "path", - "description": "Column ID", + "description": "Table ID", "required": true, "schema": { "type": "integer", @@ -8253,7 +8202,7 @@ ], "responses": { "200": { - "description": "Column returned", + "description": "Table returned with updated archived state", "content": { "application/json": { "schema": { @@ -8273,7 +8222,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/Column" + "$ref": "#/components/schemas/Table" } } } @@ -8427,11 +8376,11 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/columns/number": { - "post": { - "operationId": "api_columns-create-number-column", - "summary": "[api v2] Create new numbered column", - "description": "Specify a subtype to use any special numbered column", + "/ocs/v2.php/apps/tables/api/2/columns/{nodeType}/{nodeId}": { + "get": { + "operationId": "api_columns-index", + "summary": "[api v2] Get all columns for a table or a view", + "description": "Return an empty array if no columns were found", "tags": [ "api_columns" ], @@ -8443,114 +8392,30 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "baseNodeId", - "title" - ], - "properties": { - "baseNodeId": { - "type": "integer", - "format": "int64", - "description": "Context of the column creation" - }, - "title": { - "type": "string", - "description": "Title" - }, - "numberDefault": { - "type": "number", - "format": "double", - "nullable": true, - "description": "Default value for new rows" - }, - "numberDecimals": { - "type": "integer", - "format": "int64", - "nullable": true, - "description": "Decimals" - }, - "numberPrefix": { - "type": "string", - "nullable": true, - "description": "Prefix" - }, - "numberSuffix": { - "type": "string", - "nullable": true, - "description": "Suffix" - }, - "numberMin": { - "type": "number", - "format": "double", - "nullable": true, - "description": "Min" - }, - "numberMax": { - "type": "number", - "format": "double", - "nullable": true, - "description": "Max" - }, - "subtype": { - "type": "string", - "nullable": true, - "default": null, - "enum": [ - "progress", - "stars" - ], - "description": "Subtype for the new column" - }, - "description": { - "type": "string", - "nullable": true, - "default": null, - "description": "Description" - }, - "selectedViewIds": { - "type": "array", - "nullable": true, - "default": [], - "description": "View IDs where this columns should be added", - "items": { - "type": "integer", - "format": "int64" - } - }, - "mandatory": { - "type": "boolean", - "default": false, - "description": "Is mandatory" - }, - "baseNodeType": { - "type": "string", - "default": "table", - "enum": [ - "table", - "view" - ], - "description": "Context type of the column creation" - }, - "customSettings": { - "type": "object", - "default": {}, - "description": "Custom settings for the column", - "additionalProperties": { - "type": "object" - } - } - } - } - } - } - }, "parameters": [ + { + "name": "nodeType", + "in": "path", + "description": "Node type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "table", + "view" + ] + } + }, + { + "name": "nodeId", + "in": "path", + "description": "Node ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -8564,7 +8429,7 @@ ], "responses": { "200": { - "description": "Column created", + "description": "View deleted", "content": { "application/json": { "schema": { @@ -8584,7 +8449,10 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/Column" + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } } } } @@ -8593,8 +8461,8 @@ } } }, - "403": { - "description": "No permission", + "400": { + "description": "Invalid input arguments", "content": { "application/json": { "schema": { @@ -8631,8 +8499,8 @@ } } }, - "500": { - "description": "", + "403": { + "description": "No permissions", "content": { "application/json": { "schema": { @@ -8666,16 +8534,11 @@ } } } - }, - "text/plain": { - "schema": { - "type": "string" - } } } }, - "404": { - "description": "Not found", + "500": { + "description": "", "content": { "application/json": { "schema": { @@ -8712,8 +8575,8 @@ } } }, - "401": { - "description": "Current user is not logged in", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -8732,7 +8595,45 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} } } } @@ -8743,11 +8644,10 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/columns/text": { - "post": { - "operationId": "api_columns-create-text-column", - "summary": "[api v2] Create new text column", - "description": "Specify a subtype to use any special text column", + "/ocs/v2.php/apps/tables/api/2/columns/{id}": { + "get": { + "operationId": "api_columns-show", + "summary": "[api v2] Get a column object", "tags": [ "api_columns" ], @@ -8759,102 +8659,17 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "baseNodeId", - "title" - ], - "properties": { - "baseNodeId": { - "type": "integer", - "format": "int64", - "description": "Context of the column creation" - }, - "title": { - "type": "string", - "description": "Title" - }, - "textDefault": { - "type": "string", - "nullable": true, - "description": "Default" - }, - "textAllowedPattern": { - "type": "string", - "nullable": true, - "description": "Allowed regex pattern" - }, - "textMaxLength": { - "type": "integer", - "format": "int64", - "nullable": true, - "description": "Max raw text length" - }, - "textUnique": { - "type": "boolean", - "nullable": true, - "default": false, - "description": "Whether the text value must be unique, if column is a text" - }, - "subtype": { - "type": "string", - "nullable": true, - "default": null, - "enum": [ - "progress", - "stars" - ], - "description": "Subtype for the new column" - }, - "description": { - "type": "string", - "nullable": true, - "default": null, - "description": "Description" - }, - "selectedViewIds": { - "type": "array", - "nullable": true, - "default": [], - "description": "View IDs where this columns should be added", - "items": { - "type": "integer", - "format": "int64" - } - }, - "mandatory": { - "type": "boolean", - "default": false, - "description": "Is mandatory" - }, - "baseNodeType": { - "type": "string", - "default": "table", - "enum": [ - "table", - "view" - ], - "description": "Context type of the column creation" - }, - "customSettings": { - "type": "object", - "default": {}, - "description": "Custom settings for the column", - "additionalProperties": { - "type": "object" - } - } - } - } - } - } - }, "parameters": [ + { + "name": "id", + "in": "path", + "description": "Column ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -8868,7 +8683,7 @@ ], "responses": { "200": { - "description": "Column created", + "description": "Column returned", "content": { "application/json": { "schema": { @@ -8898,7 +8713,7 @@ } }, "403": { - "description": "No permission", + "description": "No permissions", "content": { "application/json": { "schema": { @@ -8970,11 +8785,6 @@ } } } - }, - "text/plain": { - "schema": { - "type": "string" - } } } }, @@ -9047,11 +8857,11 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/columns/selection": { + "/ocs/v2.php/apps/tables/api/2/columns/number": { "post": { - "operationId": "api_columns-create-selection-column", - "summary": "[api v2] Create new selection column", - "description": "Specify a subtype to use any special selection column", + "operationId": "api_columns-create-number-column", + "summary": "[api v2] Create new numbered column", + "description": "Specify a subtype to use any special numbered column", "tags": [ "api_columns" ], @@ -9071,8 +8881,7 @@ "type": "object", "required": [ "baseNodeId", - "title", - "selectionOptions" + "title" ], "properties": { "baseNodeId": { @@ -9084,14 +8893,39 @@ "type": "string", "description": "Title" }, - "selectionOptions": { + "numberDefault": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Default value for new rows" + }, + "numberDecimals": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Decimals" + }, + "numberPrefix": { "type": "string", - "description": "Json array{id: int, label: string} with options that can be selected, eg [{\"id\": 1, \"label\": \"first\"},{\"id\": 2, \"label\": \"second\"}]" + "nullable": true, + "description": "Prefix" }, - "selectionDefault": { + "numberSuffix": { "type": "string", "nullable": true, - "description": "Json int|list for default selected option(s), eg 5 or [\"1\", \"8\"]" + "description": "Suffix" + }, + "numberMin": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Min" + }, + "numberMax": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Max" }, "subtype": { "type": "string", @@ -9339,11 +9173,11 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/columns/datetime": { + "/ocs/v2.php/apps/tables/api/2/columns/text": { "post": { - "operationId": "api_columns-create-datetime-column", - "summary": "[api v2] Create new datetime column", - "description": "Specify a subtype to use any special datetime column", + "operationId": "api_columns-create-text-column", + "summary": "[api v2] Create new text column", + "description": "Specify a subtype to use any special text column", "tags": [ "api_columns" ], @@ -9375,14 +9209,27 @@ "type": "string", "description": "Title" }, - "datetimeDefault": { + "textDefault": { "type": "string", "nullable": true, - "enum": [ - "today", - "now" - ], - "description": "For a subtype 'date' you can set 'today'. For a main type or subtype 'time' you can set to 'now'." + "description": "Default" + }, + "textAllowedPattern": { + "type": "string", + "nullable": true, + "description": "Allowed regex pattern" + }, + "textMaxLength": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Max raw text length" + }, + "textUnique": { + "type": "boolean", + "nullable": true, + "default": false, + "description": "Whether the text value must be unique, if column is a text" }, "subtype": { "type": "string", @@ -9630,10 +9477,11 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/columns/usergroup": { + "/ocs/v2.php/apps/tables/api/2/columns/selection": { "post": { - "operationId": "api_columns-create-usergroup-column", - "summary": "[api v2] Create new usergroup column", + "operationId": "api_columns-create-selection-column", + "summary": "[api v2] Create new selection column", + "description": "Specify a subtype to use any special selection column", "tags": [ "api_columns" ], @@ -9653,7 +9501,8 @@ "type": "object", "required": [ "baseNodeId", - "title" + "title", + "selectionOptions" ], "properties": { "baseNodeId": { @@ -9665,35 +9514,24 @@ "type": "string", "description": "Title" }, - "usergroupDefault": { + "selectionOptions": { "type": "string", - "nullable": true, - "description": "Json array{id: string, type: int}, eg [{\"id\": \"admin\", \"type\": 0}, {\"id\": \"user1\", \"type\": 0}]" + "description": "Json array{id: int, label: string} with options that can be selected, eg [{\"id\": 1, \"label\": \"first\"},{\"id\": 2, \"label\": \"second\"}]" }, - "usergroupMultipleItems": { - "type": "boolean", - "default": null, - "description": "Whether you can select multiple users or/and groups" - }, - "usergroupSelectUsers": { - "type": "boolean", - "default": null, - "description": "Whether you can select users" - }, - "usergroupSelectGroups": { - "type": "boolean", - "default": null, - "description": "Whether you can select groups" - }, - "usergroupSelectTeams": { - "type": "boolean", - "default": null, - "description": "Whether you can select teams" + "selectionDefault": { + "type": "string", + "nullable": true, + "description": "Json int|list for default selected option(s), eg 5 or [\"1\", \"8\"]" }, - "showUserStatus": { - "type": "boolean", + "subtype": { + "type": "string", + "nullable": true, "default": null, - "description": "Whether to show the user's status" + "enum": [ + "progress", + "stars" + ], + "description": "Subtype for the new column" }, "description": { "type": "string", @@ -9931,12 +9769,13 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/favorites/{nodeType}/{nodeId}": { + "/ocs/v2.php/apps/tables/api/2/columns/datetime": { "post": { - "operationId": "api_favorite-create", - "summary": "[api v2] Add a node (table or view) to user favorites", + "operationId": "api_columns-create-datetime-column", + "summary": "[api v2] Create new datetime column", + "description": "Specify a subtype to use any special datetime column", "tags": [ - "api_favorite" + "api_columns" ], "security": [ { @@ -9946,27 +9785,89 @@ "basic_auth": [] } ], - "parameters": [ - { - "name": "nodeType", - "in": "path", - "description": "any Application::NODE_TYPE_* constant", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "nodeId", - "in": "path", - "description": "identifier of the node", - "required": true, - "schema": { - "type": "integer", - "format": "int64" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "baseNodeId", + "title" + ], + "properties": { + "baseNodeId": { + "type": "integer", + "format": "int64", + "description": "Context of the column creation" + }, + "title": { + "type": "string", + "description": "Title" + }, + "datetimeDefault": { + "type": "string", + "nullable": true, + "enum": [ + "today", + "now" + ], + "description": "For a subtype 'date' you can set 'today'. For a main type or subtype 'time' you can set to 'now'." + }, + "subtype": { + "type": "string", + "nullable": true, + "default": null, + "enum": [ + "progress", + "stars" + ], + "description": "Subtype for the new column" + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "Description" + }, + "selectedViewIds": { + "type": "array", + "nullable": true, + "default": [], + "description": "View IDs where this columns should be added", + "items": { + "type": "integer", + "format": "int64" + } + }, + "mandatory": { + "type": "boolean", + "default": false, + "description": "Is mandatory" + }, + "baseNodeType": { + "type": "string", + "default": "table", + "enum": [ + "table", + "view" + ], + "description": "Context type of the column creation" + }, + "customSettings": { + "type": "object", + "default": {}, + "description": "Custom settings for the column", + "additionalProperties": { + "type": "object" + } + } + } + } } - }, + } + }, + "parameters": [ { "name": "OCS-APIRequest", "in": "header", @@ -9980,7 +9881,7 @@ ], "responses": { "200": { - "description": "Tables returned", + "description": "Column created", "content": { "application/json": { "schema": { @@ -10000,7 +9901,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/Column" } } } @@ -10010,7 +9911,7 @@ } }, "403": { - "description": "No permissions", + "description": "No permission", "content": { "application/json": { "schema": { @@ -10082,6 +9983,11 @@ } } } + }, + "text/plain": { + "schema": { + "type": "string" + } } } }, @@ -10152,12 +10058,14 @@ } } } - }, - "delete": { - "operationId": "api_favorite-destroy", - "summary": "[api v2] Remove a node (table or view) to from favorites", + } + }, + "/ocs/v2.php/apps/tables/api/2/columns/usergroup": { + "post": { + "operationId": "api_columns-create-usergroup-column", + "summary": "[api v2] Create new usergroup column", "tags": [ - "api_favorite" + "api_columns" ], "security": [ { @@ -10167,27 +10075,936 @@ "basic_auth": [] } ], - "parameters": [ - { - "name": "nodeType", - "in": "path", - "description": "any Application::NODE_TYPE_* constant", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "nodeId", - "in": "path", - "description": "identifier of the node", - "required": true, - "schema": { - "type": "integer", - "format": "int64" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "baseNodeId", + "title" + ], + "properties": { + "baseNodeId": { + "type": "integer", + "format": "int64", + "description": "Context of the column creation" + }, + "title": { + "type": "string", + "description": "Title" + }, + "usergroupDefault": { + "type": "string", + "nullable": true, + "description": "Json array{id: string, type: int}, eg [{\"id\": \"admin\", \"type\": 0}, {\"id\": \"user1\", \"type\": 0}]" + }, + "usergroupMultipleItems": { + "type": "boolean", + "default": null, + "description": "Whether you can select multiple users or/and groups" + }, + "usergroupSelectUsers": { + "type": "boolean", + "default": null, + "description": "Whether you can select users" + }, + "usergroupSelectGroups": { + "type": "boolean", + "default": null, + "description": "Whether you can select groups" + }, + "usergroupSelectTeams": { + "type": "boolean", + "default": null, + "description": "Whether you can select teams" + }, + "showUserStatus": { + "type": "boolean", + "default": null, + "description": "Whether to show the user's status" + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "Description" + }, + "selectedViewIds": { + "type": "array", + "nullable": true, + "default": [], + "description": "View IDs where this columns should be added", + "items": { + "type": "integer", + "format": "int64" + } + }, + "mandatory": { + "type": "boolean", + "default": false, + "description": "Is mandatory" + }, + "baseNodeType": { + "type": "string", + "default": "table", + "enum": [ + "table", + "view" + ], + "description": "Context type of the column creation" + }, + "customSettings": { + "type": "object", + "default": {}, + "description": "Custom settings for the column", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Column created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Column" + } + } + } + } + } + } + } + }, + "403": { + "description": "No permission", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/favorites/{nodeType}/{nodeId}": { + "post": { + "operationId": "api_favorite-create", + "summary": "[api v2] Add a node (table or view) to user favorites", + "tags": [ + "api_favorite" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "nodeType", + "in": "path", + "description": "any Application::NODE_TYPE_* constant", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "nodeId", + "in": "path", + "description": "identifier of the node", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Tables returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object" + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "api_favorite-destroy", + "summary": "[api v2] Remove a node (table or view) to from favorites", + "tags": [ + "api_favorite" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "nodeType", + "in": "path", + "description": "any Application::NODE_TYPE_* constant", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "nodeId", + "in": "path", + "description": "identifier of the node", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Deleted table returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object" + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/contexts": { + "get": { + "operationId": "context-index", + "summary": "[api v2] Get all contexts available to the requesting person", + "description": "Return an empty array if no contexts were found", + "tags": [ + "context" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "reporting in available contexts", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Context" + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "context-create", + "summary": "[api v2] Create a new context and return it", + "tags": [ + "context" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + "iconName" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the context" + }, + "iconName": { + "type": "string", + "description": "Material design icon name of the context" + }, + "description": { + "type": "string", + "default": "", + "description": "Descriptive text of the context" + }, + "nodes": { + "type": "array", + "default": [], + "description": "optional nodes to be connected to this context", + "items": { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "type": { + "type": "integer", + "format": "int64" + }, + "permissions": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } } - }, + } + }, + "parameters": [ { "name": "OCS-APIRequest", "in": "header", @@ -10201,7 +11018,7 @@ ], "responses": { "200": { - "description": "Deleted table returned", + "description": "returning the full context information", "content": { "application/json": { "schema": { @@ -10221,7 +11038,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/Context" } } } @@ -10230,8 +11047,8 @@ } } }, - "403": { - "description": "No permissions", + "500": { + "description": "", "content": { "application/json": { "schema": { @@ -10268,8 +11085,8 @@ } } }, - "500": { - "description": "", + "400": { + "description": "invalid parameters were supplied", "content": { "application/json": { "schema": { @@ -10306,8 +11123,8 @@ } } }, - "404": { - "description": "Not found", + "403": { + "description": "lacking permissions on a resource", "content": { "application/json": { "schema": { @@ -10375,11 +11192,10 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/contexts": { + "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}": { "get": { - "operationId": "context-index", - "summary": "[api v2] Get all contexts available to the requesting person", - "description": "Return an empty array if no contexts were found", + "operationId": "context-show", + "summary": "[api v2] Get information about the requests context", "tags": [ "context" ], @@ -10392,6 +11208,16 @@ } ], "parameters": [ + { + "name": "contextId", + "in": "path", + "description": "ID of the context", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10405,7 +11231,7 @@ ], "responses": { "200": { - "description": "reporting in available contexts", + "description": "returning the full context information", "content": { "application/json": { "schema": { @@ -10425,10 +11251,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Context" - } + "$ref": "#/components/schemas/Context" } } } @@ -10475,6 +11298,44 @@ } } }, + "404": { + "description": "context not found or not available anymore", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { @@ -10505,9 +11366,9 @@ } } }, - "post": { - "operationId": "context-create", - "summary": "[api v2] Create a new context and return it", + "put": { + "operationId": "context-update", + "summary": "[api v2] Update an existing context and return it", "tags": [ "context" ], @@ -10520,52 +11381,53 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "name", - "iconName" - ], "properties": { "name": { "type": "string", - "description": "Name of the context" + "nullable": true, + "description": "provide this parameter to set a new name" }, "iconName": { "type": "string", - "description": "Material design icon name of the context" + "nullable": true, + "description": "provide this parameter to set a new icon" }, "description": { "type": "string", - "default": "", - "description": "Descriptive text of the context" + "nullable": true, + "description": "provide this parameter to set a new description" }, "nodes": { - "type": "array", - "default": [], - "description": "optional nodes to be connected to this context", - "items": { - "type": "object", - "required": [ - "id", - "type" - ], - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "type": { - "type": "integer", - "format": "int64" - }, - "permissions": { - "type": "integer", - "format": "int64" - } + "type": "object", + "nullable": true, + "description": "provide this parameter to set a new list of nodes.", + "required": [ + "id", + "type", + "permissions", + "order" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "type": { + "type": "integer", + "format": "int64" + }, + "permissions": { + "type": "integer", + "format": "int64" + }, + "order": { + "type": "integer", + "format": "int64" } } } @@ -10575,6 +11437,16 @@ } }, "parameters": [ + { + "name": "contextId", + "in": "path", + "description": "ID of the context", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10655,8 +11527,8 @@ } } }, - "400": { - "description": "invalid parameters were supplied", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -10694,7 +11566,45 @@ } }, "403": { - "description": "lacking permissions on a resource", + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "bad request", "content": { "application/json": { "schema": { @@ -10760,12 +11670,10 @@ } } } - } - }, - "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}": { - "get": { - "operationId": "context-show", - "summary": "[api v2] Get information about the requests context", + }, + "delete": { + "operationId": "context-destroy", + "summary": "[api v2] Delete an existing context and return it", "tags": [ "context" ], @@ -10830,8 +11738,46 @@ } } }, - "500": { - "description": "", + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -10868,8 +11814,8 @@ } } }, - "404": { - "description": "context not found or not available anymore", + "403": { + "description": "No permissions", "content": { "application/json": { "schema": { @@ -10935,10 +11881,12 @@ } } } - }, + } + }, + "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/transfer": { "put": { - "operationId": "context-update", - "summary": "[api v2] Update an existing context and return it", + "operationId": "context-transfer", + "summary": "[api v2] Transfer the ownership of a context and return it", "tags": [ "context" ], @@ -10951,55 +11899,26 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", + "required": [ + "newOwnerId" + ], "properties": { - "name": { - "type": "string", - "nullable": true, - "description": "provide this parameter to set a new name" - }, - "iconName": { - "type": "string", - "nullable": true, - "description": "provide this parameter to set a new icon" - }, - "description": { + "newOwnerId": { "type": "string", - "nullable": true, - "description": "provide this parameter to set a new description" + "description": "ID of the new owner" }, - "nodes": { - "type": "object", - "nullable": true, - "description": "provide this parameter to set a new list of nodes.", - "required": [ - "id", - "type", - "permissions", - "order" - ], - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "type": { - "type": "integer", - "format": "int64" - }, - "permissions": { - "type": "integer", - "format": "int64" - }, - "order": { - "type": "integer", - "format": "int64" - } - } + "newOwnerType": { + "type": "integer", + "format": "int64", + "default": 0, + "description": "any Application::OWNER_TYPE_* constant", + "minimum": 0, + "maximum": 0 } } } @@ -11014,7 +11933,8 @@ "required": true, "schema": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 } }, { @@ -11030,7 +11950,7 @@ ], "responses": { "200": { - "description": "returning the full context information", + "description": "Ownership transferred", "content": { "application/json": { "schema": { @@ -11097,8 +12017,8 @@ } } }, - "404": { - "description": "Not found", + "403": { + "description": "No permissions", "content": { "application/json": { "schema": { @@ -11135,8 +12055,8 @@ } } }, - "403": { - "description": "No permissions", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -11174,7 +12094,7 @@ } }, "400": { - "description": "bad request", + "description": "Invalid request", "content": { "application/json": { "schema": { @@ -11240,10 +12160,13 @@ } } } - }, - "delete": { - "operationId": "context-destroy", - "summary": "[api v2] Delete an existing context and return it", + } + }, + "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/archive": { + "post": { + "operationId": "context-archive-context", + "summary": "[api v2] Archive a context for the requesting user", + "description": "Owners archive the context for all users (clears per-user overrides). Non-owners archive only for themselves.", "tags": [ "context" ], @@ -11279,7 +12202,7 @@ ], "responses": { "200": { - "description": "returning the full context information", + "description": "Context returned with updated archived state", "content": { "application/json": { "schema": { @@ -11347,45 +12270,7 @@ } }, "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } - } - } - } - } - } - } - } - }, - "403": { - "description": "No permissions", + "description": "Context not found or not available", "content": { "application/json": { "schema": { @@ -11451,12 +12336,11 @@ } } } - } - }, - "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/transfer": { - "put": { - "operationId": "context-transfer", - "summary": "[api v2] Transfer the ownership of a context and return it", + }, + "delete": { + "operationId": "context-unarchive-context", + "summary": "[api v2] Unarchive a context for the requesting user", + "description": "Owners unarchive the context for all users (clears per-user overrides). Non-owners remove only their personal archive override.", "tags": [ "context" ], @@ -11468,33 +12352,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "newOwnerId" - ], - "properties": { - "newOwnerId": { - "type": "string", - "description": "ID of the new owner" - }, - "newOwnerType": { - "type": "integer", - "format": "int64", - "default": 0, - "description": "any Application::OWNER_TYPE_* constant", - "minimum": 0, - "maximum": 0 - } - } - } - } - } - }, "parameters": [ { "name": "contextId", @@ -11503,8 +12360,7 @@ "required": true, "schema": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" } }, { @@ -11520,7 +12376,7 @@ ], "responses": { "200": { - "description": "Ownership transferred", + "description": "Context returned with updated archived state", "content": { "application/json": { "schema": { @@ -11587,84 +12443,8 @@ } } }, - "403": { - "description": "No permissions", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } - } - } - } - } - } - } - } - }, "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid request", + "description": "Context not found or not available", "content": { "application/json": { "schema": { diff --git a/playwright/e2e/tables-archive.spec.ts b/playwright/e2e/tables-archive.spec.ts index 7c7ff73f53..e27f60e63f 100644 --- a/playwright/e2e/tables-archive.spec.ts +++ b/playwright/e2e/tables-archive.spec.ts @@ -3,67 +3,206 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { type Page } from '@playwright/test' import { test, expect } from '../support/fixtures' import { ocsRequest } from '../support/api' +import { createContext, ensureNavigationOpen } from '../support/commands' -test.describe('Archive tables/views', () => { +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- - test('can archive tables', async ({ userPage: { page } }) => { +async function openNavItemMenu(page: Page, itemLocator: ReturnType) { + await ensureNavigationOpen(page) + await itemLocator.waitFor({ state: 'visible', timeout: 10000 }) + await itemLocator.scrollIntoViewIfNeeded() + await itemLocator.hover() + const menuButton = itemLocator.getByRole('button', { name: /Actions|Open menu/i }).first() + await menuButton.waitFor({ state: 'visible', timeout: 5000 }) + await menuButton.click({ force: true }) +} + +// --------------------------------------------------------------------------- +// Table archive +// --------------------------------------------------------------------------- + +test.describe('Archive tables', () => { + + test('can archive a table via the navigation menu', async ({ userPage: { page } }) => { await page.goto('/index.php/apps/tables') const tutorialTable = page.locator('[data-cy="navigationTableItem"]').first() - await expect(tutorialTable).toContainText('Welcome to Nextcloud Tables!') - await tutorialTable.hover() - const menuButton = tutorialTable.locator('[aria-haspopup="menu"]').first() - await menuButton.waitFor({ state: 'visible' }) - await menuButton.click({ force: true }) + + await openNavItemMenu(page, tutorialTable) await page.getByText('Archive table').waitFor({ state: 'visible' }) - const archiveTableReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/api/2/tables/') && r.request().method() === 'PUT') + const archiveReqPromise = page.waitForResponse( + r => r.url().includes('/apps/tables/api/2/tables/') && r.url().endsWith('/archive') && r.request().method() === 'POST', + ) await page.getByText('Archive table').click({ force: true }) - const archiveRequest = await archiveTableReqPromise + const archiveRequest = await archiveReqPromise expect(archiveRequest.status()).toBe(200) - const body = await archiveRequest.json() - expect(body.ocs.data.archived).toBe(true) - await expect(tutorialTable.locator('..').locator('..')).toContainText('Archived tables') + // Table must be gone from the main list and the archived section must appear + await expect(tutorialTable).not.toBeVisible() + await expect(page.getByText('Archived tables')).toBeVisible({ timeout: 10000 }) }) - test('can unarchive tables', async ({ userPage: { page, user } }) => { + test('can unarchive a table via the archived section menu', async ({ userPage: { page, user } }) => { test.setTimeout(60000) await page.goto('/index.php/apps/tables') const tutorialTable = page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'Welcome to Nextcloud Tables!' }).first() const tutorialHref = await tutorialTable.locator('a').first().getAttribute('href') const tableId = tutorialHref?.match(/\/table\/(\d+)/)?.[1] - - await expect(tutorialTable).toContainText('Welcome to Nextcloud Tables!') expect(tableId).toBeTruthy() - // Archive it first so we can unarchive - await tutorialTable.hover() - const menuButtonArchive = tutorialTable.locator('[aria-haspopup="menu"]').first() - await menuButtonArchive.waitFor({ state: 'visible' }) - await menuButtonArchive.click({ force: true }) + // Archive via API so we start from a clean known state + await ocsRequest(page.request, user, { + method: 'POST', + url: `/ocs/v2.php/apps/tables/api/2/tables/${tableId}/archive?format=json`, + }) - await page.getByText('Archive table').waitFor({ state: 'visible' }) - const archiveReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/api/2/tables/') && r.request().method() === 'PUT') - await page.getByText('Archive table').click({ force: true }) - await archiveReqPromise - - // Wait for navigation to reflect the archived state. - const archivedTablesToggle = page.getByRole('link', { name: 'Archived tables' }) - await expect(archivedTablesToggle).toBeVisible({ timeout: 10000 }) - const unarchiveResponse = await ocsRequest(page.request, user, { - method: 'PUT', - url: `/ocs/v2.php/apps/tables/api/2/tables/${tableId}?format=json`, - data: { archived: false }, + await page.reload({ waitUntil: 'domcontentloaded' }) + await ensureNavigationOpen(page) + + // Expand the archived section by clicking its collapse toggle (.icon-collapse is NcAppNavigationItem's toggle class) + const archivedSection = page.locator('li').filter({ hasText: 'Archived tables' }).first() + await archivedSection.waitFor({ state: 'visible', timeout: 10000 }) + await archivedSection.locator('.icon-collapse').first().click() + + // Find the archived table and unarchive it + const archivedTable = page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'Welcome to Nextcloud Tables!' }).first() + await archivedTable.waitFor({ state: 'visible', timeout: 10000 }) + await openNavItemMenu(page, archivedTable) + + const unarchiveReqPromise = page.waitForResponse( + r => r.url().includes('/apps/tables/api/2/tables/') && r.url().endsWith('/archive') && r.request().method() === 'DELETE', + ) + await page.getByText('Unarchive table').click({ force: true }) + + const unarchiveRequest = await unarchiveReqPromise + expect(unarchiveRequest.status()).toBe(200) + + // Table reappears in the main list + await expect( + page.locator('[data-cy="navigationTableItem"] a[title="Welcome to Nextcloud Tables!"]').first(), + ).toBeVisible({ timeout: 10000 }) + }) +}) + +// --------------------------------------------------------------------------- +// Context (application) archive +// --------------------------------------------------------------------------- + +test.describe('Archive applications', () => { + + test('can archive an application via the navigation menu', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + const contextTitle = 'archive-test-app' + await createContext(page, contextTitle) + + const contextItem = page.locator('[data-cy="navigationContextItem"]').filter({ hasText: contextTitle }).first() + await openNavItemMenu(page, contextItem) + + await page.getByText('Archive application').waitFor({ state: 'visible' }) + const archiveReqPromise = page.waitForResponse( + r => r.url().includes('/apps/tables/api/2/contexts/') && r.url().endsWith('/archive') && r.request().method() === 'POST', + ) + await page.getByText('Archive application').click({ force: true }) + + const archiveRequest = await archiveReqPromise + expect(archiveRequest.status()).toBe(200) + + // "Archived applications" section must appear + await expect( + page.locator('li').filter({ hasText: 'Archived applications' }).first(), + ).toBeVisible({ timeout: 10000 }) + // Item must appear exactly once (inside the archived section) — if still in the active list the count would be 2 + await expect( + page.locator('[data-cy="navigationContextItem"]').filter({ hasText: contextTitle }), + ).toHaveCount(1, { timeout: 5000 }) + }) + + test('can unarchive an application via the archived section', async ({ userPage: { page, user } }) => { + await page.goto('/index.php/apps/tables') + const contextTitle = 'unarchive-test-app' + await createContext(page, contextTitle) + + // Read context ID from the navigation link + const contextItem = page.locator('[data-cy="navigationContextItem"]').filter({ hasText: contextTitle }).first() + const contextHref = await contextItem.locator('a').first().getAttribute('href') + const contextId = contextHref?.match(/\/application\/(\d+)/)?.[1] + expect(contextId).toBeTruthy() + + // Archive via API for a clean starting state + await ocsRequest(page.request, user, { + method: 'POST', + url: `/ocs/v2.php/apps/tables/api/2/contexts/${contextId}/archive?format=json`, }) - expect(unarchiveResponse.ok()).toBeTruthy() + await page.reload({ waitUntil: 'domcontentloaded' }) + await ensureNavigationOpen(page) + + // Expand the archived applications section by clicking its collapse toggle + const archivedSection = page.locator('li').filter({ hasText: 'Archived applications' }).first() + await archivedSection.waitFor({ state: 'visible', timeout: 10000 }) + await archivedSection.locator('.icon-collapse').first().click() + + // Find the archived context item and unarchive it + const archivedContextItem = page.locator('[data-cy="navigationContextItem"]').filter({ hasText: contextTitle }).first() + await archivedContextItem.waitFor({ state: 'visible', timeout: 10000 }) + await openNavItemMenu(page, archivedContextItem) + + const unarchiveReqPromise = page.waitForResponse( + r => r.url().includes('/apps/tables/api/2/contexts/') && r.url().endsWith('/archive') && r.request().method() === 'DELETE', + ) + await page.getByText('Unarchive application').click({ force: true }) + + const unarchiveRequest = await unarchiveReqPromise + expect(unarchiveRequest.status()).toBe(200) + + // Application reappears in the main (active) list + const activeContextItem = page.locator('[data-cy="navigationContextItem"]').filter({ hasText: contextTitle }).first() + await expect(activeContextItem).toBeVisible({ timeout: 10000 }) + await expect(page.locator('li').filter({ hasText: 'Archived applications' })).toHaveCount(0) + }) + + test('archived application appears in the sidebar archived section and not in the main list', async ({ userPage: { page, user } }) => { await page.goto('/index.php/apps/tables') - await expect(page.locator('[data-cy="navigationTableItem"] a[title="Welcome to Nextcloud Tables!"]').first()).toBeVisible({ timeout: 10000 }) + const contextTitle = 'sidebar-section-test-app' + await createContext(page, contextTitle) + + const contextItem = page.locator('[data-cy="navigationContextItem"]').filter({ hasText: contextTitle }).first() + const contextHref = await contextItem.locator('a').first().getAttribute('href') + const contextId = contextHref?.match(/\/application\/(\d+)/)?.[1] + expect(contextId).toBeTruthy() + + await ocsRequest(page.request, user, { + method: 'POST', + url: `/ocs/v2.php/apps/tables/api/2/contexts/${contextId}/archive?format=json`, + }) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await ensureNavigationOpen(page) + + // The "Archived applications" collapsible section must be present + const archivedSection = page.locator('li').filter({ hasText: 'Archived applications' }).first() + await expect(archivedSection).toBeVisible({ timeout: 10000 }) + + // Expand it by clicking the collapse toggle + await archivedSection.locator('.icon-collapse').first().click() + const archivedItem = page.locator('[data-cy="navigationContextItem"]').filter({ hasText: contextTitle }).first() + await expect(archivedItem).toBeVisible({ timeout: 10000 }) + + // The item must NOT appear in the active Applications section above + // (count inside the collapsed archived NcAppNavigationItem should be exactly 1, + // and the active list should have 0 matching items — verified by checking + // there's no second occurrence) + await expect( + page.locator('[data-cy="navigationContextItem"]').filter({ hasText: contextTitle }), + ).toHaveCount(1) }) }) diff --git a/src/modules/navigation/partials/NavigationContextItem.vue b/src/modules/navigation/partials/NavigationContextItem.vue index 3ee84aa111..8c7423edaa 100644 --- a/src/modules/navigation/partials/NavigationContextItem.vue +++ b/src/modules/navigation/partials/NavigationContextItem.vue @@ -35,6 +35,22 @@ {{ t('tables', 'Delete application') }} + + + + {{ t('tables', 'Archive application') }} + + + + + + {{ t('tables', 'Unarchive application') }} + + {{ t('tables', 'Show in app list') }} @@ -50,6 +66,8 @@ import { emit } from '@nextcloud/event-bus' import PlaylistEdit from 'vue-material-design-icons/PlaylistEdit.vue' import FileSwapOutline from 'vue-material-design-icons/FileSwapOutline.vue' import DeleteOutline from 'vue-material-design-icons/TrashCanOutline.vue' +import ArchiveArrowDown from 'vue-material-design-icons/ArchiveArrowDown.vue' +import ArchiveArrowUpOutline from 'vue-material-design-icons/ArchiveArrowUpOutline.vue' import permissionsMixin from '../../../shared/components/ncTable/mixins/permissionsMixin.js' import svgHelper from '../../../shared/components/ncIconPicker/mixins/svgHelper.js' import { NAV_ENTRY_MODE } from '../../../shared/constants.ts' @@ -64,6 +82,8 @@ export default { FileSwapOutline, TableIcon, DeleteOutline, + ArchiveArrowDown, + ArchiveArrowUpOutline, NcIconSvgWrapper, NcAppNavigationItem, NcActionButton, @@ -99,7 +119,7 @@ export default { }, methods: { - ...mapActions(useTablesStore, ['updateDisplayMode']), + ...mapActions(useTablesStore, ['updateDisplayMode', 'archiveContext', 'unarchiveContext']), emit, async editContext() { emit('tables:context:edit', this.context.id) @@ -118,6 +138,13 @@ export default { } return false }, + async toggleArchiveContext(archived) { + if (archived) { + await this.archiveContext({ id: this.context.id }) + } else { + await this.unarchiveContext({ id: this.context.id }) + } + }, async changeDisplayMode() { const value = !this.showInNavigation const displayMode = value ? NAV_ENTRY_MODE.NAV_ENTRY_MODE_ALL : NAV_ENTRY_MODE.NAV_ENTRY_MODE_HIDDEN diff --git a/src/modules/navigation/partials/NavigationTableItem.vue b/src/modules/navigation/partials/NavigationTableItem.vue index c09498ee30..f08e35b882 100644 --- a/src/modules/navigation/partials/NavigationTableItem.vue +++ b/src/modules/navigation/partials/NavigationTableItem.vue @@ -102,7 +102,7 @@ - {{ t('tables', 'Archive table') }}