Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
018fa3d
feat(archive): add per-user archive table and contexts.archived column
AndyScherzinger Apr 12, 2026
6115462
feat(archive): add archived field to Context entity and mapper
AndyScherzinger Apr 12, 2026
cb42481
feat(archive): add UserArchive entity and mapper
AndyScherzinger Apr 12, 2026
0b08eaa
feat(archive): add ArchiveService with owner and per-user logic
AndyScherzinger Apr 12, 2026
b837072
feat(archive): add per-user archive state to table and context responses
AndyScherzinger Apr 12, 2026
70b1d5d
feat(archive): migrate per-user archive overrides when ownership is t…
AndyScherzinger Apr 12, 2026
1578b3f
feat(archive): add archive/unarchive endpoints for tables and contexts
AndyScherzinger Apr 12, 2026
cd4cf2b
feat(archive): register archive/unarchive routes for tables and contexts
AndyScherzinger Apr 12, 2026
1ce6751
chore(archive): regenerate openapi.json and TypeScript types
AndyScherzinger Apr 12, 2026
09483c0
test(archive): add unit tests for ArchiveService, mapper and controllers
AndyScherzinger Apr 12, 2026
1b51346
fix(archive): add concrete isArchived() methods to archive entities
AndyScherzinger Apr 12, 2026
8f3f38a
fix(test): remove unused import
AndyScherzinger Apr 12, 2026
75ca85e
test(count): Bump query count due to added extra tests
AndyScherzinger Apr 12, 2026
5692304
feat(archive): add archive and unarchive store actions
AndyScherzinger Apr 12, 2026
177242e
feat(archive): add archive/unarchive action to table navigation item
AndyScherzinger Apr 12, 2026
d6bc6bd
feat(archive): add archive/unarchive action/section to context naviga…
AndyScherzinger Apr 12, 2026
fd235a6
test(archive): add e2e tests for archive and unarchive flows
AndyScherzinger Apr 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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+)']],
Expand Down
1 change: 1 addition & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
57 changes: 57 additions & 0 deletions lib/Controller/ApiTablesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Http::STATUS_OK, TablesTable, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 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<Http::STATUS_OK, TablesTable, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 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
*
Expand Down
49 changes: 49 additions & 0 deletions lib/Controller/ContextController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Http::STATUS_OK, TablesContext, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 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<Http::STATUS_OK, TablesContext, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 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
*
Expand Down
10 changes: 9 additions & 1 deletion lib/Db/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* @method setOwnerId(string $value): void
* @method getOwnerType(): int
* @method setOwnerType(int $value): void
* @method setArchived(bool $value): void
*
* @method getSharing(): array
* @method setSharing(array $value): void
Expand All @@ -34,6 +35,7 @@ class Context extends EntitySuper implements JsonSerializable {
protected ?string $description = null;
protected ?string $ownerId = null;
protected ?int $ownerType = null;
protected bool $archived = false;

// virtual properties
protected ?array $sharing = null;
Expand All @@ -45,6 +47,11 @@ class Context extends EntitySuper implements JsonSerializable {
public function __construct() {
$this->addType('id', 'integer');
$this->addType('owner_type', 'integer');
$this->addType('archived', 'boolean');
}

public function isArchived(): bool {
return $this->archived;
}

public function jsonSerialize(): array {
Expand All @@ -55,7 +62,8 @@ public function jsonSerialize(): array {
'iconName' => $this->getIcon(),
'description' => $this->getDescription(),
'owner' => $this->getOwnerId(),
'ownerType' => $this->getOwnerType()
'ownerType' => $this->getOwnerType(),
'archived' => $this->isArchived(),
];

// extended data
Expand Down
1 change: 1 addition & 0 deletions lib/Db/ContextMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions lib/Db/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ public function __construct() {
$this->addType('archived', 'boolean');
}

public function isArchived(): bool {
return $this->archived;
}

/**
* @psalm-return TablesTable
*/
Expand Down
35 changes: 35 additions & 0 deletions lib/Db/UserArchive.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Tables\Db;

/**
* @method getUserId(): string
* @method setUserId(string $value): void
* @method getNodeType(): int
* @method setNodeType(int $value): void
* @method getNodeId(): int
* @method setNodeId(int $value): void
* @method setArchived(bool $value): void
*/
class UserArchive extends EntitySuper {
protected ?string $userId = null;
protected ?int $nodeType = null;
protected ?int $nodeId = null;
protected bool $archived = true;

public function __construct() {
$this->addType('id', 'integer');
$this->addType('node_type', 'integer');
$this->addType('node_id', 'integer');
$this->addType('archived', 'boolean');
}

public function isArchived(): bool {
return $this->archived;
}
}
141 changes: 141 additions & 0 deletions lib/Db/UserArchiveMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Tables\Db;

use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;

/** @template-extends QBMapper<UserArchive> */
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<int, UserArchive> 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();
}
}
Loading
Loading