Skip to content
Open
4 changes: 4 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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'],
Expand Down
6 changes: 6 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
59 changes: 59 additions & 0 deletions lib/BackgroundJob/CleanupArchiveOverridesJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Tables\BackgroundJob;

use OCA\Tables\AppInfo\Application;
use OCA\Tables\Service\ArchiveCleanupService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;
use Psr\Log\LoggerInterface;

/**
* Drops the archive overrides of users who lost access to a node.
*
* Deciding this per user costs a full permission resolution, so it runs out
* of band: the triggering request (a share deletion, a group or team removal)
* only records which users to check for which node.
*/
class CleanupArchiveOverridesJob extends QueuedJob {
public function __construct(
ITimeFactory $time,
private readonly ArchiveCleanupService $archiveCleanupService,
private readonly LoggerInterface $logger,
) {
parent::__construct($time);
}

/**
* @param mixed $argument array{nodeType: int, nodeId: int, userIds: list<string>}
*/
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);
}
}
}
6 changes: 4 additions & 2 deletions lib/Command/RenameTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
;
}
Expand All @@ -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;
Comment on lines +74 to +75

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there is now no way to unarchive a table with occ tables:rename ?

$description = $input->getOption('description');

try {
Expand Down
9 changes: 6 additions & 3 deletions lib/Controller/Api1Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()];
Expand All @@ -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<Http::STATUS_OK, TablesTable, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 200: Tables returned
Expand All @@ -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) {
Expand Down
61 changes: 60 additions & 1 deletion lib/Controller/ApiTablesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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 @@ -87,7 +88,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) {
Expand Down Expand Up @@ -479,6 +480,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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems misleading, since the owner archive path does not clear personal rows. ArchiveService::archiveForUser() only sets tables_tables.archived. Could it say that the owner sets the default, and that users who made their own choice keep it?

* 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 {
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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar as at https://github.com/nextcloud/tables/pull/2476/changes#r4309477879 . The owner archive path does not clear personal rows.

* 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 {
return $this->archiveResponse($id, false);
}

/**
* @return DataResponse<Http::STATUS_OK, TablesTable, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*/
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
*
Expand Down
59 changes: 59 additions & 0 deletions lib/Controller/ContextController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar as at https://github.com/nextcloud/tables/pull/2476/changes#r4309477879 . The owner archive path does not clear personal rows.

* 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, 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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar as at https://github.com/nextcloud/tables/pull/2476/changes#r4309477879 . The owner archive path does not clear personal rows.

* 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, typeParam: 'context', idParam: 'contextId')]
public function unarchiveContext(int $contextId): DataResponse {
return $this->archiveResponse($contextId, false);
}

/**
* @return DataResponse<Http::STATUS_OK, TablesContext, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*/
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
*
Expand Down
2 changes: 1 addition & 1 deletion lib/Controller/TableController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 6 additions & 1 deletion lib/Db/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/Db/ContextMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading