From 585c05880fb888da7ea8df1042074351e74b79b1 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 12 Sep 2026 11:26:28 +0200 Subject: [PATCH 1/8] feat(archive): add per-user archive schema, entity and mapper Add the tables_archive_user table and the contexts.archived column, the UserArchive entity and mapper, and the archived flag on the Table and Context entities. Non-owner archive decisions are stored as per-user override rows, owner decisions live on the entity flag. Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Andy Scherzinger --- lib/Db/Context.php | 7 +- lib/Db/ContextMapper.php | 1 + lib/Db/Table.php | 1 + lib/Db/UserArchive.php | 33 +++ lib/Db/UserArchiveMapper.php | 164 ++++++++++++++ .../Version2400Date20260904000000.php | 203 ++++++++++++++++++ 6 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 lib/Db/UserArchive.php create mode 100644 lib/Db/UserArchiveMapper.php create mode 100644 lib/Migration/Version2400Date20260904000000.php diff --git a/lib/Db/Context.php b/lib/Db/Context.php index 3fa14f56b5..40bf6ba0b1 100644 --- a/lib/Db/Context.php +++ b/lib/Db/Context.php @@ -21,6 +21,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 @@ -35,6 +37,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; @@ -46,6 +49,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 { @@ -56,7 +60,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 0da199335e..e3fcd68b98 100644 --- a/lib/Db/ContextMapper.php +++ b/lib/Db/ContextMapper.php @@ -84,6 +84,7 @@ protected function formatResultRows(array $rows, ?string $userId) { 'description' => $rows[0]['description'], 'owner_id' => $rows[0]['owner_id'], 'owner_type' => $rows[0]['owner_type'], + 'archived' => (bool)($rows[0]['archived'] ?? false), ]; $formatted['sharing'] = array_reduce($rows, function (array $carry, array $item) use ($userId) { diff --git a/lib/Db/Table.php b/lib/Db/Table.php index 74dafd7667..175aaad104 100644 --- a/lib/Db/Table.php +++ b/lib/Db/Table.php @@ -28,6 +28,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/Db/UserArchive.php b/lib/Db/UserArchive.php new file mode 100644 index 0000000000..55836c9f72 --- /dev/null +++ b/lib/Db/UserArchive.php @@ -0,0 +1,33 @@ +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..519095849a --- /dev/null +++ b/lib/Db/UserArchiveMapper.php @@ -0,0 +1,164 @@ + */ +class UserArchiveMapper extends QBMapper { + /** + * Oracle enforces a hard limit of 1000 items per IN clause, which is the + * chunk size every other mapper in this app uses. + */ + private const DB_CHUNK_SIZE = 1_000; + + 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 user and node type, + * filtered to a specific set of node IDs. + * + * Chunks $nodeIds into batches of DB_CHUNK_SIZE and merges results in + * PHP to stay within the Oracle IN-clause 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 []; + } + $nodeIds = array_values(array_unique($nodeIds)); + + $results = []; + $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->createParameter('chunk'))); + + foreach (array_chunk($nodeIds, self::DB_CHUNK_SIZE) as $chunk) { + $qb->setParameter('chunk', $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. + * + * Uses IDBConnection::setValues() so insert-or-update happens as one + * portable atomic operation instead of a read-modify-write cycle. + * + * @throws Exception + */ + public function upsert(string $userId, int $nodeType, int $nodeId, bool $archived): void { + $this->db->setValues($this->table, [ + 'user_id' => $userId, + 'node_type' => $nodeType, + 'node_id' => $nodeId, + ], [ + 'archived' => $archived, + ]); + } + + /** + * Fetch the IDs of all users holding an archive override for a node. + * + * @return string[] + * @throws Exception + */ + public function findUserIdsForNode(int $nodeType, int $nodeId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('user_id') + ->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))); + + $result = $qb->executeQuery(); + $userIds = array_map(static fn (array $row) => (string)$row['user_id'], $result->fetchAllAssociative()); + $result->closeCursor(); + return $userIds; + } + + /** + * Remove every archive override a user holds, on any node. + * + * Called when the user account is deleted, so no orphaned rows remain + * for nodes owned by other users. + * + * @throws Exception + */ + public function deleteAllForUser(string $userId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->table) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))); + + $qb->executeStatement(); + } + + /** + * Remove the per-user archive override for a single user. + * + * @throws Exception + */ + public function deleteForUser(string $userId, int $nodeType, int $nodeId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->table) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } + + /** + * Remove all per-user archive overrides for a node (used when an owner + * archives/unarchives or when the node is permanently deleted). + * + * @throws Exception + */ + public function deleteAllForNode(int $nodeType, int $nodeId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->table) + ->where($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } +} diff --git a/lib/Migration/Version2400Date20260904000000.php b/lib/Migration/Version2400Date20260904000000.php new file mode 100644 index 0000000000..81393bd9f1 --- /dev/null +++ b/lib/Migration/Version2400Date20260904000000.php @@ -0,0 +1,203 @@ +hasTable('tables_contexts_context')) { + $table = $schema->getTable('tables_contexts_context'); + if (!$table->hasColumn('archived')) { + $table->addColumn('archived', Types::BOOLEAN, [ + 'default' => false, + 'notnull' => true, + ]); + } + } + + // 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() 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->fetchAllAssociative(); + $result->closeCursor(); + + if (empty($archivedTables)) { + $output->info('No archived tables found, skipping data migration.'); + return; + } + + // Build all per-iteration queries once and rebind parameters inside the loop + $shareQb = $this->connection->getQueryBuilder(); + $shareQb->select('receiver') + ->from('tables_shares') + ->where($shareQb->expr()->eq('node_id', $shareQb->createParameter('nodeId'))) + ->andWhere($shareQb->expr()->eq('node_type', $shareQb->createNamedParameter(ConversionHelper::constNodeType2String(Application::NODE_TYPE_TABLE)))) + ->andWhere($shareQb->expr()->eq('receiver_type', $shareQb->createNamedParameter('user'))); + + $checkQb = $this->connection->getQueryBuilder(); + $checkQb->select('id') + ->from('tables_archive_user') + ->where($checkQb->expr()->eq('user_id', $checkQb->createParameter('userId'))) + ->andWhere($checkQb->expr()->eq('node_type', $checkQb->createNamedParameter(Application::NODE_TYPE_TABLE, IQueryBuilder::PARAM_INT))) + ->andWhere($checkQb->expr()->eq('node_id', $checkQb->createParameter('nodeId'))); + + $insertQb = $this->connection->getQueryBuilder(); + $insertQb->insert('tables_archive_user') + ->values([ + 'user_id' => $insertQb->createParameter('userId'), + 'node_type' => $insertQb->createNamedParameter(Application::NODE_TYPE_TABLE, IQueryBuilder::PARAM_INT), + 'node_id' => $insertQb->createParameter('nodeId'), + 'archived' => $insertQb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL), + ]); + + $inserted = 0; + + foreach ($archivedTables as $tableRow) { + $tableId = (int)$tableRow['id']; + $ownerId = $tableRow['ownership']; + + // The owner deliberately gets no override row: the entity-level + // flag already represents their state, and an override would + // survive an unarchive that only resets that flag. + + // Insert direct user-share recipient records + $shareQb->setParameter('nodeId', $tableId, IQueryBuilder::PARAM_INT); + $shareResult = $shareQb->executeQuery(); + while ($shareRow = $shareResult->fetchAssociative()) { + $receiverId = $shareRow['receiver']; + if ($receiverId !== $ownerId) { + $inserted += $this->upsertArchiveRecord($checkQb, $insertQb, $receiverId, $tableId); + } + } + $shareResult->closeCursor(); + } + + $output->info(sprintf('Inserted %d per-user archive records.', $inserted)); + } + + /** + * Insert a `tables_archive_user` record if it does not already exist, + * rebinding the prepared check/insert queries for the given user and table. + * Returns 1 if a new record was inserted, 0 if it already existed. + * + * @throws Exception + */ + private function upsertArchiveRecord(IQueryBuilder $checkQb, IQueryBuilder $insertQb, string $userId, int $tableId): int { + // Check for existing record first to avoid unique-index violation + $checkQb->setParameter('userId', $userId, IQueryBuilder::PARAM_STR); + $checkQb->setParameter('nodeId', $tableId, IQueryBuilder::PARAM_INT); + $checkResult = $checkQb->executeQuery(); + $existing = $checkResult->fetchOne(); + $checkResult->closeCursor(); + if ($existing !== false) { + return 0; + } + + $insertQb->setParameter('userId', $userId, IQueryBuilder::PARAM_STR); + $insertQb->setParameter('nodeId', $tableId, IQueryBuilder::PARAM_INT); + $insertQb->executeStatement(); + return 1; + } +} From 73f25b63d58fd5811e55f589dbea3c601d592cf0 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 12 Sep 2026 11:26:39 +0200 Subject: [PATCH 2/8] feat(archive): add ArchiveService for owner and per-user archive logic Owner archiving toggles the shared entity flag inside a transaction and clears every per-user override; non-owners store or drop a personal override. The service also resolves the per-user flag onto entity collections and migrates overrides when ownership is transferred. Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Andy Scherzinger --- lib/Service/ArchiveService.php | 226 +++++++++++++++++++++++++++++++++ 1 file changed, 226 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..6813ead713 --- /dev/null +++ b/lib/Service/ArchiveService.php @@ -0,0 +1,226 @@ +setEntityArchived($nodeType, $nodeId, true); + } + } else { + $this->userArchiveMapper->upsert($userId, $nodeType, $nodeId, true); + } + } + + /** + * Unarchive a table or context for a user. + * + * The owner moves the entity-level flag, which is the default for users + * without a personal override. Existing overrides are left untouched: a + * user who archived it for themselves keeps it archived regardless of the + * owner. A non-owner removes their override when the entity is not + * owner-archived, or upserts an explicit unarchive override otherwise. + * + * @throws Exception + * @throws InternalError + */ + public function unarchiveForUser(string $userId, int $nodeType, int $nodeId, bool $isOwner, bool $entityArchived): void { + if ($isOwner) { + if ($entityArchived) { + $this->setEntityArchived($nodeType, $nodeId, false); + } + } 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); + } + } + + /** + * Overwrite the `archived` property on each table with the per-user + * resolved value for $userId. + * + * @param Table[] $tables + * @throws Exception + */ + public function enrichTablesWithArchiveState(array $tables, string $userId): void { + $this->overrideArchivedFlags($tables, $userId, Application::NODE_TYPE_TABLE); + } + + /** + * Overwrite the `archived` property on each context with the per-user + * resolved value for $userId. + * + * @param Context[] $contexts + * @throws Exception + */ + public function enrichContextsWithArchiveState(array $contexts, string $userId): void { + $this->overrideArchivedFlags($contexts, $userId, Application::NODE_TYPE_CONTEXT); + } + + /** + * Overwrite the `archived` property on each entity with the per-user + * resolved value for $userId. + * + * Uses a single bulk DB query (chunked for Oracle compatibility) regardless + * of how many entities are in the array. + * + * @param list $entities + * @throws Exception + */ + private function overrideArchivedFlags(array $entities, string $userId, int $nodeType): void { + $nodeIds = array_map(static fn (Table|Context $entity) => $entity->getId(), $entities); + $overrides = $this->userArchiveMapper->findAllOverridesForUser($userId, $nodeType, $nodeIds); + + foreach ($entities as $entity) { + $override = $overrides[$entity->getId()] ?? null; + $archived = $override !== null ? $override->isArchived() : $entity->isArchived(); + + // The resolved value is per-user presentation state, not a pending + // change to the shared column, so an entity that arrived clean must + // stay clean: a later mapper update would otherwise write one + // user's state to the shared column. Changes a caller made before + // enrichment are preserved. + $wasClean = $entity->getUpdatedFields() === []; + $entity->setArchived($archived); + if ($wasClean) { + $entity->resetUpdatedFields(); + } + } + } + + /** + * 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. + * Callers must clean this up via removeUserOverride() when the transfer + * leaves the outgoing owner without access to the node. + * + * 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. + // Any existing row is deleted first: this method runs inside the + // caller's transaction, where the insert-then-update-on-conflict + // recovery of setValues() would abort the transaction on PostgreSQL. + if ($newArchived !== $entityArchived) { + $this->userArchiveMapper->deleteForUser($oldOwnerId, $nodeType, $nodeId); + $this->userArchiveMapper->upsert($oldOwnerId, $nodeType, $nodeId, $entityArchived); + } + + return $newArchived; + } + + /** + * Remove a single user's archive override for a node. + * + * Used after ownership transfers when the outgoing owner no longer has + * access to the node, so no orphaned override rows are left behind. + * + * @throws Exception + */ + public function removeUserOverride(string $userId, int $nodeType, int $nodeId): void { + $this->userArchiveMapper->deleteForUser($userId, $nodeType, $nodeId); + } + + /** + * Remove all per-user archive overrides for a node. + * + * Called when a table or context is permanently deleted so that + * `tables_archive_user` does not accumulate orphaned rows. + * + * @throws Exception + */ + public function deleteNodeArchiveOverrides(int $nodeType, int $nodeId): void { + $this->userArchiveMapper->deleteAllForNode($nodeType, $nodeId); + } + + /** + * Directly set the entity-level `archived` flag for a table or context row. + * + * Intentionally bypasses TableService / ContextService to avoid a circular + * dependency: those services will eventually call ArchiveService, so + * ArchiveService must not call them back for this low-level write. + * + * @throws Exception + * @throws InternalError + */ + private function setEntityArchived(int $nodeType, int $nodeId, bool $archived): void { + $tableName = match ($nodeType) { + Application::NODE_TYPE_TABLE => 'tables_tables', + Application::NODE_TYPE_CONTEXT => 'tables_contexts_context', + default => throw new InternalError('Unsupported node type for archiving: ' . $nodeType), + }; + + $qb = $this->connection->getQueryBuilder(); + $qb->update($tableName) + ->set('archived', $qb->createNamedParameter($archived, IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } +} From 81e20476fc99618a165adaa467bdd2e360631b84 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 12 Sep 2026 11:26:39 +0200 Subject: [PATCH 3/8] feat(archive): apply per-user archive state in table and context services Enrich read paths with the per-user resolved archived flag, add archive/unarchive, keep the state consistent across ownership transfer and deletion, hide archived contexts from the navigation, and expose the archived field in the API response definitions. Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Andy Scherzinger --- lib/ResponseDefinitions.php | 1 + lib/Service/ContextService.php | 125 ++++++++++++++++- lib/Service/TableService.php | 239 ++++++++++++++++++++++++++++++--- 3 files changed, 343 insertions(+), 22 deletions(-) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c935d72524..8cfcbfd72e 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -233,6 +233,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 edfd7d9a6e..c7b7d7edee 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -56,11 +56,13 @@ public function __construct( private IEventDispatcher $eventDispatcher, private IDBConnection $dbc, private ShareService $shareService, + private ArchiveCleanupService $archiveCleanupService, private bool $isCLI, protected INavigationManager $navigationManager, protected IURLGenerator $urlGenerator, private TableMapper $tableMapper, private ViewMapper $viewMapper, + private ArchiveService $archiveService, ) { } @@ -80,11 +82,28 @@ 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; } + /** + * @return Context[] + */ public function findForNavigation(string $userId): array { - return $this->contextMapper->findForNavBar($userId); + $contexts = $this->contextMapper->findForNavBar($userId); + try { + $this->archiveService->enrichContextsWithArchiveState($contexts, $userId); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + return array_values(array_filter($contexts, static fn (Context $context) => !$context->isArchived())); } public function addToNavigation(string $userId): void { @@ -125,7 +144,88 @@ public function findById(int $id, ?string $userId): Context { throw new InternalError($error); } - return $this->contextMapper->findById($id, $userId); + $context = $this->contextMapper->findById($id, $userId); + if ($userId !== null) { + try { + $this->archiveService->enrichContextsWithArchiveState([$context], $userId); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } + return $context; + } + + /** + * Archive a context for the given user. + * + * If the user is the owner the entity flag is set and all per-user + * overrides are cleared; otherwise a personal override is stored. + * Access is validated by the mapper (NotFoundError if no access). + * + * @throws Exception + * @throws NotFoundError + * @throws InternalError + */ + public function archiveContext(int $contextId, string $userId): Context { + return $this->setArchivedForUser($contextId, $userId, true); + } + + /** + * 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 { + return $this->setArchivedForUser($contextId, $userId, false); + } + + /** + * Shared implementation of archiveContext() and unarchiveContext(). + * + * An owner-level change alters what every user with access sees, so it is + * recorded in the audit log. A personal override is invisible to others + * and stays silent. + * + * @throws Exception + * @throws NotFoundError + * @throws InternalError + */ + private function setArchivedForUser(int $contextId, string $userId, bool $archived): 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 + + try { + if ($archived) { + $this->archiveService->archiveForUser($userId, Application::NODE_TYPE_CONTEXT, $contextId, $isOwner, $entityArchived); + } else { + $this->archiveService->unarchiveForUser($userId, Application::NODE_TYPE_CONTEXT, $contextId, $isOwner, $entityArchived); + } + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + + if ($isOwner && $entityArchived !== $archived) { + $this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent( + sprintf( + 'Tables application with ID %d was %s by user %s', + $contextId, + $archived ? 'archived' : 'unarchived', + $userId, + ) + )); + } + + return $this->findById($contextId, $userId); } /** @@ -304,6 +404,14 @@ public function update(int $contextId, string $userId, ?string $name, ?string $i $context->setPages($currentPages); } + // The response must report the requesting user's archive state, not the + // shared flag, exactly as the read paths and TableService::update() do. + try { + $this->archiveService->enrichContextsWithArchiveState([$context], $userId); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + return $context; } @@ -323,6 +431,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; } @@ -359,13 +468,21 @@ 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); + $this->archiveCleanupService->removeOverrideIfStale($oldOwnerId, Application::NODE_TYPE_CONTEXT, $contextId); return $context; }, $this->dbc); } catch (\Exception $e) { diff --git a/lib/Service/TableService.php b/lib/Service/TableService.php index 5d5b704cbd..74e74f2556 100644 --- a/lib/Service/TableService.php +++ b/lib/Service/TableService.php @@ -71,6 +71,7 @@ public function __construct( protected Defaults $themingDefaults, private ActivityManager $activityManager, private FederationService $federationService, + private ArchiveService $archiveService, ) { parent::__construct($logger, $userId, $permissionsService); } @@ -155,6 +156,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); } @@ -275,6 +284,159 @@ 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; + } + + /** + * Apply the legacy `archived` flag of the update endpoints with the same + * per-user semantics the dedicated archive endpoints use. + * + * An owner toggles the entity-level flag and clears every per-user + * override, so all users inherit the owner's choice. Anyone else only + * changes their own override and leaves the shared flag alone. + * + * Returns whether this touched the shared table, i.e. an owner actually + * flipped the entity flag. A non-owner's private override, or an owner + * repeating the current state, changes nothing anyone else can observe and + * must not surface as a shared edit (bumped metadata, activity, federation). + * + * @throws InternalError + */ + private function applyArchivedOnUpdate(Table $table, bool $archived, string $userId): bool { + $nodeId = $table->getId(); + $entityArchived = $table->isArchived(); + // an empty user id is a CLI call (occ) and acts with owner-level rights + $isOwner = $userId === '' || $table->getOwnership() === $userId; + try { + if ($archived) { + $this->archiveService->archiveForUser($userId, Application::NODE_TYPE_TABLE, $nodeId, $isOwner, $entityArchived); + } else { + $this->archiveService->unarchiveForUser($userId, Application::NODE_TYPE_TABLE, $nodeId, $isOwner, $entityArchived); + } + } catch (OcpDbException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + + if ($isOwner) { + // ArchiveService already wrote the shared flag; keep the in-memory + // entity consistent for the response and the activity diff. + $table->setArchived($archived); + return $entityArchived !== $archived; + } + return false; + } + + /** + * 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 { + return $this->setArchivedForUser($id, $userId, true); + } + + /** + * 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 { + return $this->setArchivedForUser($id, $userId, false); + } + + /** + * Shared implementation of archiveTable() and unarchiveTable(). + * + * An owner changes the shared flag, which is a change to the table like + * any other: it bumps the edit metadata, reaches the activity stream and + * is announced to federated receivers. A personal override changes nothing + * anyone else can observe, so it stays silent. + * + * @throws InternalError + * @throws NotFoundError + * @throws PermissionError + */ + private function setArchivedForUser(int $id, string $userId, bool $archived): Table { + $table = $this->find($id, true, $userId); + $isOwner = $table->getOwnership() === $userId; + $entityArchived = $table->isArchived(); // entity-level flag, not per-user + $changes = new ChangeSet($table); + + try { + if ($archived) { + $this->archiveService->archiveForUser($userId, Application::NODE_TYPE_TABLE, $id, $isOwner, $entityArchived); + } else { + $this->archiveService->unarchiveForUser($userId, Application::NODE_TYPE_TABLE, $id, $isOwner, $entityArchived); + } + } catch (OcpDbException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + + if ($isOwner && $entityArchived !== $archived) { + $this->announceArchiveChange($table, $userId, $archived, $changes); + } + + return $this->getTableForUser($id, $userId); + } + + /** + * Record an owner-level archive change the way TableService::update() does. + * + * The flag itself was already written by ArchiveService, so this only + * refreshes the edit metadata and emits the notifications. Failures are + * logged rather than raised: the archive state is already stored and must + * not be rolled back because a notification could not be delivered. + */ + private function announceArchiveChange(Table $table, string $userId, bool $archived, ChangeSet $changes): void { + try { + $table->setArchived($archived); + $table->setLastEditBy($userId); + $table->setLastEditAt((new DateTime())->format('Y-m-d H:i:s')); + $table = $this->mapper->update($table); + + $this->federationService->notifyNodeUpdate($table, 'table'); + + $changes->setAfter($table); + $this->activityManager->triggerUpdateEvents( + objectType: ActivityManager::TABLES_OBJECT_TABLE, + changeSet: $changes, + subject: ActivityManager::SUBJECT_TABLE_UPDATE + ); + } catch (\Throwable $e) { + $this->logger->error('Could not announce the archive change: ' . $e->getMessage(), ['exception' => $e]); + } + } + /** * @param string $title * @param string $template @@ -381,12 +543,23 @@ 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); + if (!$this->permissionsService->canReadTable($table, $oldOwnerId)) { + $this->archiveService->removeUserOverride($oldOwnerId, Application::NODE_TYPE_TABLE, $id); + } return $table; }, $this->dbc); } catch (\Exception $e) { @@ -482,6 +655,13 @@ public function delete(int $id, ?string $userId = null): Table { throw new InternalError(static::class . ' - ' . __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); @@ -528,36 +708,48 @@ public function update(int $id, ?string $title, ?string $emoji, ?string $descrip $changes = new ChangeSet($table); $time = new DateTime(); + $hasSharedChange = false; if ($title !== null) { $title = (string)new Title($title); $table->setTitle($title); + $hasSharedChange = true; } if ($emoji !== null) { $table->setEmoji($emoji); + $hasSharedChange = true; } if ($archived !== null) { - $table->setArchived($archived); + // a non-owner's archive is private; only an owner-level flag flip is shared + $hasSharedChange = $this->applyArchivedOnUpdate($table, $archived, $userId) || $hasSharedChange; } if ($description !== null) { $table->setDescription($description); + $hasSharedChange = true; } if ($columnSettings !== null) { $table->setColumnOrder(\json_encode($columnSettings->jsonSerialize())); + $hasSharedChange = true; } if ($sort !== null) { $table->setSort(\json_encode($sort->jsonSerialize())); + $hasSharedChange = true; } - $table->setLastEditBy($userId); - $table->setLastEditAt($time->format('Y-m-d H:i:s')); - try { - $table = $this->mapper->update($table); - } catch (OcpDbException $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); - } - // notify federated shares about table update - $this->federationService->notifyNodeUpdate($table, 'table'); + // A private per-user archive override leaves the shared table untouched, + // so skip the edit-metadata bump, persist, federation notice and activity. + if ($hasSharedChange) { + $table->setLastEditBy($userId); + $table->setLastEditAt($time->format('Y-m-d H:i:s')); + try { + $table = $this->mapper->update($table); + } catch (OcpDbException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + + // notify federated shares about table update + $this->federationService->notifyNodeUpdate($table, 'table'); + } try { $this->enhanceTable($table, $userId); @@ -565,12 +757,23 @@ public function update(int $id, ?string $title, ?string $emoji, ?string $descrip $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } - $changes->setAfter($table); - $this->activityManager->triggerUpdateEvents( - objectType: ActivityManager::TABLES_OBJECT_TABLE, - changeSet: $changes, - subject: ActivityManager::SUBJECT_TABLE_UPDATE - ); + if ($hasSharedChange) { + $changes->setAfter($table); + $this->activityManager->triggerUpdateEvents( + objectType: ActivityManager::TABLES_OBJECT_TABLE, + changeSet: $changes, + subject: ActivityManager::SUBJECT_TABLE_UPDATE + ); + } + // Resolve the per-user archived state only after the activity diff is + // built, so the response reflects the requesting user's state. + if ($userId !== '') { + try { + $this->archiveService->enrichTablesWithArchiveState([$table], $userId); + } catch (OcpDbException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } return $table; } From 62906a6d37294ff829454cc64635845fcfd1c7da Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 12 Sep 2026 11:26:50 +0200 Subject: [PATCH 4/8] feat(archive): add archive/unarchive endpoints and routes Add the v2 table and context archive/unarchive endpoints (rate-limited, permission-checked, contexts addressed via the string typeParam) and their routes, resolve the per-user state in the single-item GET endpoints, and leave the archived state unchanged on partial updates through API v1 and the occ command. Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Andy Scherzinger --- appinfo/routes.php | 4 ++ lib/Command/RenameTable.php | 6 ++- lib/Controller/Api1Controller.php | 9 ++-- lib/Controller/ApiTablesController.php | 61 +++++++++++++++++++++++++- lib/Controller/ContextController.php | 59 +++++++++++++++++++++++++ lib/Controller/TableController.php | 2 +- 6 files changed, 134 insertions(+), 7 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index ad9b79e77e..724c413a2a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -138,6 +138,8 @@ ['name' => 'ApiTables#transfer', 'url' => '/api/2/tables/{id}/transfer', 'verb' => 'PUT'], ['name' => 'ApiTables#previewSchemeChanges', 'url' => '/api/2/tables/{id}/scheme/preview-changes', 'verb' => 'POST'], ['name' => 'ApiTables#importScheme', 'url' => '/api/2/tables/{id}/scheme/import', 'verb' => 'POST'], + ['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'], @@ -159,6 +161,8 @@ ['name' => 'Context#previewSchemeChanges', 'url' => '/api/2/contexts/{contextId}/scheme/preview-changes', 'verb' => 'POST'], ['name' => 'Context#importScheme', 'url' => '/api/2/contexts/{contextId}/scheme/import', 'verb' => 'POST'], ['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' => 'Config#getTableConfig', 'url' => '/api/2/config/table/{id}', 'verb' => 'GET'], diff --git a/lib/Command/RenameTable.php b/lib/Command/RenameTable.php index eac168904e..fa2d08d6eb 100644 --- a/lib/Command/RenameTable.php +++ b/lib/Command/RenameTable.php @@ -56,7 +56,7 @@ protected function configure(): void { 'archived', 'a', InputOption::VALUE_NONE, - 'Archived' + 'Archive the table; the archived state is kept unchanged when omitted' ) ; } @@ -70,7 +70,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $id = $input->getArgument('ID'); $title = $input->getArgument('title'); $emoji = $input->getOption('emoji'); - $archived = $input->getOption('archived'); + // VALUE_NONE yields false when absent; map that to null so an omitted + // flag leaves the archived state unchanged instead of unarchiving + $archived = $input->getOption('archived') ? true : null; $description = $input->getOption('description'); try { diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index e55287c238..f83b61bb0d 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -191,7 +191,10 @@ public function showScheme(int $tableId): JSONResponse|DataResponse { #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getTable(int $tableId): DataResponse { try { - return new DataResponse($this->tableService->find($tableId)->jsonSerialize()); + $table = $this->userId === null + ? $this->tableService->find($tableId) + : $this->tableService->getTableForUser($tableId, $this->userId); + return new DataResponse($table->jsonSerialize()); } catch (PermissionError $e) { $this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]); $message = ['message' => $e->getMessage()]; @@ -213,7 +216,7 @@ public function getTable(int $tableId): DataResponse { * @param int $tableId Table ID * @param string|null $title New table title * @param string|null $emoji New table emoji - * @param bool $archived Whether the table is archived + * @param bool|null $archived Whether the table is archived; the state is kept unchanged when omitted * @return DataResponse|DataResponse * * 200: Tables returned @@ -226,7 +229,7 @@ public function getTable(int $tableId): DataResponse { #[CORS] #[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] - public function updateTable(int $tableId, ?string $title = null, ?string $emoji = null, ?bool $archived = false): DataResponse { + public function updateTable(int $tableId, ?string $title = null, ?string $emoji = null, ?bool $archived = null): DataResponse { try { return new DataResponse($this->tableService->update($tableId, $title, $emoji, null, $archived, $this->userId)->jsonSerialize()); } catch (InvalidArgumentException $e) { diff --git a/lib/Controller/ApiTablesController.php b/lib/Controller/ApiTablesController.php index 105f71f0a5..c06f70527c 100644 --- a/lib/Controller/ApiTablesController.php +++ b/lib/Controller/ApiTablesController.php @@ -27,6 +27,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; @@ -85,7 +86,7 @@ public function index(): DataResponse { #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'id')] public function show(int $id): DataResponse { try { - return new DataResponse($this->service->find($id)->jsonSerialize()); + return new DataResponse($this->service->getTableForUser($id, $this->userId)->jsonSerialize()); } catch (PermissionError $e) { return $this->handlePermissionError($e); } catch (InternalError $e) { @@ -469,6 +470,64 @@ 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 { + return $this->archiveResponse($id, true); + } + + /** + * [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 { + return $this->archiveResponse($id, false); + } + + /** + * @return DataResponse|DataResponse + */ + private function archiveResponse(int $id, bool $archived): DataResponse { + try { + $table = $archived + ? $this->service->archiveTable($id, $this->userId) + : $this->service->unarchiveTable($id, $this->userId); + return new DataResponse($table->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 e87f8ca440..988e249863 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -25,6 +25,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\IDBConnection; @@ -255,6 +256,64 @@ 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 + * 403: No permissions + * 404: Context not found or not available + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'context', idParam: 'contextId')] + public function archiveContext(int $contextId): DataResponse { + return $this->archiveResponse($contextId, true); + } + + /** + * [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 + * 403: No permissions + * 404: Context not found or not available + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'context', idParam: 'contextId')] + public function unarchiveContext(int $contextId): DataResponse { + return $this->archiveResponse($contextId, false); + } + + /** + * @return DataResponse|DataResponse + */ + private function archiveResponse(int $contextId, bool $archived): DataResponse { + try { + $context = $archived + ? $this->contextService->archiveContext($contextId, $this->userId) + : $this->contextService->unarchiveContext($contextId, $this->userId); + return new DataResponse($context->jsonSerialize()); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } 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/Controller/TableController.php b/lib/Controller/TableController.php index cd7c3963f3..1e42475b32 100644 --- a/lib/Controller/TableController.php +++ b/lib/Controller/TableController.php @@ -38,7 +38,7 @@ public function index(): DataResponse { #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'id')] public function show(int $id): DataResponse { - return $this->handleError(fn () => $this->service->find($id)); + return $this->handleError(fn () => $this->service->getTableForUser($id, $this->userId)); } #[NoAdminRequired] From 742d4ba2c401a9f5d0fbbe52e0d704f513ad6233 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 12 Sep 2026 11:26:50 +0200 Subject: [PATCH 5/8] feat(archive): clean up per-user overrides when access is lost Remove stale archive overrides when a share is deleted, a group or circle membership ends, a receiver is deleted, or a user account is removed. Per-user access re-checks are handed to a background job so an unrelated request does not scale with the number of override holders. Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Andy Scherzinger --- lib/AppInfo/Application.php | 6 + .../CleanupArchiveOverridesJob.php | 59 ++++ lib/Db/ShareMapper.php | 22 ++ lib/Helper/ConversionHelper.php | 18 ++ lib/Listener/ArchiveCleanupListener.php | 84 ++++++ lib/Listener/ReceiverCleanupListener.php | 31 ++- lib/Service/ArchiveCleanupService.php | 252 ++++++++++++++++++ lib/Service/ShareService.php | 5 + 8 files changed, 475 insertions(+), 2 deletions(-) create mode 100644 lib/BackgroundJob/CleanupArchiveOverridesJob.php create mode 100644 lib/Listener/ArchiveCleanupListener.php create mode 100644 lib/Service/ArchiveCleanupService.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 110a878d8b..9376a9e1ce 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -11,6 +11,7 @@ use OC\OCM\OCMSignatoryManager; use OCA\Analytics\Datasource\DatasourceEvent; use OCA\Circles\Events\CircleDestroyedEvent; +use OCA\Circles\Events\CircleMemberRemovedEvent; use OCA\Tables\Capabilities; use OCA\Tables\Config\ConfigLexicon; use OCA\Tables\Event\RowDeletedEvent; @@ -20,6 +21,7 @@ use OCA\Tables\Federation\FederationProvider; use OCA\Tables\Listener\AddMissingIndicesListener; use OCA\Tables\Listener\AnalyticsDatasourceListener; +use OCA\Tables\Listener\ArchiveCleanupListener; use OCA\Tables\Listener\LoadAdditionalEntriesListener; use OCA\Tables\Listener\LoadAdditionalListener; use OCA\Tables\Listener\ReceiverCleanupListener; @@ -50,6 +52,7 @@ use OCP\Federation\ICloudFederationProvider; use OCP\Federation\ICloudFederationProviderManager; use OCP\Group\Events\GroupDeletedEvent; +use OCP\Group\Events\UserRemovedEvent; use OCP\Navigation\Events\LoadAdditionalEntriesEvent; use OCP\OCM\Events\LocalOCMDiscoveryEvent; use OCP\Security\Signature\ISignatoryManager; @@ -64,6 +67,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; @@ -108,6 +112,8 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserDeletedEvent::class, ReceiverCleanupListener::class); $context->registerEventListener(GroupDeletedEvent::class, ReceiverCleanupListener::class); $context->registerEventListener(CircleDestroyedEvent::class, ReceiverCleanupListener::class); + $context->registerEventListener(UserRemovedEvent::class, ArchiveCleanupListener::class); + $context->registerEventListener(CircleMemberRemovedEvent::class, ArchiveCleanupListener::class); $context->registerEventListener(LocalOCMDiscoveryEvent::class, ResourceTypeRegisterListener::class); $context->registerSearchProvider(SearchTablesProvider::class); diff --git a/lib/BackgroundJob/CleanupArchiveOverridesJob.php b/lib/BackgroundJob/CleanupArchiveOverridesJob.php new file mode 100644 index 0000000000..fb229d64f7 --- /dev/null +++ b/lib/BackgroundJob/CleanupArchiveOverridesJob.php @@ -0,0 +1,59 @@ +} + */ + protected function run($argument): void { + if (!is_array($argument)) { + $this->logger->warning('Cannot clean up archive overrides: invalid argument'); + return; + } + + $nodeType = (int)($argument['nodeType'] ?? -1); + $nodeId = (int)($argument['nodeId'] ?? 0); + $userIds = $argument['userIds'] ?? []; + + if ($nodeId <= 0 || !is_array($userIds) || $userIds === []) { + return; + } + if (!in_array($nodeType, [Application::NODE_TYPE_TABLE, Application::NODE_TYPE_CONTEXT], true)) { + $this->logger->warning('Cannot clean up archive overrides: unsupported node type ' . $nodeType); + return; + } + + foreach ($userIds as $userId) { + $this->archiveCleanupService->removeOverrideIfStale((string)$userId, $nodeType, $nodeId); + } + } +} diff --git a/lib/Db/ShareMapper.php b/lib/Db/ShareMapper.php index c8a42db6bc..ee34f1fa58 100644 --- a/lib/Db/ShareMapper.php +++ b/lib/Db/ShareMapper.php @@ -301,6 +301,28 @@ public function changeReceiverForNode(string $nodeType, int $nodeId, string $new ->executeStatement(); } + /** + * Find the distinct nodes shared with a given receiver. + * + * @return list + * @throws Exception + */ + public function findNodesByReceiver(string $receiver, string $receiverType): array { + $qb = $this->db->getQueryBuilder(); + $qb->selectDistinct(['node_type', 'node_id']) + ->from($this->table) + ->where($qb->expr()->eq('receiver', $qb->createNamedParameter($receiver, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('receiver_type', $qb->createNamedParameter($receiverType, IQueryBuilder::PARAM_STR))); + + $result = $qb->executeQuery(); + $nodes = []; + while ($row = $result->fetchAssociative()) { + $nodes[] = ['nodeType' => (string)$row['node_type'], 'nodeId' => (int)$row['node_id']]; + } + $result->closeCursor(); + return $nodes; + } + /** * @throws Exception */ diff --git a/lib/Helper/ConversionHelper.php b/lib/Helper/ConversionHelper.php index 04b0015343..c2681ca687 100644 --- a/lib/Helper/ConversionHelper.php +++ b/lib/Helper/ConversionHelper.php @@ -21,6 +21,7 @@ public static function constNodeType2String(int $nodeType): string { return match ($nodeType) { Application::NODE_TYPE_TABLE => 'table', Application::NODE_TYPE_VIEW => 'view', + Application::NODE_TYPE_CONTEXT => 'context', default => throw new InvalidArgumentException('Invalid node type'), }; } @@ -36,6 +37,23 @@ public static function stringNodeType2Const(string $nodeType): int { }; } + /** + * Map a node type as stored in `tables_shares`, which unlike the node + * types above also covers contexts. + * + * Returns null for anything unknown so callers can skip it. Kept separate + * from stringNodeType2Const() on purpose: that method doubles as a + * validation gate for endpoints that only handle tables and views. + */ + public static function shareNodeType2Const(string $nodeType): ?int { + return match ($nodeType) { + 'table', 'tables' => Application::NODE_TYPE_TABLE, + 'view', 'views' => Application::NODE_TYPE_VIEW, + 'context', 'contexts' => Application::NODE_TYPE_CONTEXT, + default => null, + }; + } + public static function object2String(Table|View $node): string { if ($node instanceof Table) { return 'table'; diff --git a/lib/Listener/ArchiveCleanupListener.php b/lib/Listener/ArchiveCleanupListener.php new file mode 100644 index 0000000000..21133420ee --- /dev/null +++ b/lib/Listener/ArchiveCleanupListener.php @@ -0,0 +1,84 @@ + + */ +class ArchiveCleanupListener implements IEventListener { + public function __construct( + private readonly ArchiveCleanupService $archiveCleanupService, + private readonly LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof UserRemovedEvent) { + $this->archiveCleanupService->cleanupAfterMembershipLoss( + [$event->getUser()->getUID()], + $event->getGroup()->getGID(), + ShareReceiverType::GROUP, + ); + return; + } + + if ($event instanceof CircleMemberRemovedEvent) { + $this->handleCircleMemberRemoved($event); + } + } + + private function handleCircleMemberRemoved(CircleMemberRemovedEvent $event): void { + try { + $member = $event->getMember(); + if ($member === null) { + return; + } + + if ($member->getUserType() === Member::TYPE_CIRCLE) { + $basedOn = $member->getBasedOn(); + $members = $basedOn !== null ? $basedOn->getInheritedMembers() : []; + } else { + $members = [$member]; + } + + $userIds = []; + foreach ($members as $affectedMember) { + if ($affectedMember->getUserType() === Member::TYPE_USER) { + $userIds[] = $affectedMember->getUserId(); + } + } + + if ($userIds === []) { + return; + } + + $this->archiveCleanupService->cleanupAfterMembershipLoss( + $userIds, + $event->getCircle()->getSingleId(), + ShareReceiverType::CIRCLE, + ); + } catch (\Throwable $e) { + $this->logger->warning('cleanup of archive overrides after circle member removal failed: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } +} diff --git a/lib/Listener/ReceiverCleanupListener.php b/lib/Listener/ReceiverCleanupListener.php index a5cfe3a470..0919584e3d 100644 --- a/lib/Listener/ReceiverCleanupListener.php +++ b/lib/Listener/ReceiverCleanupListener.php @@ -10,6 +10,7 @@ use OCA\Circles\Events\CircleDestroyedEvent; use OCA\Tables\Constants\ShareReceiverType; use OCA\Tables\Db\ShareMapper; +use OCA\Tables\Service\ArchiveCleanupService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Group\Events\GroupDeletedEvent; @@ -20,6 +21,7 @@ class ReceiverCleanupListener implements IEventListener { public function __construct( private readonly ShareMapper $shareMapper, + private readonly ArchiveCleanupService $archiveCleanupService, private readonly LoggerInterface $logger, ) { } @@ -27,10 +29,35 @@ public function __construct( public function handle(Event $event): void { if ($event instanceof UserDeletedEvent) { $this->cleanupByParticipant(ShareReceiverType::USER, $event->getUser()->getUID()); + $this->archiveCleanupService->cleanupDeletedUser($event->getUser()->getUID()); } elseif ($event instanceof GroupDeletedEvent) { - $this->cleanupByParticipant(ShareReceiverType::GROUP, $event->getGroup()->getGID()); + $this->cleanupReceiverGone(ShareReceiverType::GROUP, $event->getGroup()->getGID()); } elseif ($event instanceof CircleDestroyedEvent) { - $this->cleanupByParticipant(ShareReceiverType::CIRCLE, $event->getCircle()->getSingleId()); + $this->cleanupReceiverGone(ShareReceiverType::CIRCLE, $event->getCircle()->getSingleId()); + } + } + + /** + * Delete all shares of a removed group or circle receiver and drop the + * archive overrides of every ex-member who lost access to the affected + * nodes with those shares. + */ + private function cleanupReceiverGone(string $type, string $participant): void { + try { + $nodes = $this->shareMapper->findNodesByReceiver($participant, $type); + } catch (\Throwable $e) { + $this->logger->warning('collecting nodes shared with deleted receiver has failed: ' . $e->getMessage(), [ + 'exception' => $e, + 'receiver_type' => $type, + 'receiver' => $participant, + ]); + $nodes = []; + } + + $this->cleanupByParticipant($type, $participant); + + foreach ($nodes as $node) { + $this->archiveCleanupService->purgeShareNodeOverrides((string)$node['nodeType'], $node['nodeId']); } } diff --git a/lib/Service/ArchiveCleanupService.php b/lib/Service/ArchiveCleanupService.php new file mode 100644 index 0000000000..c817afc3ff --- /dev/null +++ b/lib/Service/ArchiveCleanupService.php @@ -0,0 +1,252 @@ +shareMapper->findNodesByReceiver($receiver, $receiverType); + } catch (\Throwable $e) { + $this->logNonFatal(__FUNCTION__, $e); + return; + } + + $nodeIdsByType = [ + Application::NODE_TYPE_TABLE => [], + Application::NODE_TYPE_CONTEXT => [], + ]; + foreach ($nodes as $node) { + $nodeType = $this->shareNodeType2Const($node['nodeType']); + if ($nodeType !== null) { + $nodeIdsByType[$nodeType][] = $node['nodeId']; + } + } + + $affectedUsersByNode = []; + foreach ($userIds as $userId) { + foreach ($nodeIdsByType as $nodeType => $nodeIds) { + if ($nodeIds === []) { + continue; + } + try { + $overrides = $this->userArchiveMapper->findAllOverridesForUser($userId, $nodeType, $nodeIds); + } catch (\Throwable $e) { + $this->logNonFatal(__FUNCTION__, $e); + continue; + } + foreach (array_keys($overrides) as $nodeId) { + $affectedUsersByNode[$nodeType][$nodeId][] = $userId; + } + } + } + + foreach ($affectedUsersByNode as $nodeType => $usersByNodeId) { + foreach ($usersByNodeId as $nodeId => $affectedUserIds) { + $this->scheduleCleanup((int)$nodeType, (int)$nodeId, $affectedUserIds); + } + } + } + + /** + * Remove the stale archive overrides of a node addressed by its share + * node-type string; share types without archive support (views) are + * ignored. + */ + public function purgeShareNodeOverrides(string $shareNodeType, int $nodeId): void { + $nodeType = $this->shareNodeType2Const($shareNodeType); + if ($nodeType === null) { + return; + } + $this->purgeNodeOverrides($nodeType, $nodeId); + } + + /** + * Remove the archive overrides of every user who can no longer access + * the given node. + * + * Called when a group or circle receiver of the node is deleted, or when + * a group or circle share of the node is removed. + */ + public function purgeNodeOverrides(int $nodeType, int $nodeId): void { + try { + $userIds = $this->userArchiveMapper->findUserIdsForNode($nodeType, $nodeId); + } catch (\Throwable $e) { + $this->logNonFatal(__FUNCTION__, $e); + return; + } + + $this->scheduleCleanup($nodeType, $nodeId, $userIds); + } + + /** + * Hand the per-user access checks to a background job. + * + * Each check is a full permission resolution, so doing them inline would + * make an unrelated request (deleting one share, removing one member) + * scale with the number of users holding an override. + * + * @param list $userIds + */ + private function scheduleCleanup(int $nodeType, int $nodeId, array $userIds): void { + if ($userIds === []) { + return; + } + + // A job argument is stored as JSON and rejected above 4000 bytes, so a + // widely shared node is split across several jobs rather than silently + // failing to schedule. + foreach (array_chunk(array_values(array_unique($userIds)), self::JOB_USER_CHUNK_SIZE) as $chunk) { + try { + $this->jobList->add(CleanupArchiveOverridesJob::class, [ + 'nodeType' => $nodeType, + 'nodeId' => $nodeId, + 'userIds' => $chunk, + ]); + } catch (\Throwable $e) { + $this->logNonFatal(__FUNCTION__, $e); + } + } + } + + /** + * Remove archive overrides that became stale because a share was deleted. + */ + public function cleanupAfterShareDeletion(Share $share): void { + $nodeType = $this->shareNodeType2Const((string)$share->getNodeType()); + if ($nodeType === null) { + return; + } + + $receiverType = $share->getReceiverType(); + if ($receiverType === ShareReceiverType::USER) { + $this->removeOverrideIfStale((string)$share->getReceiver(), $nodeType, (int)$share->getNodeId()); + } elseif ($receiverType === ShareReceiverType::GROUP || $receiverType === ShareReceiverType::CIRCLE) { + $this->purgeNodeOverrides($nodeType, (int)$share->getNodeId()); + } + } + + /** + * Remove every archive override of a deleted user account. + */ + public function cleanupDeletedUser(string $userId): void { + try { + $this->userArchiveMapper->deleteAllForUser($userId); + } catch (\Throwable $e) { + $this->logNonFatal(__FUNCTION__, $e); + } + } + + /** + * Drop a user's archive override for a node they can no longer reach. + * + * Team shares are taken into account, which plain context access checks do + * not do, so this is the only correct way to decide the question. + */ + public function removeOverrideIfStale(string $userId, int $nodeType, int $nodeId): void { + try { + if ($this->hasAccess($userId, $nodeType, $nodeId)) { + return; + } + $this->userArchiveMapper->deleteForUser($userId, $nodeType, $nodeId); + } catch (\Throwable $e) { + $this->logNonFatal(__FUNCTION__, $e); + } + } + + private function hasAccess(string $userId, int $nodeType, int $nodeId): bool { + if ($nodeType !== Application::NODE_TYPE_CONTEXT) { + return $this->permissionsService->canAccessNodeById($nodeType, $nodeId, $userId); + } + if ($this->permissionsService->canAccessContextById($nodeId, $userId)) { + return true; + } + + // Context access resolves through ContextMapper, which only considers + // the owner plus user and group shares, never team shares. Ask the + // share table directly, otherwise a user whose only access is a team + // share looks like they lost it and their override gets deleted. + return $this->hasTeamShareFor($userId, $nodeId); + } + + private function hasTeamShareFor(string $userId, int $nodeId): bool { + $circleIds = $this->circleHelper->getCircleIdsForUser($userId); + if (empty($circleIds)) { + return false; + } + + $shares = $this->shareMapper->findAllSharesForNodeTo('context', $nodeId, $userId, [], $circleIds); + foreach ($shares as $share) { + if ($share->getReceiverType() === ShareReceiverType::CIRCLE) { + return true; + } + } + return false; + } + + private function shareNodeType2Const(string $nodeType): ?int { + $nodeTypeConst = ConversionHelper::shareNodeType2Const($nodeType); + if ($nodeTypeConst === null) { + return null; + } + // Only tables and contexts can carry archive overrides; view shares + // must not trigger pointless lookup or access-check queries. + return in_array($nodeTypeConst, [Application::NODE_TYPE_TABLE, Application::NODE_TYPE_CONTEXT], true) + ? $nodeTypeConst + : null; + } + + private function logNonFatal(string $method, \Throwable $e): void { + $this->logger->warning(static::class . ' - ' . $method . ': archive override cleanup failed: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + } +} diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index cbbb649e05..93b9eb2401 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -71,6 +71,7 @@ public function __construct( private readonly IHasher $hasher, private readonly IShareManager $shareManager, private readonly FederationService $federationService, + private readonly ArchiveCleanupService $archiveCleanupService, ) { parent::__construct($logger, $userId, $permissionsService); } @@ -731,6 +732,9 @@ public function delete(int $id): Share { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + + $this->archiveCleanupService->cleanupAfterShareDeletion($item); + return $item; } @@ -817,6 +821,7 @@ public function deleteForShareReview(int $id): void { if ($share->getNodeType() === 'context') { $this->contextNavigationMapper->deleteByShareId($share->getId()); } + $this->archiveCleanupService->cleanupAfterShareDeletion($share); } public function deleteAllForTable(Table $table):void { From 32b3624cf2537fbf77b57ed673995640d8271b23 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 12 Sep 2026 11:27:02 +0200 Subject: [PATCH 6/8] feat(archive): add archiving to the navigation and store Add archive/unarchive actions to the table and context navigation items, a collapsible archived-applications section, and the matching store actions that guard against a missing local item. Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Andy Scherzinger --- .../partials/NavigationContextItem.vue | 30 +++++++- .../partials/NavigationTableItem.vue | 15 ++-- .../navigation/sections/Navigation.vue | 23 +++++- src/store/store.js | 77 ++++++++++++++++++- 4 files changed, 135 insertions(+), 10 deletions(-) diff --git a/src/modules/navigation/partials/NavigationContextItem.vue b/src/modules/navigation/partials/NavigationContextItem.vue index 73c0416a30..4945364664 100644 --- a/src/modules/navigation/partials/NavigationContextItem.vue +++ b/src/modules/navigation/partials/NavigationContextItem.vue @@ -42,6 +42,22 @@ {{ t('tables', 'Transfer application') }} + + + + {{ t('tables', 'Archive application') }} + + + + + + {{ t('tables', 'Unarchive application') }} + + {{ t('tables', 'Delete application') }} + {{ t('tables', 'Show in app list') }} @@ -65,6 +82,8 @@ import PlaylistEdit from 'vue-material-design-icons/PlaylistEdit.vue' import FileSwapOutline from 'vue-material-design-icons/FileSwapOutline.vue' import DeleteOutline from 'vue-material-design-icons/TrashCanOutline.vue' import TrayArrowDown from 'vue-material-design-icons/TrayArrowDown.vue' +import ArchiveArrowDownOutline from 'vue-material-design-icons/ArchiveArrowDownOutline.vue' +import ArchiveArrowUpOutline from 'vue-material-design-icons/ArchiveArrowUpOutline.vue' import permissionsMixin from '../../../shared/components/ncTable/mixins/permissionsMixin.js' import svgHelper from '../../../shared/components/ncIconPicker/mixins/svgHelper.js' import { NAV_ENTRY_MODE } from '../../../shared/constants.ts' @@ -83,6 +102,8 @@ export default { FileSwapOutline, TableIcon, DeleteOutline, + ArchiveArrowDownOutline, + ArchiveArrowUpOutline, NcIconSvgWrapper, NcAppNavigationItem, NcActionButton, @@ -119,7 +140,7 @@ export default { }, methods: { - ...mapActions(useTablesStore, ['updateDisplayMode']), + ...mapActions(useTablesStore, ['updateDisplayMode', 'archiveContext', 'unarchiveContext']), emit, async editContext() { emit('tables:context:edit', this.context.id) @@ -156,6 +177,13 @@ export default { } return false }, + async toggleArchiveContext(archived) { + if (archived) { + await this.archiveContext({ id: this.context.id }) + } else { + await this.unarchiveContext({ id: this.context.id }) + } + }, async changeDisplayMode() { const value = !this.showInNavigation const displayMode = value ? NAV_ENTRY_MODE.NAV_ENTRY_MODE_ALL : NAV_ENTRY_MODE.NAV_ENTRY_MODE_HIDDEN diff --git a/src/modules/navigation/partials/NavigationTableItem.vue b/src/modules/navigation/partials/NavigationTableItem.vue index 14df41fbc2..92651faf71 100644 --- a/src/modules/navigation/partials/NavigationTableItem.vue +++ b/src/modules/navigation/partials/NavigationTableItem.vue @@ -109,7 +109,7 @@ - {{ t('tables', 'Archive table') }}