From 018fa3d5a9d8207bcea4e953510ce5bd3e9d90c4 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 13:11:19 +0200 Subject: [PATCH 01/17] feat(archive): add per-user archive table and contexts.archived column * add secondary index on (node_type, node_id) for deleteAllForNode * document why archived boolean columns are not indexed AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../Version1000Date20260411000000.php | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 lib/Migration/Version1000Date20260411000000.php 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; + } +} From 61154628d0262977b63aa524bcd674ce401750cd Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 13:16:56 +0200 Subject: [PATCH 02/17] feat(archive): add archived field to Context entity and mapper AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/Db/Context.php | 7 ++++++- lib/Db/ContextMapper.php | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Db/Context.php b/lib/Db/Context.php index 671e4be10e..e297b553e1 100644 --- a/lib/Db/Context.php +++ b/lib/Db/Context.php @@ -20,6 +20,8 @@ * @method setOwnerId(string $value): void * @method getOwnerType(): int * @method setOwnerType(int $value): void + * @method isArchived(): bool + * @method setArchived(bool $value): void * * @method getSharing(): array * @method setSharing(array $value): void @@ -34,6 +36,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 +48,7 @@ class Context extends EntitySuper implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); $this->addType('owner_type', 'integer'); + $this->addType('archived', 'boolean'); } public function jsonSerialize(): array { @@ -55,7 +59,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) { From cb4248115bacf0c6bcb47ab1058c24cdd2bf5f91 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 13:22:46 +0200 Subject: [PATCH 03/17] feat(archive): add UserArchive entity and mapper AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/AppInfo/Application.php | 1 + lib/Db/UserArchive.php | 32 ++++++++ lib/Db/UserArchiveMapper.php | 141 +++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 lib/Db/UserArchive.php create mode 100644 lib/Db/UserArchiveMapper.php 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/Db/UserArchive.php b/lib/Db/UserArchive.php new file mode 100644 index 0000000000..b088192a97 --- /dev/null +++ b/lib/Db/UserArchive.php @@ -0,0 +1,32 @@ +addType('id', 'integer'); + $this->addType('node_type', 'integer'); + $this->addType('node_id', 'integer'); + $this->addType('archived', 'boolean'); + } +} 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(); + } +} From 0b08eaa1ed1ebd9bcbe0c315b468f994f24726e2 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 13:26:48 +0200 Subject: [PATCH 04/17] feat(archive): add ArchiveService with owner and per-user logic AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/Service/ArchiveService.php | 158 +++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 lib/Service/ArchiveService.php diff --git a/lib/Service/ArchiveService.php b/lib/Service/ArchiveService.php new file mode 100644 index 0000000000..77dd2f949a --- /dev/null +++ b/lib/Service/ArchiveService.php @@ -0,0 +1,158 @@ +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; + } + + /** + * 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(); + } +} From b8370727293db8a7f8cd19b06d901a2163283a93 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 13:38:59 +0200 Subject: [PATCH 05/17] feat(archive): add per-user archive state to table and context responses * clean up per-user archive records when table or context is deleted AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/Service/ArchiveService.php | 12 ++++++++++++ lib/Service/ContextService.php | 22 +++++++++++++++++++-- lib/Service/TableService.php | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/lib/Service/ArchiveService.php b/lib/Service/ArchiveService.php index 77dd2f949a..c1d4ff28a9 100644 --- a/lib/Service/ArchiveService.php +++ b/lib/Service/ArchiveService.php @@ -132,6 +132,18 @@ public function enrichContextsWithArchiveState(array $contexts, string $userId): return $contexts; } + /** + * 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. * diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index ae925b44b7..59e49a6643 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,15 @@ 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; } /** @@ -265,6 +282,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; } diff --git a/lib/Service/TableService.php b/lib/Service/TableService.php index afd8c7d6ef..2863b643d6 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,26 @@ 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; + } + /** * @param string $title * @param string $template @@ -449,6 +478,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); From 70b1d5dd115d97b7aa40a541c8239ed4b47617c5 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 13:42:44 +0200 Subject: [PATCH 06/17] feat(archive): migrate per-user archive overrides when ownership is transferred AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/Service/ArchiveService.php | 43 ++++++++++++++++++++++++++++++++++ lib/Service/ContextService.php | 9 ++++++- lib/Service/TableService.php | 10 +++++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/lib/Service/ArchiveService.php b/lib/Service/ArchiveService.php index c1d4ff28a9..d571f96ba2 100644 --- a/lib/Service/ArchiveService.php +++ b/lib/Service/ArchiveService.php @@ -132,6 +132,49 @@ public function enrichContextsWithArchiveState(array $contexts, string $userId): 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. * diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index 59e49a6643..5acb564566 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -319,11 +319,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 2863b643d6..be27ea2f2a 100644 --- a/lib/Service/TableService.php +++ b/lib/Service/TableService.php @@ -379,10 +379,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; From 1578b3f6b365830006e75ffaf5d53e1157f4b2b4 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 13:49:06 +0200 Subject: [PATCH 07/17] feat(archive): add archive/unarchive endpoints for tables and contexts AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/Controller/ApiTablesController.php | 57 ++++++++++++++++++++++++++ lib/Controller/ContextController.php | 49 ++++++++++++++++++++++ lib/Db/Table.php | 1 + lib/ResponseDefinitions.php | 1 + lib/Service/ContextService.php | 40 ++++++++++++++++++ lib/Service/TableService.php | 46 +++++++++++++++++++++ 6 files changed, 194 insertions(+) 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/Table.php b/lib/Db/Table.php index 2d91b5a09e..eef378b5aa 100644 --- a/lib/Db/Table.php +++ b/lib/Db/Table.php @@ -23,6 +23,7 @@ * @method getEmoji(): string * @method setEmoji(string $emoji) * @method getArchived(): bool + * @method isArchived(): bool * @method setArchived(bool $archived) * @method getDescription(): string * @method setDescription(string $description) 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/ContextService.php b/lib/Service/ContextService.php index 5acb564566..7c38065da0 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -136,6 +136,46 @@ public function findById(int $id, ?string $userId): Context { 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); + } + /** * @psalm-param list $nodes * @throws Exception|PermissionError|InvalidArgumentException diff --git a/lib/Service/TableService.php b/lib/Service/TableService.php index be27ea2f2a..2aacf045e3 100644 --- a/lib/Service/TableService.php +++ b/lib/Service/TableService.php @@ -291,6 +291,52 @@ public function getTableForUser(int $id, string $userId): Table { 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 From cd4cf2b64f1bee12da4fe967d992cc1348efd2e1 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 13:51:02 +0200 Subject: [PATCH 08/17] feat(archive): register archive/unarchive routes for tables and contexts AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- appinfo/routes.php | 4 ++++ 1 file changed, 4 insertions(+) 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+)']], From 1ce67510ed039b36d46991b5f12d5b3310a2b99d Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 13:53:59 +0200 Subject: [PATCH 09/17] chore(archive): regenerate openapi.json and TypeScript types AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- openapi.json | 2148 +++++++++++++++++++++++----------- src/types/openapi/openapi.ts | 385 ++++++ 2 files changed, 1849 insertions(+), 684 deletions(-) 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/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 14fff1e485..2cbc78deaf 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -599,6 +599,30 @@ export type paths = { readonly patch?: never; readonly trace?: never; }; + readonly "/ocs/v2.php/apps/tables/api/2/tables/{id}/archive": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + readonly put?: never; + /** + * [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. + */ + readonly post: operations["api_tables-archive-table"]; + /** + * [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. + */ + readonly delete: operations["api_tables-unarchive-table"]; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; readonly "/ocs/v2.php/apps/tables/api/2/columns/{nodeType}/{nodeId}": { readonly parameters: { readonly query?: never; @@ -808,6 +832,30 @@ export type paths = { readonly patch?: never; readonly trace?: never; }; + readonly "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/archive": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + readonly put?: never; + /** + * [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. + */ + readonly post: operations["context-archive-context"]; + /** + * [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. + */ + readonly delete: operations["context-unarchive-context"]; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; readonly "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/pages/{pageId}": { readonly parameters: { readonly query?: never; @@ -974,6 +1022,7 @@ export type components = { readonly owner: string; /** Format: int64 */ readonly ownerType: number; + readonly archived: boolean; }; readonly ContextNavigation: { /** Format: int64 */ @@ -4934,6 +4983,190 @@ export interface operations { }; }; }; + readonly "api_tables-archive-table": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description Table ID */ + readonly id: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Table returned with updated archived state */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["Table"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "api_tables-unarchive-table": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description Table ID */ + readonly id: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Table returned with updated archived state */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["Table"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; readonly "api_columns-index": { readonly parameters: { readonly query?: never; @@ -6663,6 +6896,158 @@ export interface operations { }; }; }; + readonly "context-archive-context": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description ID of the context */ + readonly contextId: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Context returned with updated archived state */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["Context"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description Context not found or not available */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "context-unarchive-context": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description ID of the context */ + readonly contextId: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Context returned with updated archived state */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["Context"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description Context not found or not available */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; readonly "context-update-content-order": { readonly parameters: { readonly query?: never; From 09483c05be40425b9ae5fbf7d73dc87a63925755 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 14:04:37 +0200 Subject: [PATCH 10/17] test(archive): add unit tests for ArchiveService, mapper and controllers AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ApiTablesControllerArchiveTest.php | 155 ++++++++++++ .../ContextControllerArchiveTest.php | 129 ++++++++++ tests/unit/Db/UserArchiveMapperTest.php | 232 +++++++++++++++++ tests/unit/Service/ArchiveServiceTest.php | 236 ++++++++++++++++++ 4 files changed, 752 insertions(+) create mode 100644 tests/unit/Controller/ApiTablesControllerArchiveTest.php create mode 100644 tests/unit/Controller/ContextControllerArchiveTest.php create mode 100644 tests/unit/Db/UserArchiveMapperTest.php create mode 100644 tests/unit/Service/ArchiveServiceTest.php diff --git a/tests/unit/Controller/ApiTablesControllerArchiveTest.php b/tests/unit/Controller/ApiTablesControllerArchiveTest.php new file mode 100644 index 0000000000..6965f52253 --- /dev/null +++ b/tests/unit/Controller/ApiTablesControllerArchiveTest.php @@ -0,0 +1,155 @@ +tableService = $this->createMock(TableService::class); + + $n = $this->createMock(IL10N::class); + $n->method('t')->willReturnArgument(0); + + $this->controller = new ApiTablesController( + $this->createMock(IRequest::class), + $this->createMock(LoggerInterface::class), + $this->tableService, + $this->createMock(ColumnService::class), + $this->createMock(ViewService::class), + $n, + $this->createMock(IAppManager::class), + $this->createMock(IDBConnection::class), + 'alice', + ); + } + + // ------------------------------------------------------------------------- + // archiveTable + // ------------------------------------------------------------------------- + + public function testArchiveTableReturns200OnSuccess(): void { + $table = $this->createTableStub(5, true); + $this->tableService->expects($this->once()) + ->method('archiveTable') + ->with(5, 'alice') + ->willReturn($table); + + $response = $this->controller->archiveTable(5); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(5, $response->getData()['id']); + $this->assertTrue($response->getData()['archived']); + } + + public function testArchiveTableReturns403OnPermissionError(): void { + $this->tableService->method('archiveTable') + ->willThrowException(new PermissionError('no access')); + + $response = $this->controller->archiveTable(5); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + + public function testArchiveTableReturns404OnNotFoundError(): void { + $this->tableService->method('archiveTable') + ->willThrowException(new NotFoundError('not found')); + + $response = $this->controller->archiveTable(5); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + } + + public function testArchiveTableReturns500OnInternalError(): void { + $this->tableService->method('archiveTable') + ->willThrowException(new InternalError('boom')); + + $response = $this->controller->archiveTable(5); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + } + + // ------------------------------------------------------------------------- + // unarchiveTable + // ------------------------------------------------------------------------- + + public function testUnarchiveTableReturns200OnSuccess(): void { + $table = $this->createTableStub(5, false); + $this->tableService->expects($this->once()) + ->method('unarchiveTable') + ->with(5, 'alice') + ->willReturn($table); + + $response = $this->controller->unarchiveTable(5); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertFalse($response->getData()['archived']); + } + + public function testUnarchiveTableReturns403OnPermissionError(): void { + $this->tableService->method('unarchiveTable') + ->willThrowException(new PermissionError('no access')); + + $response = $this->controller->unarchiveTable(5); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + + public function testUnarchiveTableReturns404OnNotFoundError(): void { + $this->tableService->method('unarchiveTable') + ->willThrowException(new NotFoundError('not found')); + + $response = $this->controller->unarchiveTable(5); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + } + + public function testUnarchiveTableReturns500OnInternalError(): void { + $this->tableService->method('unarchiveTable') + ->willThrowException(new InternalError('boom')); + + $response = $this->controller->unarchiveTable(5); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function createTableStub(int $id, bool $archived): Table { + $table = $this->createPartialMock(Table::class, []); + $table->setId($id); + $table->setArchived($archived); + $table->setTitle('Test'); + $table->setOwnership('alice'); + return $table; + } +} diff --git a/tests/unit/Controller/ContextControllerArchiveTest.php b/tests/unit/Controller/ContextControllerArchiveTest.php new file mode 100644 index 0000000000..ba61dfc1c7 --- /dev/null +++ b/tests/unit/Controller/ContextControllerArchiveTest.php @@ -0,0 +1,129 @@ +contextService = $this->createMock(ContextService::class); + + $n = $this->createMock(IL10N::class); + $n->method('t')->willReturnArgument(0); + + $this->controller = new ContextController( + $this->createMock(IRequest::class), + $this->createMock(LoggerInterface::class), + $n, + 'alice', + $this->contextService, + ); + } + + // ------------------------------------------------------------------------- + // archiveContext + // ------------------------------------------------------------------------- + + public function testArchiveContextReturns200OnSuccess(): void { + $context = $this->createContextStub(3, true); + $this->contextService->expects($this->once()) + ->method('archiveContext') + ->with(3, 'alice') + ->willReturn($context); + + $response = $this->controller->archiveContext(3); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(3, $response->getData()['id']); + $this->assertTrue($response->getData()['archived']); + } + + public function testArchiveContextReturns404OnNotFoundError(): void { + $this->contextService->method('archiveContext') + ->willThrowException(new NotFoundError('not found')); + + $response = $this->controller->archiveContext(3); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + } + + public function testArchiveContextReturns500OnInternalError(): void { + $this->contextService->method('archiveContext') + ->willThrowException(new InternalError('boom')); + + $response = $this->controller->archiveContext(3); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + } + + // ------------------------------------------------------------------------- + // unarchiveContext + // ------------------------------------------------------------------------- + + public function testUnarchiveContextReturns200OnSuccess(): void { + $context = $this->createContextStub(3, false); + $this->contextService->expects($this->once()) + ->method('unarchiveContext') + ->with(3, 'alice') + ->willReturn($context); + + $response = $this->controller->unarchiveContext(3); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertFalse($response->getData()['archived']); + } + + public function testUnarchiveContextReturns404OnNotFoundError(): void { + $this->contextService->method('unarchiveContext') + ->willThrowException(new NotFoundError('not found')); + + $response = $this->controller->unarchiveContext(3); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + } + + public function testUnarchiveContextReturns500OnInternalError(): void { + $this->contextService->method('unarchiveContext') + ->willThrowException(new InternalError('boom')); + + $response = $this->controller->unarchiveContext(3); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function createContextStub(int $id, bool $archived): Context { + $context = $this->createPartialMock(Context::class, []); + $context->setId($id); + $context->setArchived($archived); + $context->setName('Test'); + $context->setOwnerId('alice'); + $context->setOwnerType(0); + return $context; + } +} diff --git a/tests/unit/Db/UserArchiveMapperTest.php b/tests/unit/Db/UserArchiveMapperTest.php new file mode 100644 index 0000000000..0fad85eb10 --- /dev/null +++ b/tests/unit/Db/UserArchiveMapperTest.php @@ -0,0 +1,232 @@ +mapper = new UserArchiveMapper($this->connectionAdapter); + $this->cleanupArchiveData(); + } + + protected function tearDown(): void { + $this->cleanupArchiveData(); + parent::tearDown(); + } + + private function cleanupArchiveData(): void { + $qb = $this->connection->getQueryBuilder(); + $qb->delete('tables_archive_user')->executeStatement(); + } + + // ------------------------------------------------------------------------- + // findForUser + // ------------------------------------------------------------------------- + + public function testFindForUserReturnsNullWhenNoRecord(): void { + $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 42); + $this->assertNull($result); + } + + public function testFindForUserReturnsInsertedRecord(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 42, true); + + $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 42); + $this->assertNotNull($result); + $this->assertTrue($result->isArchived()); + $this->assertSame('alice', $result->getUserId()); + $this->assertSame(Application::NODE_TYPE_TABLE, $result->getNodeType()); + $this->assertSame(42, $result->getNodeId()); + } + + public function testFindForUserIsScopedToUser(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 42, true); + + $result = $this->mapper->findForUser('bob', Application::NODE_TYPE_TABLE, 42); + $this->assertNull($result); + } + + public function testFindForUserIsScopedToNodeType(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 42, true); + + $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_CONTEXT, 42); + $this->assertNull($result); + } + + public function testFindForUserIsScopedToNodeId(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 42, true); + + $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 99); + $this->assertNull($result); + } + + // ------------------------------------------------------------------------- + // upsert + // ------------------------------------------------------------------------- + + public function testUpsertInsertsNewRecord(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 1, false); + + $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 1); + $this->assertNotNull($result); + $this->assertFalse($result->isArchived()); + } + + public function testUpsertUpdatesExistingRecord(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 1, false); + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 1, true); + + $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 1); + $this->assertNotNull($result); + $this->assertTrue($result->isArchived()); + + // Confirm only one row + $all = $this->mapper->findAllForNode(Application::NODE_TYPE_TABLE, 1); + $this->assertCount(1, $all); + } + + public function testUpsertDoesNotAffectOtherUsers(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 1, true); + $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 1, false); + + $alice = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 1); + $bob = $this->mapper->findForUser('bob', Application::NODE_TYPE_TABLE, 1); + + $this->assertTrue($alice->isArchived()); + $this->assertFalse($bob->isArchived()); + } + + // ------------------------------------------------------------------------- + // findAllForNode + // ------------------------------------------------------------------------- + + public function testFindAllForNodeReturnsAllUsers(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 5, true); + $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 5, false); + $this->mapper->upsert('carol', Application::NODE_TYPE_TABLE, 5, true); + + $results = $this->mapper->findAllForNode(Application::NODE_TYPE_TABLE, 5); + $this->assertCount(3, $results); + } + + public function testFindAllForNodeIsScopedToNodeId(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 5, true); + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 6, true); + + $results = $this->mapper->findAllForNode(Application::NODE_TYPE_TABLE, 5); + $this->assertCount(1, $results); + $this->assertSame(5, $results[0]->getNodeId()); + } + + public function testFindAllForNodeReturnsEmptyWhenNone(): void { + $results = $this->mapper->findAllForNode(Application::NODE_TYPE_TABLE, 999); + $this->assertEmpty($results); + } + + // ------------------------------------------------------------------------- + // findAllOverridesForUser + // ------------------------------------------------------------------------- + + public function testFindAllOverridesForUserReturnsKeyedByNodeId(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 10, true); + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 20, false); + $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 10, false); // different user + + $results = $this->mapper->findAllOverridesForUser('alice', Application::NODE_TYPE_TABLE, [10, 20, 30]); + + $this->assertArrayHasKey(10, $results); + $this->assertArrayHasKey(20, $results); + $this->assertArrayNotHasKey(30, $results); // no record for nodeId 30 + $this->assertTrue($results[10]->isArchived()); + $this->assertFalse($results[20]->isArchived()); + } + + public function testFindAllOverridesForUserReturnsEmptyArrayForEmptyInput(): void { + $results = $this->mapper->findAllOverridesForUser('alice', Application::NODE_TYPE_TABLE, []); + $this->assertSame([], $results); + } + + public function testFindAllOverridesForUserIgnoresOtherNodeTypes(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_CONTEXT, 10, true); + + $results = $this->mapper->findAllOverridesForUser('alice', Application::NODE_TYPE_TABLE, [10]); + $this->assertEmpty($results); + } + + // ------------------------------------------------------------------------- + // deleteForUser + // ------------------------------------------------------------------------- + + public function testDeleteForUserRemovesRecord(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 7, true); + $this->mapper->deleteForUser('alice', Application::NODE_TYPE_TABLE, 7); + + $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 7); + $this->assertNull($result); + } + + public function testDeleteForUserDoesNotAffectOtherUsers(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 7, true); + $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 7, false); + + $this->mapper->deleteForUser('alice', Application::NODE_TYPE_TABLE, 7); + + $this->assertNull($this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 7)); + $this->assertNotNull($this->mapper->findForUser('bob', Application::NODE_TYPE_TABLE, 7)); + } + + public function testDeleteForUserIsNoOpWhenRecordAbsent(): void { + // Must not throw + $this->mapper->deleteForUser('nobody', Application::NODE_TYPE_TABLE, 999); + $this->assertTrue(true); + } + + // ------------------------------------------------------------------------- + // deleteAllForNode + // ------------------------------------------------------------------------- + + public function testDeleteAllForNodeRemovesAllUsers(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 8, true); + $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 8, false); + $this->mapper->upsert('carol', Application::NODE_TYPE_TABLE, 8, true); + + $this->mapper->deleteAllForNode(Application::NODE_TYPE_TABLE, 8); + + $remaining = $this->mapper->findAllForNode(Application::NODE_TYPE_TABLE, 8); + $this->assertEmpty($remaining); + } + + public function testDeleteAllForNodeDoesNotAffectOtherNodes(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 8, true); + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 9, false); + + $this->mapper->deleteAllForNode(Application::NODE_TYPE_TABLE, 8); + + $this->assertEmpty($this->mapper->findAllForNode(Application::NODE_TYPE_TABLE, 8)); + $this->assertCount(1, $this->mapper->findAllForNode(Application::NODE_TYPE_TABLE, 9)); + } + + public function testDeleteAllForNodeDoesNotAffectOtherNodeTypes(): void { + $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 8, true); + $this->mapper->upsert('alice', Application::NODE_TYPE_CONTEXT, 8, false); + + $this->mapper->deleteAllForNode(Application::NODE_TYPE_TABLE, 8); + + $this->assertEmpty($this->mapper->findAllForNode(Application::NODE_TYPE_TABLE, 8)); + $this->assertCount(1, $this->mapper->findAllForNode(Application::NODE_TYPE_CONTEXT, 8)); + } +} diff --git a/tests/unit/Service/ArchiveServiceTest.php b/tests/unit/Service/ArchiveServiceTest.php new file mode 100644 index 0000000000..2416b0a7ec --- /dev/null +++ b/tests/unit/Service/ArchiveServiceTest.php @@ -0,0 +1,236 @@ +mapper = $this->createMock(UserArchiveMapper::class); + $this->connection = $this->createMock(IDBConnection::class); + $this->service = new ArchiveService($this->connection, $this->mapper); + } + + // ------------------------------------------------------------------------- + // archiveForUser + // ------------------------------------------------------------------------- + + public function testArchiveForUserOwnerSetsEntityAndClearsOverrides(): void { + $this->mapper->expects($this->never())->method('upsert'); + $this->mapper->expects($this->once()) + ->method('deleteAllForNode') + ->with(Application::NODE_TYPE_TABLE, 42); + + // connection->getQueryBuilder() called for setEntityArchived + $qb = $this->createMockQueryBuilder(true); + $this->connection->expects($this->once()) + ->method('getQueryBuilder') + ->willReturn($qb); + + $this->service->archiveForUser('alice', Application::NODE_TYPE_TABLE, 42, true); + } + + public function testArchiveForUserNonOwnerUpsertsPersonalOverride(): void { + $this->mapper->expects($this->once()) + ->method('upsert') + ->with('bob', Application::NODE_TYPE_TABLE, 42, true); + $this->mapper->expects($this->never())->method('deleteAllForNode'); + $this->connection->expects($this->never())->method('getQueryBuilder'); + + $this->service->archiveForUser('bob', Application::NODE_TYPE_TABLE, 42, false); + } + + // ------------------------------------------------------------------------- + // unarchiveForUser + // ------------------------------------------------------------------------- + + public function testUnarchiveForUserOwnerClearsEntityAndAllOverrides(): void { + $this->mapper->expects($this->never())->method('upsert'); + $this->mapper->expects($this->never())->method('deleteForUser'); + $this->mapper->expects($this->once()) + ->method('deleteAllForNode') + ->with(Application::NODE_TYPE_TABLE, 42); + + $qb = $this->createMockQueryBuilder(false); + $this->connection->expects($this->once()) + ->method('getQueryBuilder') + ->willReturn($qb); + + $this->service->unarchiveForUser('alice', Application::NODE_TYPE_TABLE, 42, true, true); + } + + public function testUnarchiveForUserNonOwnerEntityNotArchivedDeletesOverride(): void { + $this->mapper->expects($this->once()) + ->method('deleteForUser') + ->with('bob', Application::NODE_TYPE_TABLE, 42); + $this->mapper->expects($this->never())->method('upsert'); + $this->connection->expects($this->never())->method('getQueryBuilder'); + + $this->service->unarchiveForUser('bob', Application::NODE_TYPE_TABLE, 42, false, false); + } + + public function testUnarchiveForUserNonOwnerEntityArchivedUpsertsFalseOverride(): void { + $this->mapper->expects($this->once()) + ->method('upsert') + ->with('bob', Application::NODE_TYPE_TABLE, 42, false); + $this->mapper->expects($this->never())->method('deleteForUser'); + $this->connection->expects($this->never())->method('getQueryBuilder'); + + $this->service->unarchiveForUser('bob', Application::NODE_TYPE_TABLE, 42, false, true); + } + + // ------------------------------------------------------------------------- + // isArchivedForUser + // ------------------------------------------------------------------------- + + public function testIsArchivedForUserReturnsOverrideWhenPresent(): void { + $override = new UserArchive(); + $override->setArchived(false); + + $this->mapper->expects($this->once()) + ->method('findForUser') + ->with('bob', Application::NODE_TYPE_TABLE, 42) + ->willReturn($override); + + // entity says archived=true, but user override says false + $result = $this->service->isArchivedForUser('bob', Application::NODE_TYPE_TABLE, 42, true); + $this->assertFalse($result); + } + + public function testIsArchivedForUserFallsBackToEntityWhenNoOverride(): void { + $this->mapper->expects($this->once()) + ->method('findForUser') + ->willReturn(null); + + $result = $this->service->isArchivedForUser('alice', Application::NODE_TYPE_TABLE, 42, true); + $this->assertTrue($result); + } + + // ------------------------------------------------------------------------- + // enrichTablesWithArchiveState + // ------------------------------------------------------------------------- + + public function testEnrichTablesReplacesArchivedWithPerUserValue(): void { + $table = $this->createTableStub(7, false); + + $override = new UserArchive(); + $override->setArchived(true); + + $this->mapper->expects($this->once()) + ->method('findAllOverridesForUser') + ->with('alice', Application::NODE_TYPE_TABLE, [7]) + ->willReturn([7 => $override]); + + $result = $this->service->enrichTablesWithArchiveState([$table], 'alice'); + $this->assertSame([$table], $result); + // Entity had archived=false but override says true + $this->assertTrue($table->isArchived()); + } + + public function testEnrichTablesFallsBackToEntityWhenNoOverride(): void { + $table = $this->createTableStub(7, true); + + $this->mapper->expects($this->once()) + ->method('findAllOverridesForUser') + ->willReturn([]); + + $this->service->enrichTablesWithArchiveState([$table], 'alice'); + $this->assertTrue($table->isArchived()); + } + + // ------------------------------------------------------------------------- + // prepareOwnershipTransfer + // ------------------------------------------------------------------------- + + public function testPrepareOwnershipTransferNoOverrideReturnsSameArchived(): void { + $this->mapper->expects($this->once()) + ->method('findForUser') + ->with('new', Application::NODE_TYPE_TABLE, 10) + ->willReturn(null); + $this->mapper->expects($this->never())->method('deleteForUser'); + $this->mapper->expects($this->never())->method('upsert'); + + $result = $this->service->prepareOwnershipTransfer('old', 'new', Application::NODE_TYPE_TABLE, 10, false); + $this->assertFalse($result); + } + + public function testPrepareOwnershipTransferOverrideSameValueNoOutgoingRecord(): void { + $override = new UserArchive(); + $override->setArchived(false); // same as entity + + $this->mapper->expects($this->once()) + ->method('findForUser') + ->willReturn($override); + $this->mapper->expects($this->once()) + ->method('deleteForUser') + ->with('new', Application::NODE_TYPE_TABLE, 10); + $this->mapper->expects($this->never())->method('upsert'); + + $result = $this->service->prepareOwnershipTransfer('old', 'new', Application::NODE_TYPE_TABLE, 10, false); + $this->assertFalse($result); + } + + public function testPrepareOwnershipTransferOverrideDiffersPreservesOutgoingOwner(): void { + $override = new UserArchive(); + $override->setArchived(true); // differs from entity (false) + + $this->mapper->expects($this->once()) + ->method('findForUser') + ->willReturn($override); + $this->mapper->expects($this->once()) + ->method('deleteForUser') + ->with('new', Application::NODE_TYPE_TABLE, 10); + $this->mapper->expects($this->once()) + ->method('upsert') + ->with('old', Application::NODE_TYPE_TABLE, 10, false); // preserve old owner's view + + $result = $this->service->prepareOwnershipTransfer('old', 'new', Application::NODE_TYPE_TABLE, 10, false); + $this->assertTrue($result); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function createTableStub(int $id, bool $archived): \OCA\Tables\Db\Table { + $table = $this->createPartialMock(\OCA\Tables\Db\Table::class, []); + $table->setId($id); + $table->setArchived($archived); + return $table; + } + + /** + * Returns a mock query builder chain that absorbs update()->set()->where()->executeStatement(). + */ + private function createMockQueryBuilder(bool $archived): object { + $expr = $this->createMock(\OCP\DB\QueryBuilder\IExpressionBuilder::class); + $expr->method('eq')->willReturnSelf(); + + $qb = $this->createMock(\OCP\DB\QueryBuilder\IQueryBuilder::class); + $qb->method('update')->willReturnSelf(); + $qb->method('set')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturnArgument(0); + $qb->method('expr')->willReturn($expr); + $qb->method('executeStatement')->willReturn(1); + return $qb; + } +} From 1b51346312fda94ff1bcb136f437a37d6c1e44f9 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 14:28:26 +0200 Subject: [PATCH 11/17] fix(archive): add concrete isArchived() methods to archive entities AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- lib/Db/Context.php | 5 ++++- lib/Db/Table.php | 5 ++++- lib/Db/UserArchive.php | 5 ++++- tests/unit/Service/ArchiveServiceTest.php | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/Db/Context.php b/lib/Db/Context.php index e297b553e1..db5840f0b1 100644 --- a/lib/Db/Context.php +++ b/lib/Db/Context.php @@ -20,7 +20,6 @@ * @method setOwnerId(string $value): void * @method getOwnerType(): int * @method setOwnerType(int $value): void - * @method isArchived(): bool * @method setArchived(bool $value): void * * @method getSharing(): array @@ -51,6 +50,10 @@ public function __construct() { $this->addType('archived', 'boolean'); } + public function isArchived(): bool { + return $this->archived; + } + public function jsonSerialize(): array { // basic information $data = [ diff --git a/lib/Db/Table.php b/lib/Db/Table.php index eef378b5aa..611fe5456a 100644 --- a/lib/Db/Table.php +++ b/lib/Db/Table.php @@ -23,7 +23,6 @@ * @method getEmoji(): string * @method setEmoji(string $emoji) * @method getArchived(): bool - * @method isArchived(): bool * @method setArchived(bool $archived) * @method getDescription(): string * @method setDescription(string $description) @@ -85,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 index b088192a97..2805ebb6d1 100644 --- a/lib/Db/UserArchive.php +++ b/lib/Db/UserArchive.php @@ -14,7 +14,6 @@ * @method setNodeType(int $value): void * @method getNodeId(): int * @method setNodeId(int $value): void - * @method isArchived(): bool * @method setArchived(bool $value): void */ class UserArchive extends EntitySuper { @@ -29,4 +28,8 @@ public function __construct() { $this->addType('node_id', 'integer'); $this->addType('archived', 'boolean'); } + + public function isArchived(): bool { + return $this->archived; + } } diff --git a/tests/unit/Service/ArchiveServiceTest.php b/tests/unit/Service/ArchiveServiceTest.php index 2416b0a7ec..4f7a25d9d3 100644 --- a/tests/unit/Service/ArchiveServiceTest.php +++ b/tests/unit/Service/ArchiveServiceTest.php @@ -222,7 +222,7 @@ private function createTableStub(int $id, bool $archived): \OCA\Tables\Db\Table */ private function createMockQueryBuilder(bool $archived): object { $expr = $this->createMock(\OCP\DB\QueryBuilder\IExpressionBuilder::class); - $expr->method('eq')->willReturnSelf(); + $expr->method('eq')->willReturn('1=1'); $qb = $this->createMock(\OCP\DB\QueryBuilder\IQueryBuilder::class); $qb->method('update')->willReturnSelf(); From 8f3f38aa76c5562632431571488a0f1e30b8dd05 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 14:30:46 +0200 Subject: [PATCH 12/17] fix(test): remove unused import Signed-off-by: Andy Scherzinger --- tests/unit/Db/UserArchiveMapperTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/Db/UserArchiveMapperTest.php b/tests/unit/Db/UserArchiveMapperTest.php index 0fad85eb10..c0ed4335f9 100644 --- a/tests/unit/Db/UserArchiveMapperTest.php +++ b/tests/unit/Db/UserArchiveMapperTest.php @@ -10,7 +10,6 @@ namespace OCA\Tables\Tests\Unit\Db; use OCA\Tables\AppInfo\Application; -use OCA\Tables\Db\UserArchive; use OCA\Tables\Db\UserArchiveMapper; use OCA\Tables\Tests\Unit\Database\DatabaseTestCase; From 75ca85ebfcaa8b086ea81cc1063bb3069a4e0ffb Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 14:43:10 +0200 Subject: [PATCH 13/17] test(count): Bump query count due to added extra tests Signed-off-by: Andy Scherzinger --- tests/integration/base-query-count.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/base-query-count.txt b/tests/integration/base-query-count.txt index aa798a7a0d..6d1d7d02cd 100644 --- a/tests/integration/base-query-count.txt +++ b/tests/integration/base-query-count.txt @@ -1 +1 @@ -184394 +184630 From 569230499fad2b8d9ac6302414c4e426aad91552 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 14:53:21 +0200 Subject: [PATCH 14/17] feat(archive): add archive and unarchive store actions AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- src/store/store.js | 64 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/store/store.js b/src/store/store.js index 5a2e1b17eb..f7959f0e1e 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -349,6 +349,70 @@ export const useTablesStore = defineStore('store', { return true }, + async archiveTable({ id }) { + try { + await axios.post(generateOcsUrl(`/apps/tables/api/2/tables/${id}/archive`)) + } catch (e) { + displayError(e, t('tables', 'Could not archive table.')) + return false + } + + const index = this.tables.findIndex(t => t.id === id) + const table = this.tables[index] + table.archived = true + this.setTable(table) + + return true + }, + + async unarchiveTable({ id }) { + try { + await axios.delete(generateOcsUrl(`/apps/tables/api/2/tables/${id}/archive`)) + } catch (e) { + displayError(e, t('tables', 'Could not unarchive table.')) + return false + } + + const index = this.tables.findIndex(t => t.id === id) + const table = this.tables[index] + table.archived = false + this.setTable(table) + + return true + }, + + async archiveContext({ id }) { + try { + await axios.post(generateOcsUrl(`/apps/tables/api/2/contexts/${id}/archive`)) + } catch (e) { + displayError(e, t('tables', 'Could not archive application.')) + return false + } + + const index = this.contexts.findIndex(c => c.id === id) + const context = this.contexts[index] + context.archived = true + this.setContext(context) + + return true + }, + + async unarchiveContext({ id }) { + try { + await axios.delete(generateOcsUrl(`/apps/tables/api/2/contexts/${id}/archive`)) + } catch (e) { + displayError(e, t('tables', 'Could not unarchive application.')) + return false + } + + const index = this.contexts.findIndex(c => c.id === id) + const context = this.contexts[index] + context.archived = false + this.setContext(context) + + return true + }, + async shareContext({ id, previousReceivers, receivers, displayMode }) { const share = { nodeType: 'context', From 177242e1ec0f1d3e1db7c05022a7e54b065e7fac Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 12 Apr 2026 15:09:11 +0200 Subject: [PATCH 15/17] feat(archive): add archive/unarchive action to table navigation item AI-assistant: Claude Code 2.1.101 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../navigation/partials/NavigationTableItem.vue | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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') }} -