Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c4d2001
feat(archive): add per-user archive table and contexts.archived column
AndyScherzinger Apr 12, 2026
091f9d4
feat(archive): add archived field to Context entity and mapper
AndyScherzinger Apr 12, 2026
239eabf
feat(archive): add UserArchive entity and mapper
AndyScherzinger Apr 12, 2026
022b98c
feat(archive): add ArchiveService with owner and per-user logic
AndyScherzinger Apr 12, 2026
102814e
feat(archive): add per-user archive state to table and context responses
AndyScherzinger Apr 12, 2026
a44192c
feat(archive): migrate per-user archive overrides when ownership is t…
AndyScherzinger Apr 12, 2026
8fa12b3
feat(archive): add archive/unarchive endpoints for tables and contexts
AndyScherzinger Apr 12, 2026
2d153d2
feat(archive): register archive/unarchive routes for tables and contexts
AndyScherzinger Apr 12, 2026
35194f2
chore(archive): regenerate openapi.json and TypeScript types
AndyScherzinger Apr 12, 2026
c871b67
test(archive): add unit tests for ArchiveService, mapper and controllers
AndyScherzinger Apr 12, 2026
2fedf5e
fix(archive): add concrete isArchived() methods to archive entities
AndyScherzinger Apr 12, 2026
a19298d
fix(test): remove unused import
AndyScherzinger Apr 12, 2026
f750849
feat(archive): add archive and unarchive store actions
AndyScherzinger Apr 12, 2026
32a3068
feat(archive): add archive/unarchive action to table navigation item
AndyScherzinger Apr 12, 2026
286f062
feat(archive): add archive/unarchive action/section to context naviga…
AndyScherzinger Apr 12, 2026
4a7cae2
test(archive): add e2e tests for archive and unarchive flows
AndyScherzinger Apr 12, 2026
bc1cbf8
style(archiving): Use outline variant
AndyScherzinger Apr 28, 2026
8338dd8
style(archiving): Put (un)archive above the delete menu item
AndyScherzinger Apr 28, 2026
5e9f627
fix(archiving): Annotate endpoints for clarity while the business log…
AndyScherzinger Apr 28, 2026
dbf0d52
perf(archiving): Create the query outside the loop with a parameter.
AndyScherzinger Apr 28, 2026
90c52c7
style(archiving): Boolean getters can also be used as isXXX and not j…
AndyScherzinger Apr 28, 2026
8f2669b
docs(archiving): Update openAPI
AndyScherzinger Apr 28, 2026
086aad3
test(archiving): Add ArchiveService mock
AndyScherzinger Apr 28, 2026
53e2c70
test(perf): Raise query count due to added tests / business logic
AndyScherzinger Apr 28, 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 @@ -24,6 +24,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 @@ -370,6 +371,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
59 changes: 59 additions & 0 deletions lib/Controller/ContextController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@
namespace OCA\Tables\Controller;

use InvalidArgumentException;
use OCA\Tables\AppInfo\Application;
use OCA\Tables\Db\Context;
use OCA\Tables\Errors\BadRequestError;
use OCA\Tables\Errors\InternalError;
use OCA\Tables\Errors\NotFoundError;
use OCA\Tables\Errors\PermissionError;
use OCA\Tables\Middleware\Attribute\RequirePermission;
use OCA\Tables\ResponseDefinitions;
use OCA\Tables\Service\ContextService;
use OCP\AppFramework\Db\DoesNotExistException;
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 +249,62 @@ 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_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 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, type: Application::NODE_TYPE_CONTEXT, idParam: 'contextId')]
public function archiveContext(int $contextId): DataResponse {
try {
return new DataResponse($this->contextService->archiveContext($contextId, $this->userId)->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] 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_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 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, type: Application::NODE_TYPE_CONTEXT, idParam: 'contextId')]
public function unarchiveContext(int $contextId): DataResponse {
try {
return new DataResponse($this->contextService->unarchiveContext($contextId, $this->userId)->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
*
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 @@ -93,6 +93,10 @@ public function __construct() {
$this->addType('archived', 'boolean');
}

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

/**
* @psalm-return TablesTable
*/
Expand Down
32 changes: 32 additions & 0 deletions lib/Db/UserArchive.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?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
* @method isArchived(): bool
*/
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');
}
}
Loading
Loading