From fef56047abd1ce7866a1d5da5ad0c518933fe64c Mon Sep 17 00:00:00 2001 From: Jaggob <37583151+Jaggob@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:36:36 +0200 Subject: [PATCH 01/86] Give a pad's row the two dates a sweep of files gone for good needs trashed_at says when the file was seen going to a trash or being deleted; missing_since, when a sweep first missed it without that. Both nullable and indexed. Nothing writes them yet. --- .../Version000005Date20260926150000.php | 51 +++++++++++++++++++ lib/Service/Binding.php | 9 ++++ .../phpunit/Support/InMemoryBindingQuery.php | 32 ++++++++---- .../phpunit/Support/InMemoryBindingTable.php | 13 ++++- tests/phpunit/unit/BindingServiceTest.php | 8 +-- tests/phpunit/unit/BindingTest.php | 10 ++-- 6 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 lib/Migration/Version000005Date20260926150000.php diff --git a/lib/Migration/Version000005Date20260926150000.php b/lib/Migration/Version000005Date20260926150000.php new file mode 100644 index 00000000..773e2f73 --- /dev/null +++ b/lib/Migration/Version000005Date20260926150000.php @@ -0,0 +1,51 @@ +hasTable(BindingService::TABLE)) { + return null; + } + $table = $schema->getTable(BindingService::TABLE); + $changed = false; + foreach (['trashed_at' => 'ep_bind_trashed_idx', 'missing_since' => 'ep_bind_missing_idx'] as $column => $index) { + if (!$table->hasColumn($column)) { + $table->addColumn($column, 'bigint', [ + 'notnull' => false, + ]); + $changed = true; + } + if (!$table->hasIndex($index)) { + $table->addIndex([$column], $index); + $changed = true; + } + } + return $changed ? $schema : null; + } +} diff --git a/lib/Service/Binding.php b/lib/Service/Binding.php index fc8e7b33..6be13ff2 100644 --- a/lib/Service/Binding.php +++ b/lib/Service/Binding.php @@ -28,6 +28,13 @@ public function __construct( */ public readonly ?int $deletedAt = null, public readonly int $updatedAt = 0, + /** + * When the file was seen going to a trash or being deleted: once it is + * gone from the file cache after that, it is gone for good. + */ + public readonly ?int $trashedAt = null, + /** When a sweep first missed the file without that: the grace period's start. */ + public readonly ?int $missingSince = null, ) { } @@ -45,6 +52,8 @@ public static function fromRow(array $row): self { state: DbRows::string($row, 'state'), deletedAt: DbRows::nullableInt($row, 'deleted_at'), updatedAt: DbRows::int($row, 'updated_at'), + trashedAt: DbRows::nullableInt($row, 'trashed_at'), + missingSince: DbRows::nullableInt($row, 'missing_since'), ); } diff --git a/tests/phpunit/Support/InMemoryBindingQuery.php b/tests/phpunit/Support/InMemoryBindingQuery.php index d9131aa3..071dbd84 100644 --- a/tests/phpunit/Support/InMemoryBindingQuery.php +++ b/tests/phpunit/Support/InMemoryBindingQuery.php @@ -19,7 +19,7 @@ final class InMemoryBindingQuery implements IQueryBuilder { private string $statement = 'select'; /** @var array */ private array $parameters = []; - /** @var list column and the parameter it must equal */ + /** @var list<\Closure(array): bool> what a row must meet */ private array $conditions = []; /** @var array column and the parameter it is set to */ private array $assignments = []; @@ -52,14 +52,14 @@ public function set(string $column, string $parameter): self { return $this; } - /** @param array{string,string} $condition */ - public function where(array $condition): self { + /** @param \Closure(array): bool $condition */ + public function where(\Closure $condition): self { $this->conditions[] = $condition; return $this; } - /** @param array{string,string} $condition */ - public function andWhere(array $condition): self { + /** @param \Closure(array): bool $condition */ + public function andWhere(\Closure $condition): self { $this->conditions[] = $condition; return $this; } @@ -75,14 +75,24 @@ public function createNamedParameter(mixed $value, ?int $type = null): string { return $name; } - /** Its own expression builder: equality is all these statements use. */ + /** Its own expression builder, for what these statements use. */ public function expr(): self { return $this; } - /** @return array{string,string} */ - public function eq(string $column, string $parameter): array { - return [$column, $parameter]; + /** @return \Closure(array): bool */ + public function eq(string $column, string $parameter): \Closure { + return fn (array $row): bool => (string)($row[$column] ?? '') === (string)$this->parameters[$parameter]; + } + + /** @return \Closure(array): bool */ + public function isNull(string $column): \Closure { + return static fn (array $row): bool => ($row[$column] ?? null) === null; + } + + /** @return \Closure(array): bool */ + public function isNotNull(string $column): \Closure { + return static fn (array $row): bool => ($row[$column] ?? null) !== null; } public function executeStatement(): int { @@ -126,8 +136,8 @@ public function closeCursor(): void { private function matchingKeys(): array { $keys = []; foreach ($this->table->rows as $key => $row) { - foreach ($this->conditions as [$column, $parameter]) { - if ((string)($row[$column] ?? '') !== (string)$this->parameters[$parameter]) { + foreach ($this->conditions as $condition) { + if (!$condition($row)) { continue 2; } } diff --git a/tests/phpunit/Support/InMemoryBindingTable.php b/tests/phpunit/Support/InMemoryBindingTable.php index 89275d9b..b16e7473 100644 --- a/tests/phpunit/Support/InMemoryBindingTable.php +++ b/tests/phpunit/Support/InMemoryBindingTable.php @@ -19,8 +19,17 @@ * a condition left out changes which rows are hit, and a test sees it. */ final class InMemoryBindingTable implements IDBConnection { - /** @param list> $rows */ - public function __construct(public array $rows) { + /** @var list> */ + public array $rows; + + /** + * A row given without trashed_at or missing_since has them NULL, as the + * columns default to in the table. + * + * @param list> $rows + */ + public function __construct(array $rows) { + $this->rows = array_map(static fn (array $row): array => $row + ['trashed_at' => null, 'missing_since' => null], $rows); } public function getQueryBuilder(): IQueryBuilder { diff --git a/tests/phpunit/unit/BindingServiceTest.php b/tests/phpunit/unit/BindingServiceTest.php index 60b05ae8..00ba9e2c 100644 --- a/tests/phpunit/unit/BindingServiceTest.php +++ b/tests/phpunit/unit/BindingServiceTest.php @@ -240,7 +240,7 @@ public function testRebindMovesARowOnlyWhileItNamesThatPadInThatState(): void { self::assertTrue($service->rebind(1, 'old', BindingService::STATE_PENDING_DELETE, 'new', BindingService::STATE_ACTIVE)); self::assertSame([ - ['file_id' => 1, 'pad_id' => 'new', 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => BindingService::STATE_ACTIVE, 'deleted_at' => null, 'updated_at' => 500], + ['file_id' => 1, 'pad_id' => 'new', 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => BindingService::STATE_ACTIVE, 'deleted_at' => null, 'updated_at' => 500, 'trashed_at' => null, 'missing_since' => null], self::bindingRow(2, 'other', BindingService::STATE_PENDING_DELETE), ], $table->rows); } @@ -266,7 +266,7 @@ public function testTransitionToPendingDeleteDatesTheDeletionAnew(): void { self::assertTrue($service->transition(1, 'pad', BindingService::STATE_RESTORE_PENDING, BindingService::STATE_PENDING_DELETE)); self::assertSame( - ['file_id' => 1, 'pad_id' => 'pad', 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => BindingService::STATE_PENDING_DELETE, 'deleted_at' => 500, 'updated_at' => 500], + ['file_id' => 1, 'pad_id' => 'pad', 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => BindingService::STATE_PENDING_DELETE, 'deleted_at' => 500, 'updated_at' => 500, 'trashed_at' => null, 'missing_since' => null], $table->rows[0], ); } @@ -346,7 +346,7 @@ public function testARowThatStaysOwedKeepsItsDate(): void { self::assertTrue($service->transition(1, 'pad', BindingService::STATE_PENDING_DELETE, BindingService::STATE_PENDING_DELETE)); self::assertSame( - ['file_id' => 1, 'pad_id' => 'pad', 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => BindingService::STATE_PENDING_DELETE, 'deleted_at' => 100, 'updated_at' => 500], + ['file_id' => 1, 'pad_id' => 'pad', 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => BindingService::STATE_PENDING_DELETE, 'deleted_at' => 100, 'updated_at' => 500, 'trashed_at' => null, 'missing_since' => null], $table->rows[0], ); } @@ -355,7 +355,7 @@ public function testARowThatStaysOwedKeepsItsDate(): void { private static function bindingRow(int $fileId, string $padId, string $state, string $accessMode = BindingService::ACCESS_PUBLIC): array { // Dated only as a deletion owed, as the table holds it: leaving that state clears the date. $deletedAt = $state === BindingService::STATE_PENDING_DELETE ? 100 : null; - return ['file_id' => $fileId, 'pad_id' => $padId, 'access_mode' => $accessMode, 'state' => $state, 'deleted_at' => $deletedAt, 'updated_at' => 100]; + return ['file_id' => $fileId, 'pad_id' => $padId, 'access_mode' => $accessMode, 'state' => $state, 'deleted_at' => $deletedAt, 'updated_at' => 100, 'trashed_at' => null, 'missing_since' => null]; } } diff --git a/tests/phpunit/unit/BindingTest.php b/tests/phpunit/unit/BindingTest.php index 20e736ac..611be0fb 100644 --- a/tests/phpunit/unit/BindingTest.php +++ b/tests/phpunit/unit/BindingTest.php @@ -30,16 +30,18 @@ public function testReadsEachColumnIntoItsPlace(): void { 'deleted_at' => 100, 'created_at' => 50, 'updated_at' => 200, + 'trashed_at' => 150, + 'missing_since' => 180, ]); - $this->assertEquals(new Binding(7, 'nc-abc', BindingService::ACCESS_PROTECTED, BindingService::STATE_PENDING_DELETE, 100, 200), $binding); + $this->assertEquals(new Binding(7, 'nc-abc', BindingService::ACCESS_PROTECTED, BindingService::STATE_PENDING_DELETE, 100, 200, 150, 180), $binding); } /** Only a deletion owed has a date for it; a row without one says so, not 0. */ public function testARowWithoutADeletionOwedHasNoDateForIt(): void { - $binding = Binding::fromRow(['file_id' => 7, 'pad_id' => 'nc-abc', 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => BindingService::STATE_ACTIVE, 'deleted_at' => null, 'updated_at' => 200]); + $binding = Binding::fromRow(['file_id' => 7, 'pad_id' => 'nc-abc', 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => BindingService::STATE_ACTIVE, 'deleted_at' => null, 'updated_at' => 200, 'trashed_at' => null, 'missing_since' => null]); - $this->assertNull($binding->deletedAt); + $this->assertSame([null, null, null], [$binding->deletedAt, $binding->trashedAt, $binding->missingSince]); } /** @@ -49,7 +51,7 @@ public function testARowWithoutADeletionOwedHasNoDateForIt(): void { * error where the row is read instead. */ public function testAColumnTheQueryLeftOutIsAnError(): void { - $row = ['file_id' => 7, 'pad_id' => 'nc-abc', 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => BindingService::STATE_PENDING_DELETE, 'deleted_at' => 100, 'updated_at' => 200, 'file_path' => null, 'waiting_since' => 100]; + $row = ['file_id' => 7, 'pad_id' => 'nc-abc', 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => BindingService::STATE_PENDING_DELETE, 'deleted_at' => 100, 'updated_at' => 200, 'trashed_at' => null, 'missing_since' => null, 'file_path' => null, 'waiting_since' => 100]; $reads = [ 'Binding' => static fn (array $r): mixed => Binding::fromRow($r), 'WaitingBinding' => static fn (array $r): mixed => WaitingBinding::fromRow($r), From c98d4d05a19e990a1f19fbb29cb67c76872a4622 Mon Sep 17 00:00:00 2001 From: Jaggob <37583151+Jaggob@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:43:19 +0200 Subject: [PATCH 02/86] Mark the rows of pads leaving Files A .pad file moved to a trash or deleted past it, every .pad file under a folder that goes, and every file of a user about to be deleted get trashed_at on their row: once such a file is gone from the file cache, it is gone for good, however its trash was emptied. MarkLeavingPadsListener hears MoveToTrashEvent, BeforeNodeDeletedEvent and BeforeUserDeletedEvent and never throws. A folder's files are found in the file cache by the folder's storage and path. The in-memory binding table of the tests now holds a file cache too and evaluates joins, LIKE, IN and IS NULL. --- lib/AppInfo/Application.php | 14 + lib/Listeners/MarkLeavingPadsListener.php | 84 ++++++ lib/Service/BindingService.php | 57 ++++ .../phpunit/Support/InMemoryBindingQuery.php | 244 ++++++++++++++++-- .../phpunit/Support/InMemoryBindingTable.php | 6 +- tests/phpunit/bootstrap.php | 3 + .../OCP/DB/QueryBuilder/IQueryBuilder.php | 1 + .../Events/Node/BeforeNodeDeletedEvent.php | 19 ++ tests/phpunit/stubs/OCP/Files/File.php | 2 +- tests/phpunit/stubs/OCP/Files/Folder.php | 2 +- tests/phpunit/stubs/OCP/Files/Node.php | 11 + .../User/Events/BeforeUserDeletedEvent.php | 19 ++ tests/phpunit/unit/BindingServiceTest.php | 54 ++++ .../unit/MarkLeavingPadsListenerTest.php | 90 +++++++ 14 files changed, 578 insertions(+), 28 deletions(-) create mode 100644 lib/Listeners/MarkLeavingPadsListener.php create mode 100644 tests/phpunit/stubs/OCP/Files/Events/Node/BeforeNodeDeletedEvent.php create mode 100644 tests/phpunit/stubs/OCP/Files/Node.php create mode 100644 tests/phpunit/stubs/OCP/User/Events/BeforeUserDeletedEvent.php create mode 100644 tests/phpunit/unit/MarkLeavingPadsListenerTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4a51317c..88196fd5 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -121,6 +121,20 @@ public function register(IRegistrationContext $context): void { 'OCA\\Files_Trashbin::moveToTrash', \OCA\EtherpadNextcloud\Listeners\MoveToTrashListener::class, ); + // Marks the rows of .pad files leaving Files, for the sweep of files + // gone for good; runs beside the listener above. + $context->registerEventListener( + 'OCA\\Files_Trashbin\\Events\\MoveToTrashEvent', + \OCA\EtherpadNextcloud\Listeners\MarkLeavingPadsListener::class, + ); + $context->registerEventListener( + \OCP\Files\Events\Node\BeforeNodeDeletedEvent::class, + \OCA\EtherpadNextcloud\Listeners\MarkLeavingPadsListener::class, + ); + $context->registerEventListener( + \OCP\User\Events\BeforeUserDeletedEvent::class, + \OCA\EtherpadNextcloud\Listeners\MarkLeavingPadsListener::class, + ); $context->registerEventListener( 'OCA\\Files_Trashbin\\Events\\NodeRestoredEvent', \OCA\EtherpadNextcloud\Listeners\RestoreFromTrashListener::class, diff --git a/lib/Listeners/MarkLeavingPadsListener.php b/lib/Listeners/MarkLeavingPadsListener.php new file mode 100644 index 00000000..d700a826 --- /dev/null +++ b/lib/Listeners/MarkLeavingPadsListener.php @@ -0,0 +1,84 @@ + + */ +class MarkLeavingPadsListener implements IEventListener { + public function __construct( + private BindingService $bindingService, + private IRootFolder $rootFolder, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + try { + if ($event instanceof BeforeUserDeletedEvent) { + $this->bindingService->markTrashedUnder($this->rootFolder->getUserFolder($event->getUser()->getUID())->getId(), wholeStorage: true); + return; + } + if ($event instanceof BeforeNodeDeletedEvent) { + $this->mark($event->getNode()); + return; + } + $this->markNodeOf($event); + } catch (\Throwable $e) { + $this->logger->warning('Could not mark the pads of files leaving Files; the sweep finds them later.', [ + 'app' => 'etherpad_nextcloud', + ...SafeError::context($e), + ]); + } + } + + /** MoveToTrashEvent is the trash app's, not OCP's: its node, asked for by name. */ + private function markNodeOf(Event $event): void { + if (!method_exists($event, 'getNode')) { + return; + } + $node = $event->getNode(); + if (!$node instanceof Node) { + return; + } + $this->mark($node); + } + + private function mark(Node $node): void { + if ($node instanceof Folder) { + $this->bindingService->markTrashedUnder($node->getId()); + } elseif ($node instanceof File && PadFileType::isPad($node->getName())) { + $this->bindingService->markTrashed($node->getId()); + } + } +} diff --git a/lib/Service/BindingService.php b/lib/Service/BindingService.php index e3e52532..a845cf7d 100644 --- a/lib/Service/BindingService.php +++ b/lib/Service/BindingService.php @@ -251,6 +251,63 @@ private function findWaitingByAge(string $state, string $ageColumn, int $minAgeS return $rows; } + /** + * Marks the file's row as seen going to a trash or being deleted + * (GoneFileSweep): once the file is gone from the file cache after this, + * it is gone for good. A row marked already keeps its first mark. + */ + public function markTrashed(int $fileId): void { + $qb = $this->db->getQueryBuilder(); + $qb->update(self::TABLE) + ->set('trashed_at', $qb->createNamedParameter($this->timeFactory->getTime(), IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('trashed_at')); + $qb->executeStatement(); + } + + /** + * markTrashed() for the row of every file under the folder $folderId, as + * the file cache has it now - before a trash moves it, or a delete takes + * it. With $wholeStorage, every file on the folder's storage: a user + * deleted takes their home storage with them. + */ + public function markTrashedUnder(int $folderId, bool $wholeStorage = false): void { + $folder = $this->db->getQueryBuilder(); + $folder->select('storage', 'path') + ->from('filecache') + ->where($folder->expr()->eq('fileid', $folder->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))); + $result = $folder->executeQuery(); + $row = DbRows::one($result->fetch()); + $result->closeCursor(); + if ($row === null) { + return; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('b.file_id') + ->from(self::TABLE, 'b') + ->innerJoin('b', 'filecache', 'fc', $qb->expr()->eq('b.file_id', 'fc.fileid')) + ->where($qb->expr()->eq('fc.storage', $qb->createNamedParameter(DbRows::int($row, 'storage'), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('b.trashed_at')); + $path = DbRows::string($row, 'path'); + if (!$wholeStorage && $path !== '') { + $qb->andWhere($qb->expr()->like('fc.path', $qb->createNamedParameter($this->db->escapeLikeParameter($path . '/') . '%'))); + } + $result = $qb->executeQuery(); + $fileIds = array_map(static fn (array $found): int => DbRows::int($found, 'file_id'), DbRows::all($result->fetchAll())); + $result->closeCursor(); + + $now = $this->timeFactory->getTime(); + foreach (array_chunk($fileIds, 500) as $chunk) { + $update = $this->db->getQueryBuilder(); + $update->update(self::TABLE) + ->set('trashed_at', $update->createNamedParameter($now, IQueryBuilder::PARAM_INT)) + ->where($update->expr()->in('file_id', $update->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($update->expr()->isNull('trashed_at')); + $update->executeStatement(); + } + } + public function createBinding(int $fileId, string $padId, string $accessMode): void { $this->assertAccessMode($accessMode); $now = $this->timeFactory->getTime(); diff --git a/tests/phpunit/Support/InMemoryBindingQuery.php b/tests/phpunit/Support/InMemoryBindingQuery.php index 071dbd84..19577765 100644 --- a/tests/phpunit/Support/InMemoryBindingQuery.php +++ b/tests/phpunit/Support/InMemoryBindingQuery.php @@ -12,38 +12,75 @@ use OCP\DB\QueryBuilder\IQueryBuilder; /** - * One statement against an InMemoryBindingTable. Only what the binding - * reads and conditional writes use: equality, select, update and delete. + * One statement against an InMemoryBindingTable: select, update and delete + * on the binding table, selects on the file cache, and the joins between + * the two the sweeps use. Conditions are evaluated, not recorded, so a + * condition left out changes which rows are hit. + * + * A column is named as the statement names it: plain on a table without + * an alias, `alias.column` once aliased. A condition's second operand is a + * named parameter or, in a join, a column. */ final class InMemoryBindingQuery implements IQueryBuilder { private string $statement = 'select'; + private string $table = ''; + private ?string $alias = null; + /** @var list): bool}> kind, table, alias and condition of each join */ + private array $joins = []; /** @var array */ private array $parameters = []; /** @var list<\Closure(array): bool> what a row must meet */ private array $conditions = []; /** @var array column and the parameter it is set to */ private array $assignments = []; + /** @var list */ + private array $selected = []; + /** @var list */ + private array $aliases = []; + private ?string $orderBy = null; private ?int $limit = null; - public function __construct(private InMemoryBindingTable $table) { + public function __construct(private InMemoryBindingTable $db) { } public function select(string ...$columns): self { $this->statement = 'select'; + $this->selected = array_values($columns); + return $this; + } + + public function selectAlias(string $column, string $alias): self { + $this->aliases[] = [$column, $alias]; return $this; } public function update(string $table): self { $this->statement = 'update'; + $this->table = $table; return $this; } public function delete(string $table): self { $this->statement = 'delete'; + $this->table = $table; + return $this; + } + + public function from(string $table, ?string $alias = null): self { + $this->table = $table; + $this->alias = $alias; + return $this; + } + + /** @param \Closure(array): bool $condition */ + public function innerJoin(string $fromAlias, string $table, string $alias, \Closure $condition): self { + $this->joins[] = ['inner', $table, $alias, $condition]; return $this; } - public function from(string $table): self { + /** @param \Closure(array): bool $condition */ + public function leftJoin(string $fromAlias, string $table, string $alias, \Closure $condition): self { + $this->joins[] = ['left', $table, $alias, $condition]; return $this; } @@ -64,12 +101,18 @@ public function andWhere(\Closure $condition): self { return $this; } + /** Ascending only: no statement here sorts the other way. */ + public function orderBy(string $column, string $direction = 'ASC'): self { + $this->orderBy = $column; + return $this; + } + public function setMaxResults(int $limit): self { $this->limit = $limit; return $this; } - public function createNamedParameter(mixed $value, ?int $type = null): string { + public function createNamedParameter(mixed $value, mixed $type = null): string { $name = ':p' . count($this->parameters); $this->parameters[$name] = $value; return $name; @@ -81,38 +124,110 @@ public function expr(): self { } /** @return \Closure(array): bool */ - public function eq(string $column, string $parameter): \Closure { - return fn (array $row): bool => (string)($row[$column] ?? '') === (string)$this->parameters[$parameter]; + public function eq(string $column, string $operand): \Closure { + return fn (array $row): bool => $this->value($row, $column) !== null && (string)$this->value($row, $column) === (string)$this->operand($row, $operand); + } + + /** @return \Closure(array): bool */ + public function gt(string $column, string $operand): \Closure { + return fn (array $row): bool => $this->value($row, $column) !== null && (int)$this->value($row, $column) > (int)$this->operand($row, $operand); + } + + /** @return \Closure(array): bool */ + public function lte(string $column, string $operand): \Closure { + return fn (array $row): bool => $this->value($row, $column) !== null && (int)$this->value($row, $column) <= (int)$this->operand($row, $operand); + } + + /** @return \Closure(array): bool */ + public function in(string $column, string $parameter): \Closure { + return fn (array $row): bool => in_array($this->value($row, $column), (array)$this->parameters[$parameter], false); } /** @return \Closure(array): bool */ public function isNull(string $column): \Closure { - return static fn (array $row): bool => ($row[$column] ?? null) === null; + return fn (array $row): bool => $this->value($row, $column) === null; } /** @return \Closure(array): bool */ public function isNotNull(string $column): \Closure { - return static fn (array $row): bool => ($row[$column] ?? null) !== null; + return fn (array $row): bool => $this->value($row, $column) !== null; + } + + /** + * SQL's LIKE: `%` any run, `_` any one character, a backslash taking + * the next one as it is (IDBConnection::escapeLikeParameter()). + * + * @return \Closure(array): bool + */ + public function like(string $column, string $parameter): \Closure { + return function (array $row) use ($column, $parameter): bool { + $pattern = (string)$this->parameters[$parameter]; + $regex = ''; + for ($i = 0, $n = strlen($pattern); $i < $n; $i++) { + $char = $pattern[$i]; + if ($char === '\\' && $i + 1 < $n) { + $regex .= preg_quote($pattern[++$i], '/'); + } elseif ($char === '%') { + $regex .= '.*'; + } elseif ($char === '_') { + $regex .= '.'; + } else { + $regex .= preg_quote($char, '/'); + } + } + $value = $this->value($row, $column); + return $value !== null && preg_match('/^' . $regex . '$/s', (string)$value) === 1; + }; + } + + /** + * @param \Closure(array): bool ...$conditions + * @return \Closure(array): bool + */ + public function orX(\Closure ...$conditions): \Closure { + return static function (array $row) use ($conditions): bool { + foreach ($conditions as $condition) { + if ($condition($row)) { + return true; + } + } + return false; + }; } public function executeStatement(): int { - $hit = $this->matchingKeys(); + $hit = []; + foreach ($this->db->rows as $key => $row) { + if ($this->meets($row)) { + $hit[] = $key; + } + } foreach ($hit as $key) { if ($this->statement === 'delete') { - unset($this->table->rows[$key]); + unset($this->db->rows[$key]); continue; } foreach ($this->assignments as $column => $parameter) { - $this->table->rows[$key][$column] = $this->parameters[$parameter]; + $this->db->rows[$key][$column] = $this->parameters[$parameter]; } } - $this->table->rows = array_values($this->table->rows); + $this->db->rows = array_values($this->db->rows); return count($hit); } public function executeQuery(): object { - $rows = array_map(fn (int $key): array => $this->table->rows[$key], $this->matchingKeys()); - return new class(array_slice($rows, 0, $this->limit)) { + $rows = []; + foreach ($this->joined() as $row) { + if ($this->meets($row)) { + $rows[] = $row; + } + } + if ($this->orderBy !== null) { + $column = $this->orderBy; + usort($rows, fn (array $a, array $b): int => (int)$this->value($a, $column) <=> (int)$this->value($b, $column)); + } + $rows = array_map(fn (array $row): array => $this->projected($row), array_slice($rows, 0, $this->limit)); + return new class($rows) { /** @param list> $rows */ public function __construct(private array $rows) { } @@ -132,17 +247,98 @@ public function closeCursor(): void { }; } - /** @return list */ - private function matchingKeys(): array { - $keys = []; - foreach ($this->table->rows as $key => $row) { - foreach ($this->conditions as $condition) { - if (!$condition($row)) { - continue 2; + /** @param array $row */ + private function meets(array $row): bool { + foreach ($this->conditions as $condition) { + if (!$condition($row)) { + return false; + } + } + return true; + } + + /** + * The rows of the statement's table, each with its joins, every column + * under the name the statement uses for it. + * + * @return list> + */ + private function joined(): array { + $rows = array_map(fn (array $row): array => $this->named($row, $this->alias), $this->rowsOf($this->table)); + foreach ($this->joins as [$kind, $table, $alias, $condition]) { + $next = []; + foreach ($rows as $row) { + $matched = false; + foreach ($this->rowsOf($table) as $other) { + $combined = $row + $this->named($other, $alias); + if ($condition($combined)) { + $next[] = $combined; + $matched = true; + } + } + if (!$matched && $kind === 'left') { + $next[] = $row; + } + } + $rows = $next; + } + return $rows; + } + + /** @return list> */ + private function rowsOf(string $table): array { + return $table === 'filecache' ? $this->db->fileCache : $this->db->rows; + } + + /** + * @param array $row + * @return array + */ + private function named(array $row, ?string $alias): array { + if ($alias === null) { + return $row; + } + $named = []; + foreach ($row as $column => $value) { + $named[$alias . '.' . $column] = $value; + } + return $named; + } + + /** + * The selected columns, named as a database answers: `b.file_id` as + * `file_id`, an alias as itself, `*` as every column of the table. + * + * @param array $row + * @return array + */ + private function projected(array $row): array { + $out = []; + foreach ($this->selected as $column) { + if ($column === '*') { + foreach ($row as $name => $value) { + if ($this->alias === null ? !str_contains($name, '.') : str_starts_with($name, $this->alias . '.')) { + $out[$this->alias === null ? $name : substr($name, strlen($this->alias) + 1)] = $value; + } } + continue; } - $keys[] = $key; + $parts = explode('.', $column); + $out[end($parts)] = $this->value($row, $column); } - return $keys; + foreach ($this->aliases as [$column, $alias]) { + $out[$alias] = $this->value($row, $column); + } + return $out; + } + + /** @param array $row */ + private function value(array $row, string $column): mixed { + return $row[$column] ?? null; + } + + /** @param array $row */ + private function operand(array $row, string $operand): mixed { + return array_key_exists($operand, $this->parameters) ? $this->parameters[$operand] : $this->value($row, $operand); } } diff --git a/tests/phpunit/Support/InMemoryBindingTable.php b/tests/phpunit/Support/InMemoryBindingTable.php index b16e7473..6da4c9a7 100644 --- a/tests/phpunit/Support/InMemoryBindingTable.php +++ b/tests/phpunit/Support/InMemoryBindingTable.php @@ -24,11 +24,13 @@ final class InMemoryBindingTable implements IDBConnection { /** * A row given without trashed_at or missing_since has them NULL, as the - * columns default to in the table. + * columns default to in the table. $fileCache holds what the file cache + * knows (`fileid`, `storage`, `path`), for the statements that join it. * * @param list> $rows + * @param list> $fileCache */ - public function __construct(array $rows) { + public function __construct(array $rows, public array $fileCache = []) { $this->rows = array_map(static fn (array $row): array => $row + ['trashed_at' => null, 'missing_since' => null], $rows); } diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index 522f78a7..f157d7b7 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -44,6 +44,7 @@ __DIR__ . '/stubs/OCP/FullTextSearch/Model/IIndexDocument.php', __DIR__ . '/stubs/OCP/FullTextSearch/Model/ISearchResult.php', __DIR__ . '/stubs/OCP/Constants.php', + __DIR__ . '/stubs/OCP/Files/Node.php', __DIR__ . '/stubs/OCP/Files/File.php', __DIR__ . '/stubs/OCP/Files/Folder.php', __DIR__ . '/stubs/OCP/Files/IMimeTypeLoader.php', @@ -71,6 +72,8 @@ __DIR__ . '/stubs/OCP/IURLGenerator.php', __DIR__ . '/stubs/OCP/IUser.php', __DIR__ . '/stubs/OCP/User/Events/UserLoggedOutEvent.php', + __DIR__ . '/stubs/OCP/User/Events/BeforeUserDeletedEvent.php', + __DIR__ . '/stubs/OCP/Files/Events/Node/BeforeNodeDeletedEvent.php', __DIR__ . '/stubs/OCP/IUserSession.php', __DIR__ . '/stubs/OCP/Files/InvalidPathException.php', __DIR__ . '/stubs/OCP/Files/IFilenameValidator.php', diff --git a/tests/phpunit/stubs/OCP/DB/QueryBuilder/IQueryBuilder.php b/tests/phpunit/stubs/OCP/DB/QueryBuilder/IQueryBuilder.php index ea1259d6..7c126b9d 100644 --- a/tests/phpunit/stubs/OCP/DB/QueryBuilder/IQueryBuilder.php +++ b/tests/phpunit/stubs/OCP/DB/QueryBuilder/IQueryBuilder.php @@ -8,5 +8,6 @@ interface IQueryBuilder { public const PARAM_INT = 1; public const PARAM_NULL = 0; + public const PARAM_INT_ARRAY = 101; } } diff --git a/tests/phpunit/stubs/OCP/Files/Events/Node/BeforeNodeDeletedEvent.php b/tests/phpunit/stubs/OCP/Files/Events/Node/BeforeNodeDeletedEvent.php new file mode 100644 index 00000000..a1bbc8d6 --- /dev/null +++ b/tests/phpunit/stubs/OCP/Files/Events/Node/BeforeNodeDeletedEvent.php @@ -0,0 +1,19 @@ +node; + } + } +} diff --git a/tests/phpunit/stubs/OCP/Files/File.php b/tests/phpunit/stubs/OCP/Files/File.php index 9c4ef701..0a3df7ea 100644 --- a/tests/phpunit/stubs/OCP/Files/File.php +++ b/tests/phpunit/stubs/OCP/Files/File.php @@ -5,7 +5,7 @@ namespace OCP\Files; if (!interface_exists(File::class)) { - interface File { + interface File extends Node { public function getId(): int; public function getName(): string; diff --git a/tests/phpunit/stubs/OCP/Files/Folder.php b/tests/phpunit/stubs/OCP/Files/Folder.php index bcb5953c..1d747f26 100644 --- a/tests/phpunit/stubs/OCP/Files/Folder.php +++ b/tests/phpunit/stubs/OCP/Files/Folder.php @@ -5,7 +5,7 @@ namespace OCP\Files; if (!interface_exists(Folder::class)) { - interface Folder { + interface Folder extends Node { public function nodeExists(string $path): bool; /** diff --git a/tests/phpunit/stubs/OCP/Files/Node.php b/tests/phpunit/stubs/OCP/Files/Node.php new file mode 100644 index 00000000..863535ca --- /dev/null +++ b/tests/phpunit/stubs/OCP/Files/Node.php @@ -0,0 +1,11 @@ +user; + } + } +} diff --git a/tests/phpunit/unit/BindingServiceTest.php b/tests/phpunit/unit/BindingServiceTest.php index 00ba9e2c..6b21c1b6 100644 --- a/tests/phpunit/unit/BindingServiceTest.php +++ b/tests/phpunit/unit/BindingServiceTest.php @@ -177,6 +177,60 @@ public function testAnUnagedRunComparesNoDate(): void { } } + /** + * A file seen leaving Files marks its row once: a row marked already + * keeps its first mark, and the other rows are left alone. + */ + public function testAFileLeavingFilesMarksItsRowOnce(): void { + $table = new InMemoryBindingTable([ + self::bindingRow(1, 'pad-a', BindingService::STATE_ACTIVE), + ['trashed_at' => 300] + self::bindingRow(2, 'pad-b', BindingService::STATE_ACTIVE), + self::bindingRow(3, 'pad-c', BindingService::STATE_ACTIVE), + ]); + $service = new BindingService($table, new FixedClock(500)); + + $service->markTrashed(1); + $service->markTrashed(2); + + $this->assertSame([500, 300, null], array_column($table->rows, 'trashed_at')); + } + + /** + * A folder leaving Files marks the row of every file under it, as the + * file cache has it: on the folder's storage, below its path and not + * beside it. A `_` in the folder's name is a character, not any one. A + * user deleted marks every row on their storage, and an id the file + * cache does not know marks nothing. + */ + public function testAFolderLeavingFilesMarksTheRowsUnderIt(): void { + $fileCache = [ + ['fileid' => 100, 'storage' => 1, 'path' => 'files/Te_m'], + ['fileid' => 1, 'storage' => 1, 'path' => 'files/Te_m/a.pad'], + ['fileid' => 2, 'storage' => 1, 'path' => 'files/Te_m/sub/b.pad'], + ['fileid' => 3, 'storage' => 1, 'path' => 'files/Te_mwork/c.pad'], + ['fileid' => 4, 'storage' => 1, 'path' => 'files/Team/d.pad'], + ['fileid' => 5, 'storage' => 2, 'path' => 'files/Te_m/e.pad'], + ['fileid' => 6, 'storage' => 1, 'path' => 'files/f.pad'], + ]; + $rows = array_map(static fn (int $id): array => self::bindingRow($id, 'pad-' . $id, BindingService::STATE_ACTIVE), [1, 2, 3, 4, 5, 6]); + $marked = static fn (InMemoryBindingTable $table): array => array_values(array_map( + static fn (array $row): int => $row['file_id'], + array_filter($table->rows, static fn (array $row): bool => $row['trashed_at'] !== null), + )); + + $table = new InMemoryBindingTable($rows, $fileCache); + (new BindingService($table, new FixedClock(500)))->markTrashedUnder(100); + $this->assertSame([1, 2], $marked($table), 'the folder'); + + $table = new InMemoryBindingTable($rows, $fileCache); + (new BindingService($table, new FixedClock(500)))->markTrashedUnder(100, wholeStorage: true); + $this->assertSame([1, 2, 3, 4, 6], $marked($table), 'the whole storage'); + + $table = new InMemoryBindingTable($rows, $fileCache); + (new BindingService($table, new FixedClock(500)))->markTrashedUnder(999); + $this->assertSame([], $marked($table), 'an unknown folder'); + } + /** @param list> $rows */ private function serviceOver(array $rows): BindingService { return new BindingService(new InMemoryBindingTable($rows), new FixedClock(500)); diff --git a/tests/phpunit/unit/MarkLeavingPadsListenerTest.php b/tests/phpunit/unit/MarkLeavingPadsListenerTest.php new file mode 100644 index 00000000..1196c56b --- /dev/null +++ b/tests/phpunit/unit/MarkLeavingPadsListenerTest.php @@ -0,0 +1,90 @@ +file(7, 'Notes.pad'); + $text = $this->file(8, 'Notes.txt'); + $folder = $this->createMock(Folder::class); + $folder->method('getId')->willReturn(9); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $home = $this->createMock(Folder::class); + $home->method('getId')->willReturn(3); + + $cases = [ + 'a .pad file to the trash' => [$this->trashEvent($pad), [['file', 7]]], + 'another file to the trash' => [$this->trashEvent($text), []], + 'a folder to the trash' => [$this->trashEvent($folder), [['under', 9, false]]], + 'a .pad file deleted' => [new BeforeNodeDeletedEvent($pad), [['file', 7]]], + 'a folder deleted' => [new BeforeNodeDeletedEvent($folder), [['under', 9, false]]], + 'a user deleted' => [new BeforeUserDeletedEvent($user), [['under', 3, true]]], + 'anything else' => [new Event(), []], + ]; + foreach ($cases as $case => [$event, $expected]) { + $marks = []; + $bindings = $this->createMock(BindingService::class); + $bindings->method('markTrashed')->willReturnCallback(static function (int $fileId) use (&$marks): void { + $marks[] = ['file', $fileId]; + }); + $bindings->method('markTrashedUnder')->willReturnCallback(static function (int $folderId, bool $wholeStorage = false) use (&$marks): void { + $marks[] = ['under', $folderId, $wholeStorage]; + }); + $rootFolder = $this->createMock(IRootFolder::class); + $rootFolder->method('getUserFolder')->with('alice')->willReturn($home); + + (new MarkLeavingPadsListener($bindings, $rootFolder, $this->createMock(LoggerInterface::class)))->handle($event); + + $this->assertSame($expected, $marks, $case); + } + } + + /** A mark that fails is a warning; the trash goes ahead, and the sweep finds the file later. */ + public function testAMarkThatFailsNeverStopsTheTrash(): void { + $bindings = $this->createMock(BindingService::class); + $bindings->method('markTrashed')->willThrowException(new \RuntimeException('the database went away')); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning')->with('Could not mark the pads of files leaving Files; the sweep finds them later.', $this->anything()); + + (new MarkLeavingPadsListener($bindings, $this->createMock(IRootFolder::class), $logger))->handle($this->trashEvent($this->file(7, 'Notes.pad'))); + } + + private function file(int $id, string $name): File { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn($id); + $file->method('getName')->willReturn($name); + return $file; + } + + /** The trash app's MoveToTrashEvent, which carries its node under getNode(). */ + private function trashEvent(object $node): Event { + return new class($node) extends Event { + public function __construct(private object $node) { + } + + public function getNode(): object { + return $this->node; + } + }; + } +} From f6d079f10130cd0c6ccb7bf83efe76c6b51788f8 Mon Sep 17 00:00:00 2001 From: Jaggob <37583151+Jaggob@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:55:48 +0200 Subject: [PATCH 03/86] Delete the pads of files gone for good in a sweep GoneFileSweep runs every five minutes. Active rows marked as leaving Files whose file the file cache has nothing left of are gone for good: pad and row go. A pass over every active row, 200 a run with a cursor, keeps the dates in step with the file cache: it marks a file under a trash path, clears the mark of one back in Files, starts a seven-day grace period for a file missing without a mark, and ends it for one found again. After the grace period pad and row go too, unless more such files went missing since the brake's last release than its threshold (20): then they are kept and a warning says so, once. The file cache is asked once more right before a pad goes. A pad Etherpad no longer has counts as deleted, a refusal is logged and tried again, and Etherpad not answering ends the run with the slice taken again next time. With delete_on_trash off nothing is deleted, but the dates and the brake are kept. The grace period, threshold, release time, brake state and cursor live in the app config through IAppConfig. --- lib/AppInfo/Application.php | 1 + lib/BackgroundJob/GoneFileSweepJob.php | 32 ++ lib/Service/AppConfigService.php | 38 ++ lib/Service/BindingService.php | 114 ++++++ lib/Service/GoneFileSweep.php | 171 +++++++++ lib/Service/SweptBinding.php | 40 +++ tests/phpunit/stubs/OCP/IAppConfig.php | 4 + tests/phpunit/unit/AppConfigServiceTest.php | 37 +- tests/phpunit/unit/BindingServiceTest.php | 73 ++++ tests/phpunit/unit/GoneFileSweepTest.php | 377 ++++++++++++++++++++ 10 files changed, 885 insertions(+), 2 deletions(-) create mode 100644 lib/BackgroundJob/GoneFileSweepJob.php create mode 100644 lib/Service/GoneFileSweep.php create mode 100644 lib/Service/SweptBinding.php create mode 100644 tests/phpunit/unit/GoneFileSweepTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 88196fd5..cf290c63 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -158,6 +158,7 @@ public function boot(IBootContext $context): void { $jobList->add(\OCA\EtherpadNextcloud\BackgroundJob\HotPendingDeleteRetryJob::class); $jobList->add(\OCA\EtherpadNextcloud\BackgroundJob\WarmPendingDeleteRetryJob::class); $jobList->add(\OCA\EtherpadNextcloud\BackgroundJob\ColdPendingDeleteRetryJob::class); + $jobList->add(\OCA\EtherpadNextcloud\BackgroundJob\GoneFileSweepJob::class); }); } } diff --git a/lib/BackgroundJob/GoneFileSweepJob.php b/lib/BackgroundJob/GoneFileSweepJob.php new file mode 100644 index 00000000..9f70a2c0 --- /dev/null +++ b/lib/BackgroundJob/GoneFileSweepJob.php @@ -0,0 +1,32 @@ +setInterval(5 * 60); + } + + /** + * @param mixed $argument + */ + protected function run($argument): void { + $this->sweep->run(); + } +} diff --git a/lib/Service/AppConfigService.php b/lib/Service/AppConfigService.php index 261b510d..b2cd24eb 100644 --- a/lib/Service/AppConfigService.php +++ b/lib/Service/AppConfigService.php @@ -9,12 +9,14 @@ namespace OCA\EtherpadNextcloud\Service; use OCA\EtherpadNextcloud\AppInfo\Application; +use OCP\IAppConfig; use OCP\IConfig; class AppConfigService { public function __construct( private IConfig $config, private TrustedEmbedOriginsNormalizer $trustedEmbedOriginsNormalizer, + private IAppConfig $appConfig, ) { } @@ -41,6 +43,42 @@ public function isDeleteOnTrashEnabled(): bool { return $this->config->getAppValue(Application::APP_ID, 'delete_on_trash', 'yes') === 'yes'; } + /** + * How long a pad lives on after its file went missing without being + * seen leaving Files (GoneFileSweep): seven days, unless set otherwise. + */ + public function getGoneFileGraceSeconds(): int { + return max(0, $this->appConfig->getValueInt(Application::APP_ID, 'gone_file_grace_seconds', 7 * 24 * 60 * 60)); + } + + /** More files missed without a mark since the brake's last release than this stops the grace deletions. */ + public function getGoneFileBrakeThreshold(): int { + return max(0, $this->appConfig->getValueInt(Application::APP_ID, 'gone_file_brake_threshold', 20)); + } + + /** When an admin last released the brake; files missed before then count no more. */ + public function getGoneFileBrakeReleasedAt(): int { + return $this->appConfig->getValueInt(Application::APP_ID, 'gone_file_brake_released_at'); + } + + /** Whether the last sweep found the brake on: so it warns once, and the health check can say so. */ + public function isGoneFileBrakeEngaged(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, 'gone_file_brake_engaged'); + } + + public function setGoneFileBrakeEngaged(bool $engaged): void { + $this->appConfig->setValueBool(Application::APP_ID, 'gone_file_brake_engaged', $engaged); + } + + /** The last row the sweep's pass over every row reached; 0 to start over. */ + public function getGoneFileSweepCursor(): int { + return max(0, $this->appConfig->getValueInt(Application::APP_ID, 'gone_file_sweep_cursor')); + } + + public function setGoneFileSweepCursor(int $rowId): void { + $this->appConfig->setValueInt(Application::APP_ID, 'gone_file_sweep_cursor', $rowId); + } + /** The test fault a debug instance injects (TestFaults), or '' for none. */ public function getTestFault(): string { return trim($this->config->getAppValue(Application::APP_ID, 'test_fault', '')); diff --git a/lib/Service/BindingService.php b/lib/Service/BindingService.php index a845cf7d..de671d7f 100644 --- a/lib/Service/BindingService.php +++ b/lib/Service/BindingService.php @@ -308,6 +308,120 @@ public function markTrashedUnder(int $folderId, bool $wholeStorage = false): voi } } + /** The columns a Binding is read from, as `b.` a join selects them. */ + private const BINDING_COLUMNS = ['b.file_id', 'b.pad_id', 'b.access_mode', 'b.state', 'b.deleted_at', 'b.updated_at', 'b.trashed_at', 'b.missing_since']; + + /** + * Active rows marked as leaving Files whose file the file cache has + * nothing left of: gone for good (GoneFileSweep). The earliest marked + * first. + * + * @return list + */ + public function findMarkedGone(int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(...self::BINDING_COLUMNS) + ->from(self::TABLE, 'b') + ->leftJoin('b', 'filecache', 'fc', $qb->expr()->eq('b.file_id', 'fc.fileid')) + ->where($qb->expr()->isNotNull('b.trashed_at')) + ->andWhere($qb->expr()->eq('b.state', $qb->createNamedParameter(self::STATE_ACTIVE))) + ->andWhere($qb->expr()->isNull('fc.fileid')) + ->orderBy('b.trashed_at', 'ASC') + ->setMaxResults(max(1, $limit)); + $result = $qb->executeQuery(); + $rows = array_map(Binding::fromRow(...), DbRows::all($result->fetchAll())); + $result->closeCursor(); + return $rows; + } + + /** + * Active rows after the row $afterId, in id order, each with the path its + * file has in the file cache now: a slice of the sweep's pass over every + * row (GoneFileSweep). + * + * @return list + */ + public function findActiveAfter(int $afterId, int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('b.id', ...self::BINDING_COLUMNS) + ->selectAlias('fc.path', 'file_path') + ->from(self::TABLE, 'b') + ->leftJoin('b', 'filecache', 'fc', $qb->expr()->eq('b.file_id', 'fc.fileid')) + ->where($qb->expr()->gt('b.id', $qb->createNamedParameter($afterId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('b.state', $qb->createNamedParameter(self::STATE_ACTIVE))) + ->orderBy('b.id', 'ASC') + ->setMaxResults(max(1, $limit)); + $result = $qb->executeQuery(); + $rows = array_map(SweptBinding::fromRow(...), DbRows::all($result->fetchAll())); + $result->closeCursor(); + return $rows; + } + + /** Whether the file cache has nothing left of the file: asked once more right before its pad goes. */ + public function isFileGone(int $fileId): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select('fileid') + ->from('filecache') + ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); + $result = $qb->executeQuery(); + $found = DbRows::one($result->fetch()); + $result->closeCursor(); + return $found === null; + } + + /** The file is back in Files, or never left: its row loses the mark. */ + public function clearTrashed(int $fileId): void { + $this->setDate($fileId, 'trashed_at', null); + } + + /** A sweep missed the file without a mark: the grace period starts now, unless it has already. */ + public function markMissing(int $fileId): void { + $this->setDate($fileId, 'missing_since', $this->timeFactory->getTime()); + } + + /** The file is there after all: its grace period is over. */ + public function clearMissing(int $fileId): void { + $this->setDate($fileId, 'missing_since', null); + } + + /** + * How many active rows without a mark a sweep has missed the file of + * since $since, counted up to $atMost: the sweep's brake only asks + * whether they are more than its threshold. + */ + public function countMissingSince(int $since, int $atMost): int { + $qb = $this->db->getQueryBuilder(); + $qb->select('file_id') + ->from(self::TABLE) + ->where($qb->expr()->gt('missing_since', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('trashed_at')) + ->andWhere($qb->expr()->eq('state', $qb->createNamedParameter(self::STATE_ACTIVE))) + ->setMaxResults(max(1, $atMost)); + $result = $qb->executeQuery(); + $count = count($result->fetchAll()); + $result->closeCursor(); + return $count; + } + + /** + * $column to $value on the file's row: a date set only where none is, + * so the first one stays; null clears it. + */ + private function setDate(int $fileId, string $column, ?int $value): void { + $qb = $this->db->getQueryBuilder(); + $qb->update(self::TABLE) + ->set($column, $value === null + ? $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL) + : $qb->createNamedParameter($value, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); + if ($value === null) { + $qb->andWhere($qb->expr()->isNotNull($column)); + } else { + $qb->andWhere($qb->expr()->isNull($column)); + } + $qb->executeStatement(); + } + public function createBinding(int $fileId, string $padId, string $accessMode): void { $this->assertAccessMode($accessMode); $now = $this->timeFactory->getTime(); diff --git a/lib/Service/GoneFileSweep.php b/lib/Service/GoneFileSweep.php new file mode 100644 index 00000000..4dc574b0 --- /dev/null +++ b/lib/Service/GoneFileSweep.php @@ -0,0 +1,171 @@ +timeFactory, RunBudget::DEFAULT_SECONDS); + $deleting = $this->appConfig->isDeleteOnTrashEnabled(); + try { + if ($deleting) { + foreach ($this->bindingService->findMarkedGone(self::LIMIT) as $binding) { + $this->discard($binding, $budget, false); + } + } + $this->passSlice($budget, $deleting); + } catch (RunBudgetSpentException) { + // The rest waits for the next run. + } catch (EtherpadClientException $e) { + $this->logger->info('Etherpad did not answer the sweep of files gone for good; it tries again next run.', [ + 'app' => 'etherpad_nextcloud', + ...SafeError::context($e), + ]); + } + } + + /** + * One slice of the pass over every active row, from where the last one + * stopped. The dates first, then the brake, so files going missing in + * this slice count, then the pads past their grace period. The cursor + * moves once the slice is done; a slice cut short is taken again. + * + * @throws RunBudgetSpentException + * @throws EtherpadClientException Etherpad did not answer + */ + private function passSlice(RunBudget $budget, bool $deleting): void { + $rows = $this->bindingService->findActiveAfter($this->appConfig->getGoneFileSweepCursor(), self::LIMIT); + $graceEnded = $this->timeFactory->getTime() - $this->appConfig->getGoneFileGraceSeconds(); + $pastGrace = []; + $last = 0; + foreach ($rows as $row) { + $last = $row->id; + $binding = $row->binding; + if ($row->filePath === null) { + if ($binding->trashedAt !== null) { + // Gone for good: the first pass's. + } elseif ($binding->missingSince === null) { + $this->bindingService->markMissing($binding->fileId); + } elseif ($binding->missingSince <= $graceEnded) { + $pastGrace[] = $binding; + } + continue; + } + if ($row->isInTrash() && $binding->trashedAt === null) { + $this->bindingService->markTrashed($binding->fileId); + } elseif (!$row->isInTrash() && $binding->trashedAt !== null) { + $this->bindingService->clearTrashed($binding->fileId); + } + if ($binding->missingSince !== null) { + $this->bindingService->clearMissing($binding->fileId); + } + } + if (!$this->brakeHolds() && $deleting) { + foreach ($pastGrace as $binding) { + $this->discard($binding, $budget, true); + } + } + // A short slice was the end of the table: the next starts over. + $this->appConfig->setGoneFileSweepCursor(count($rows) < self::LIMIT ? 0 : $last); + } + + /** + * Whether more files went missing without a mark since the brake's last + * release than the threshold. Kept in the app config for the admin, and + * warned of once, when it starts to hold. + */ + private function brakeHolds(): bool { + $threshold = $this->appConfig->getGoneFileBrakeThreshold(); + $holds = $this->bindingService->countMissingSince($this->appConfig->getGoneFileBrakeReleasedAt(), $threshold + 1) > $threshold; + if ($holds !== $this->appConfig->isGoneFileBrakeEngaged()) { + $this->appConfig->setGoneFileBrakeEngaged($holds); + if ($holds) { + $this->logger->warning('Many .pad files went missing at once without passing a trash; their pads are kept until an admin releases the brake.', [ + 'app' => 'etherpad_nextcloud', + 'threshold' => $threshold, + ]); + } + } + return $holds; + } + + /** + * The pad and row of a file gone for good, once the file cache confirms + * it. Left when the file is there after all, the row changed, or + * Etherpad refused. + * + * @throws RunBudgetSpentException + * @throws EtherpadClientException Etherpad did not answer + */ + private function discard(Binding $binding, RunBudget $budget, bool $afterGrace): void { + if (!$this->bindingService->isFileGone($binding->fileId)) { + return; + } + $context = ['app' => 'etherpad_nextcloud', 'fileId' => $binding->fileId, 'padId' => $binding->padId]; + try { + $this->padLifecycle->discardIfPresent($binding->padId, $budget, retried: true); + } catch (RunBudgetSpentException $e) { + throw $e; + } catch (\Throwable $e) { + if (EtherpadClientException::isEtherpadUnreachable($e)) { + throw $e; + } + $this->logger->warning('Could not delete the pad of a file gone for good; it is tried again.', $context + SafeError::context($e)); + return; + } + if (!$this->bindingService->deleteActiveBinding($binding->fileId, $binding->padId)) { + return; + } + if ($afterGrace) { + $this->logger->warning('The file of a pad has been missing past the grace period without passing a trash; the pad is deleted.', $context); + } else { + $this->logger->info('The file of a pad is gone for good; the pad is deleted.', $context); + } + } +} diff --git a/lib/Service/SweptBinding.php b/lib/Service/SweptBinding.php new file mode 100644 index 00000000..57176b90 --- /dev/null +++ b/lib/Service/SweptBinding.php @@ -0,0 +1,40 @@ + $row + * @throws \UnexpectedValueException when a column was not selected, or holds what it cannot + */ + public static function fromRow(array $row): self { + return new self(DbRows::int($row, 'id'), Binding::fromRow($row), DbRows::nullableString($row, 'file_path')); + } + + /** In a user's trash, or a team folder's on the root storage. */ + public function isInTrash(): bool { + return $this->filePath !== null + && (str_starts_with($this->filePath, BindingService::USER_TRASH_PATH) || str_starts_with($this->filePath, BindingService::TEAM_TRASH_PATH)); + } +} diff --git a/tests/phpunit/stubs/OCP/IAppConfig.php b/tests/phpunit/stubs/OCP/IAppConfig.php index 0547cddc..ad8c30ca 100644 --- a/tests/phpunit/stubs/OCP/IAppConfig.php +++ b/tests/phpunit/stubs/OCP/IAppConfig.php @@ -13,5 +13,9 @@ public function getValueInt(string $app, string $key, int $default = 0, bool $la public function getValueBool(string $app, string $key, bool $default = false, bool $lazy = false): bool; public function setValueString(string $app, string $key, string $value, bool $lazy = false, bool $sensitive = false): bool; + + public function setValueInt(string $app, string $key, int $value, bool $lazy = false, bool $sensitive = false): bool; + + public function setValueBool(string $app, string $key, bool $value, bool $lazy = false): bool; } } diff --git a/tests/phpunit/unit/AppConfigServiceTest.php b/tests/phpunit/unit/AppConfigServiceTest.php index d3786888..bab70571 100644 --- a/tests/phpunit/unit/AppConfigServiceTest.php +++ b/tests/phpunit/unit/AppConfigServiceTest.php @@ -10,6 +10,7 @@ use OCA\EtherpadNextcloud\Service\AppConfigService; use OCA\EtherpadNextcloud\Service\TrustedEmbedOriginsNormalizer; +use OCP\IAppConfig; use OCP\IConfig; use PHPUnit\Framework\TestCase; @@ -32,7 +33,39 @@ public function testTheTestFaultIsReadTrimmed(): void { $this->assertSame('trash_read_lock', $this->service($config)->getTestFault()); } - private function service(IConfig $config): AppConfigService { - return new AppConfigService($config, $this->createMock(TrustedEmbedOriginsNormalizer::class)); + /** + * The sweep of files gone for good: seven days' grace and a brake past + * 20 files unless set otherwise, neither below 0; the cursor and the + * brake as the sweep left them. + */ + public function testTheGoneFileSweepSettings(): void { + $stored = []; + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueInt')->willReturnCallback(static function (string $app, string $key, int $default = 0) use (&$stored): int { + return (int)($stored[$app . '/' . $key] ?? $default); + }); + $appConfig->method('getValueBool')->willReturnCallback(static function (string $app, string $key, bool $default = false) use (&$stored): bool { + return (bool)($stored[$app . '/' . $key] ?? $default); + }); + $set = static function (string $app, string $key, int|bool $value) use (&$stored): bool { + $stored[$app . '/' . $key] = $value; + return true; + }; + $appConfig->method('setValueInt')->willReturnCallback($set); + $appConfig->method('setValueBool')->willReturnCallback($set); + $service = $this->service($this->createMock(IConfig::class), $appConfig); + + $this->assertSame([7 * 24 * 60 * 60, 20, 0, false, 0], [$service->getGoneFileGraceSeconds(), $service->getGoneFileBrakeThreshold(), $service->getGoneFileBrakeReleasedAt(), $service->isGoneFileBrakeEngaged(), $service->getGoneFileSweepCursor()]); + + $stored = ['etherpad_nextcloud/gone_file_grace_seconds' => -5, 'etherpad_nextcloud/gone_file_brake_threshold' => -1, 'etherpad_nextcloud/gone_file_brake_released_at' => 900, 'etherpad_nextcloud/gone_file_sweep_cursor' => -3]; + $this->assertSame([0, 0, 900, 0], [$service->getGoneFileGraceSeconds(), $service->getGoneFileBrakeThreshold(), $service->getGoneFileBrakeReleasedAt(), $service->getGoneFileSweepCursor()]); + + $service->setGoneFileSweepCursor(400); + $service->setGoneFileBrakeEngaged(true); + $this->assertSame([400, true], [$service->getGoneFileSweepCursor(), $service->isGoneFileBrakeEngaged()]); + } + + private function service(IConfig $config, ?IAppConfig $appConfig = null): AppConfigService { + return new AppConfigService($config, $this->createMock(TrustedEmbedOriginsNormalizer::class), $appConfig ?? $this->createMock(IAppConfig::class)); } } diff --git a/tests/phpunit/unit/BindingServiceTest.php b/tests/phpunit/unit/BindingServiceTest.php index 6b21c1b6..a40345ea 100644 --- a/tests/phpunit/unit/BindingServiceTest.php +++ b/tests/phpunit/unit/BindingServiceTest.php @@ -12,6 +12,7 @@ use OCA\EtherpadNextcloud\Service\Binding; use OCA\EtherpadNextcloud\Service\BindingService; use OCA\EtherpadNextcloud\Service\FileLocation; +use OCA\EtherpadNextcloud\Service\SweptBinding; use OCA\EtherpadNextcloud\Service\WaitingBinding; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -231,6 +232,78 @@ public function testAFolderLeavingFilesMarksTheRowsUnderIt(): void { $this->assertSame([], $marked($table), 'an unknown folder'); } + /** + * The sweep of files gone for good asks the file cache through the + * binding table. Marked and gone: the earliest marked first, active rows + * only. The pass: active rows after the cursor in id order, each with its + * file's path, null once gone. + */ + public function testTheSweepFindsWhatTheFileCacheNoLongerHas(): void { + $row = static fn (int $id, int $fileId, string $state, ?int $trashedAt): array => ['id' => $id, 'trashed_at' => $trashedAt] + self::bindingRow($fileId, 'pad-' . $fileId, $state); + $table = new InMemoryBindingTable([ + $row(1, 11, BindingService::STATE_ACTIVE, 300), + $row(2, 12, BindingService::STATE_ACTIVE, 200), + $row(3, 13, BindingService::STATE_ACTIVE, 100), + $row(4, 14, BindingService::STATE_ACTIVE, null), + $row(5, 15, BindingService::STATE_PENDING_DELETE, 50), + ], [ + ['fileid' => 13, 'storage' => 1, 'path' => 'files_trashbin/files/13.pad.d100'], + ['fileid' => 99, 'storage' => 1, 'path' => 'files/99.pad'], + ]); + $service = new BindingService($table, new FixedClock(500)); + $pass = static fn (array $swept): array => array_map(static fn (SweptBinding $s): array => [$s->id, $s->binding->fileId, $s->filePath], $swept); + + $this->assertSame([12, 11], array_map(static fn (Binding $b): int => $b->fileId, $service->findMarkedGone(10))); + $this->assertSame([12], array_map(static fn (Binding $b): int => $b->fileId, $service->findMarkedGone(1))); + $this->assertSame([[1, 11, null], [2, 12, null], [3, 13, 'files_trashbin/files/13.pad.d100'], [4, 14, null]], $pass($service->findActiveAfter(0, 10))); + $this->assertSame([[3, 13, 'files_trashbin/files/13.pad.d100']], $pass($service->findActiveAfter(2, 1))); + $this->assertSame([], $service->findActiveAfter(4, 10)); + $this->assertTrue($service->isFileGone(11)); + $this->assertFalse($service->isFileGone(13)); + } + + /** + * A date the sweep sets stays as first set; cleared, it is gone. Other + * rows are left alone. + */ + public function testTheSweepsDatesAreSetOnceAndCleared(): void { + $table = new InMemoryBindingTable([ + ['trashed_at' => 300] + self::bindingRow(1, 'pad-a', BindingService::STATE_ACTIVE), + ['trashed_at' => 300, 'missing_since' => 300] + self::bindingRow(2, 'pad-b', BindingService::STATE_ACTIVE), + ]); + $clock = new FixedClock(500); + $service = new BindingService($table, $clock); + + $service->markMissing(1); + $clock->advance(100); + $service->markMissing(1); + $this->assertSame([[300, 500], [300, 300]], array_map(static fn (array $r): array => [$r['trashed_at'], $r['missing_since']], $table->rows)); + + $service->clearMissing(1); + $service->clearTrashed(1); + $this->assertSame([[null, null], [300, 300]], array_map(static fn (array $r): array => [$r['trashed_at'], $r['missing_since']], $table->rows)); + } + + /** + * The brake counts active rows without a mark missed after its release, + * no further than it asks. + */ + public function testTheBrakeCountsRowsMissingSinceItsRelease(): void { + $row = static fn (int $fileId, int $missingSince, string $state = BindingService::STATE_ACTIVE, ?int $trashedAt = null): array => ['missing_since' => $missingSince, 'trashed_at' => $trashedAt] + self::bindingRow($fileId, 'pad-' . $fileId, $state); + $service = $this->serviceOver([ + $row(1, 200), + $row(2, 201), + $row(3, 400), + $row(4, 400, trashedAt: 400), + $row(5, 400, BindingService::STATE_PENDING_DELETE), + self::bindingRow(6, 'pad-6', BindingService::STATE_ACTIVE), + ]); + + $this->assertSame(2, $service->countMissingSince(200, 10)); + $this->assertSame(1, $service->countMissingSince(200, 1)); + $this->assertSame(0, $service->countMissingSince(400, 10)); + } + /** @param list> $rows */ private function serviceOver(array $rows): BindingService { return new BindingService(new InMemoryBindingTable($rows), new FixedClock(500)); diff --git a/tests/phpunit/unit/GoneFileSweepTest.php b/tests/phpunit/unit/GoneFileSweepTest.php new file mode 100644 index 00000000..f4daa1e0 --- /dev/null +++ b/tests/phpunit/unit/GoneFileSweepTest.php @@ -0,0 +1,377 @@ + the app config the sweep reads and writes */ + private array $config = []; + /** @var list pads deleted in Etherpad */ + private array $deletedPads = []; + /** @var array what deleting a pad throws */ + private array $padErrors = []; + /** @var list level, message and file of each line logged */ + private array $lines = []; + /** @var (\Closure(): void)|null what deleting a pad takes, besides */ + private ?\Closure $onDelete = null; + + protected function setUp(): void { + $this->clock = new FixedClock(self::NOW); + $this->config = ['delete_on_trash' => true, 'grace' => 7 * self::DAY, 'threshold' => 20, 'released_at' => 0, 'engaged' => false, 'cursor' => 0]; + $this->deletedPads = []; + $this->padErrors = []; + $this->lines = []; + } + + /** + * A file marked as leaving Files and gone from the file cache is gone for + * good: its pad and row go, in one run, with a line naming the pad. One + * still in a trash, and one back in Files, keep theirs. + */ + public function testAFileSeenLeavingAndGoneTakesItsPad(): void { + $this->table( + [self::row(1, 11, 'pad-gone', trashedAt: 100), self::row(2, 12, 'pad-in-trash', trashedAt: 100), self::row(3, 13, 'pad-back', trashedAt: 100)], + [self::cached(12, 'files_trashbin/files/Notes.pad.d100'), self::cached(13, 'files/Notes.pad')], + ); + + $this->sweep(); + + $this->assertSame(['pad-gone'], $this->deletedPads); + $this->assertSame([12, 13], $this->fileIds()); + $this->assertSame([['info', 'The file of a pad is gone for good; the pad is deleted.', 11]], $this->lines); + } + + /** + * The pass over every row keeps the dates in step with the file cache: + * a file under a trash path gets the mark, one in Files loses it, a file + * missing without a mark starts its grace period, and one found again + * ends it. + */ + public function testThePassKeepsTheDatesInStepWithTheFileCache(): void { + $this->table( + [ + self::row(1, 11, 'pad-user-trash'), + self::row(2, 12, 'pad-team-trash'), + self::row(3, 13, 'pad-restored', trashedAt: 100), + self::row(4, 14, 'pad-missing'), + self::row(5, 15, 'pad-found-again', missingSince: 100), + ], + [ + self::cached(11, 'files_trashbin/files/A.pad.d100'), + self::cached(12, '__groupfolders/trash/3/B.pad.d100'), + self::cached(13, 'files/C.pad'), + self::cached(15, 'files/E.pad'), + ], + ); + + $this->sweep(); + + $this->assertSame([], $this->deletedPads); + $this->assertSame([ + 11 => [self::NOW, null], + 12 => [self::NOW, null], + 13 => [null, null], + 14 => [null, self::NOW], + 15 => [null, null], + ], $this->dates()); + } + + /** + * A file missing without a mark keeps its pad through the grace period + * and loses it after, with a warning naming the pad. As many such files + * as the threshold do not hold the brake. + */ + public function testAFileMissingWithoutAMarkTakesItsPadAfterTheGracePeriod(): void { + $this->table([ + self::row(1, 11, 'pad-within', missingSince: self::NOW - 7 * self::DAY + 1), + self::row(2, 12, 'pad-past', missingSince: self::NOW - 7 * self::DAY), + ], []); + $this->config['threshold'] = 2; + + $this->sweep(); + + $this->assertSame(['pad-past'], $this->deletedPads); + $this->assertSame([11], $this->fileIds()); + $this->assertSame([['warning', 'The file of a pad has been missing past the grace period without passing a trash; the pad is deleted.', 12]], $this->lines); + } + + /** + * More files missing without a mark since the last release than the + * threshold: the brake holds as they go missing, warned of once, and + * keeps every pad past its grace period - also one in the same slice, + * ahead of them - until an admin releases it. Files seen leaving are + * not held. + */ + public function testTheBrakeHoldsGraceDeletionsUntilReleased(): void { + $this->table([ + self::row(1, 11, 'pad-old', missingSince: self::NOW - 8 * self::DAY), + self::row(2, 12, 'pad-trashed', trashedAt: 100), + self::row(3, 13, 'pad-3'), + self::row(4, 14, 'pad-4'), + self::row(5, 15, 'pad-5'), + ], []); + $this->config['threshold'] = 3; + $brake = ['warning', 'Many .pad files went missing at once without passing a trash; their pads are kept until an admin releases the brake.', null]; + + $this->sweep(); + $this->assertSame(['pad-trashed'], $this->deletedPads, 'held as they go missing'); + $this->assertTrue($this->config['engaged']); + $this->assertContains($brake, $this->lines); + + $this->lines = []; + $this->clock->advance(8 * self::DAY); + $this->sweep(); + $this->assertSame(['pad-trashed'], $this->deletedPads, 'held past their grace period'); + $this->assertSame([], $this->lines, 'warned once'); + + $this->config['released_at'] = $this->clock->getTime(); + $this->config['engaged'] = false; + $this->sweep(); + $this->assertSame(['pad-trashed', 'pad-old', 'pad-3', 'pad-4', 'pad-5'], $this->deletedPads, 'released'); + $this->assertFalse($this->config['engaged']); + } + + /** + * The brake follows the files: found again, they no longer hold it. + */ + public function testTheBrakeLetsGoWhenTheFilesAreFoundAgain(): void { + $this->table([ + self::row(1, 11, 'pad-1', missingSince: self::NOW - 100), + self::row(2, 12, 'pad-2', missingSince: self::NOW - 100), + ], [self::cached(11, 'files/1.pad'), self::cached(12, 'files/2.pad')]); + $this->config['threshold'] = 1; + $this->config['engaged'] = true; + + $this->sweep(); + + $this->assertFalse($this->config['engaged']); + $this->assertSame([], $this->lines); + } + + /** + * With `delete_on_trash` off no pad goes, neither gone for good nor past + * its grace period; the dates and the brake are kept all the same, so + * switching it on finds them in place. + */ + public function testWithDeletingOffNothingGoesButTheDatesAreKept(): void { + $this->table([ + self::row(1, 11, 'pad-gone', trashedAt: 100), + self::row(2, 12, 'pad-past', missingSince: self::NOW - 8 * self::DAY), + self::row(3, 13, 'pad-missing'), + ], []); + $this->config['delete_on_trash'] = false; + + $this->sweep(); + + $this->assertSame([], $this->deletedPads); + $this->assertSame([11 => [100, null], 12 => [null, self::NOW - 8 * self::DAY], 13 => [null, self::NOW]], $this->dates()); + + $this->config['threshold'] = 1; + $this->sweep(); + $this->assertTrue($this->config['engaged']); + } + + /** + * A pad Etherpad no longer has is gone as surely: its row goes. A pad + * Etherpad refuses to delete keeps its row for another try, with a + * warning, and the run goes on. + */ + public function testAPadAlreadyGoneCountsAndARefusalIsTriedAgain(): void { + $this->table([ + self::row(1, 11, 'pad-already-gone', trashedAt: 100), + self::row(2, 12, 'pad-refused', trashedAt: 101), + self::row(3, 13, 'pad-fine', trashedAt: 102), + ], []); + $this->padErrors = [ + 'pad-already-gone' => new EtherpadRefusedException('padID does not exist'), + 'pad-refused' => new EtherpadRefusedException('apikey is invalid'), + ]; + + $this->sweep(); + + $this->assertSame(['pad-fine'], $this->deletedPads); + $this->assertSame([12], $this->fileIds()); + $this->assertSame([ + ['info', 'The file of a pad is gone for good; the pad is deleted.', 11], + ['warning', 'Could not delete the pad of a file gone for good; it is tried again.', 12], + ['info', 'The file of a pad is gone for good; the pad is deleted.', 13], + ], $this->lines); + } + + /** + * Etherpad not answering ends the run, with a line. Dates already set + * stay; the slice it cut short is taken again. + */ + public function testEtherpadNotAnsweringEndsTheRun(): void { + $this->table([ + self::row(6, 16, 'pad-past', missingSince: self::NOW - 8 * self::DAY), + self::row(7, 17, 'pad-after', missingSince: self::NOW - 8 * self::DAY), + self::row(8, 18, 'pad-missing'), + ], []); + $this->config['cursor'] = 5; + $this->padErrors = ['pad-past' => new EtherpadClientException('Etherpad API request failed: deletePad')]; + + $this->sweep(); + + $this->assertSame([], $this->deletedPads); + $this->assertSame([16, 17, 18], $this->fileIds()); + $this->assertSame(5, $this->config['cursor']); + $this->assertSame(self::NOW, $this->dates()[18][1]); + $this->assertSame([['info', 'Etherpad did not answer the sweep of files gone for good; it tries again next run.', null]], $this->lines); + } + + /** + * A run spends its time budget and no more: out of it, the rest waits for + * the next run, quietly. + */ + public function testARunStopsAtItsTimeBudget(): void { + $this->table([ + self::row(1, 11, 'pad-1', trashedAt: 100), + self::row(2, 12, 'pad-2', trashedAt: 101), + self::row(3, 13, 'pad-3', missingSince: self::NOW - 8 * self::DAY), + ], []); + $this->onDelete = function (): void { + $this->clock->advance(19); + }; + + $this->sweep(); + + $this->assertSame(['pad-1'], $this->deletedPads); + $this->assertSame([12, 13], $this->fileIds()); + $this->assertSame(0, $this->config['cursor']); + $this->assertSame([['info', 'The file of a pad is gone for good; the pad is deleted.', 11]], $this->lines); + } + + /** + * A file the file cache has again by the time its pad would go keeps it. + */ + public function testTheFileCacheIsAskedOnceMoreBeforeAPadGoes(): void { + $this->table([self::row(1, 11, 'pad-1', trashedAt: 100)], []); + $bindings = new class($this->db, $this->clock) extends BindingService { + public function isFileGone(int $fileId): bool { + return false; + } + }; + + $this->sweep($bindings); + + $this->assertSame([], $this->deletedPads); + $this->assertSame([11], $this->fileIds()); + } + + /** + * The pass takes 200 rows a run, from where the last one stopped, and + * starts over after a slice shorter than that. Rows that wait are the + * older sweep's and are not passed. + */ + public function testThePassGoesOnWhereItStoppedAndStartsOver(): void { + $rows = []; + for ($id = 1; $id <= 250; $id++) { + $rows[] = self::row($id, 1000 + $id, 'pad-' . $id); + } + $rows[] = self::row(251, 2000, 'pad-waiting', state: BindingService::STATE_PENDING_DELETE); + $cached = array_map(static fn (int $id): array => self::cached(1000 + $id, 'files/' . $id . '.pad'), range(1, 250)); + $this->table($rows, $cached); + + $this->sweep(); + $this->assertSame(200, $this->config['cursor']); + + $this->sweep(); + $this->assertSame(0, $this->config['cursor']); + $this->assertSame([null, null], $this->dates()[2000]); + } + + /** + * @param list> $rows + * @param list> $fileCache + */ + private function table(array $rows, array $fileCache): void { + $this->db = new InMemoryBindingTable($rows, $fileCache); + } + + private function sweep(?BindingService $bindings = null): void { + $etherpad = $this->createMock(EtherpadClient::class); + $etherpad->method('deletePad')->willReturnCallback(function (string $padId): void { + if (isset($this->padErrors[$padId])) { + throw $this->padErrors[$padId]; + } + $this->deletedPads[] = $padId; + if ($this->onDelete !== null) { + ($this->onDelete)(); + } + }); + $config = $this->createMock(AppConfigService::class); + $config->method('isDeleteOnTrashEnabled')->willReturnCallback(fn (): bool => (bool)$this->config['delete_on_trash']); + $config->method('getGoneFileGraceSeconds')->willReturnCallback(fn (): int => (int)$this->config['grace']); + $config->method('getGoneFileBrakeThreshold')->willReturnCallback(fn (): int => (int)$this->config['threshold']); + $config->method('getGoneFileBrakeReleasedAt')->willReturnCallback(fn (): int => (int)$this->config['released_at']); + $config->method('isGoneFileBrakeEngaged')->willReturnCallback(fn (): bool => (bool)$this->config['engaged']); + $config->method('setGoneFileBrakeEngaged')->willReturnCallback(function (bool $engaged): void { + $this->config['engaged'] = $engaged; + }); + $config->method('getGoneFileSweepCursor')->willReturnCallback(fn (): int => (int)$this->config['cursor']); + $config->method('setGoneFileSweepCursor')->willReturnCallback(function (int $cursor): void { + $this->config['cursor'] = $cursor; + }); + $logger = $this->createMock(LoggerInterface::class); + foreach (['debug', 'info', 'warning'] as $level) { + $logger->method($level)->willReturnCallback(function (string $message, array $context) use ($level): void { + $this->lines[] = [$level, $message, $context['fileId'] ?? null]; + }); + } + (new GoneFileSweep( + $bindings ?? new BindingService($this->db, $this->clock), + new ManagedPadLifecycle($etherpad, $this->createMock(LoggerInterface::class)), + $config, + $this->clock, + $logger, + ))->run(); + } + + /** @return list the files that still have a row */ + private function fileIds(): array { + return array_map(static fn (array $row): int => $row['file_id'], $this->db->rows); + } + + /** @return array trashed_at and missing_since by file */ + private function dates(): array { + $dates = []; + foreach ($this->db->rows as $row) { + $dates[$row['file_id']] = [$row['trashed_at'], $row['missing_since']]; + } + return $dates; + } + + /** @return array */ + private static function row(int $id, int $fileId, string $padId, ?int $trashedAt = null, ?int $missingSince = null, string $state = BindingService::STATE_ACTIVE): array { + return ['id' => $id, 'file_id' => $fileId, 'pad_id' => $padId, 'access_mode' => BindingService::ACCESS_PUBLIC, 'state' => $state, 'deleted_at' => null, 'updated_at' => 100, 'trashed_at' => $trashedAt, 'missing_since' => $missingSince]; + } + + /** @return array */ + private static function cached(int $fileId, string $path): array { + return ['fileid' => $fileId, 'storage' => 1, 'path' => $path]; + } +} From 00eec47efa58169cf99bb66a9fedd911af1f110a Mon Sep 17 00:00:00 2001 From: Jaggob <37583151+Jaggob@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:03:14 +0200 Subject: [PATCH 04/86] Let an admin see and release the brake of the sweep of files gone for good The consistency check counts the active rows whose file went missing without being seen leaving Files, in their grace period or held, and says whether the brake holds; its message then names the brake. While it holds, the admin page offers to release it, after asking: POST /api/v1/admin/gone-file-brake/release. Rows missing until then no longer count, and their pads go once past their grace period. architecture.md describes the sweep under "Files gone for good", with the two columns and the events that mark a row; api-reference.md lists the new fields and the endpoint, and drops the consistency counters the check never returned. New strings in de, es, fr and it. --- appinfo/routes.php | 1 + docs/api-reference.md | 13 +++--- docs/architecture.md | 25 ++++++++++- js/etherpad_nextcloud-admin-settings.mjs | 2 +- js/etherpad_nextcloud-admin-settings.mjs.map | 2 +- l10n/de.js | 6 +++ l10n/de.json | 6 +++ l10n/es.js | 6 +++ l10n/es.json | 6 +++ l10n/fr.js | 6 +++ l10n/fr.json | 6 +++ l10n/it.js | 6 +++ l10n/it.json | 6 +++ lib/Controller/AdminController.php | 20 +++++++++ .../AdminConsistencyCheckResponseBuilder.php | 11 +++-- lib/Service/AppConfigService.php | 8 +++- lib/Service/ConsistencyCheckService.php | 23 ++++++++++ lib/Service/GoneFileSweep.php | 9 ++++ lib/Settings/AdminSettings.php | 5 +++ src/admin-settings.js | 37 +++++++++++++++- templates/admin-settings.php | 7 ++++ tests/js/admin-settings.test.js | 42 +++++++++++++++++++ .../phpunit/Support/InMemoryBindingQuery.php | 21 ++++++++-- ...minConsistencyCheckResponseBuilderTest.php | 15 +++++++ tests/phpunit/unit/AdminControllerTest.php | 18 ++++++++ tests/phpunit/unit/AppConfigServiceTest.php | 3 ++ .../unit/ConsistencyCheckServiceTest.php | 39 +++++++++++++++++ tests/phpunit/unit/GoneFileSweepTest.php | 21 ++++++---- 28 files changed, 346 insertions(+), 24 deletions(-) create mode 100644 tests/phpunit/unit/ConsistencyCheckServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 64359ec5..4874f30c 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -31,6 +31,7 @@ ['name' => 'admin#healthCheck', 'url' => '/api/v1/admin/health', 'verb' => 'POST'], ['name' => 'admin#consistencyCheck', 'url' => '/api/v1/admin/consistency-check', 'verb' => 'POST'], ['name' => 'admin#settlePending', 'url' => '/api/v1/admin/settle-pending', 'verb' => 'POST'], + ['name' => 'admin#releaseGoneFileBrake', 'url' => '/api/v1/admin/gone-file-brake/release', 'verb' => 'POST'], ['name' => 'admin#setTestFault', 'url' => '/api/v1/admin/test-fault', 'verb' => 'POST'], ['name' => 'admin#listPadTemplates', 'url' => '/api/v1/admin/templates', 'verb' => 'GET'], ['name' => 'admin#uploadPadTemplate', 'url' => '/api/v1/admin/templates', 'verb' => 'POST'], diff --git a/docs/api-reference.md b/docs/api-reference.md index 8f43b8c5..9f65125d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -392,13 +392,16 @@ solely by the separate external-pad policy, not by these two settings. - Auth: admin only - Purpose: optional structural integrity check across binding table and `.pad` files. - Result includes: - - `binding_without_file_count` - - `file_without_binding_count` - - `invalid_frontmatter_count` - - `frontmatter_scanned` - - `frontmatter_skipped` + - `binding_without_file_count`: rows whose file the file cache has nothing of + - `missing_file_count`: active rows whose file went missing without being seen leaving Files, in their grace period or held by the brake + - `gone_file_brake_engaged`: whether the brake holds; the message then names it - `samples` (bounded debug sample lists per issue class) +- `POST /api/v1/admin/gone-file-brake/release` + - Controller: `AdminController::releaseGoneFileBrake` + - Auth: admin only + - Purpose: releases the brake of the sweep of files gone for good (`docs/architecture.md`, "Files gone for good"). Rows missing until now no longer count, and their pads are deleted once past their grace period. + - `POST /api/v1/admin/settle-pending` - Controller: `AdminController::settlePending` - Auth: admin only diff --git a/docs/architecture.md b/docs/architecture.md index 7f009b5d..8178f936 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,6 +86,7 @@ checked-in runtime assets in `js/`. - `deleted_at` - `created_at` - `updated_at` + - `trashed_at`, `missing_since`: when the file was seen leaving Files, and when a sweep first missed it without that (`Version000005Date20260926150000`, see "Files gone for good") - stores internal managed pads only; external `ext.*` rows from earlier development versions are removed by `Version000003Date20260512230000` - `.pad` file - Frontmatter: format, binding metadata, state, export metadata. @@ -275,11 +276,31 @@ Primary flow (native viewer): - `deleted_at` is set only in `pending_delete`, when a row gets there, and kept while it stays. - External pads skip lifecycle side effects entirely. Trash/restore only affects the Nextcloud file; the remote Etherpad server is never mutated. +### 5b) Files gone for good + +- A pad goes once its `.pad` file is gone for good: the file cache has nothing left of it. For now this covers active rows, beside the trash flow above: files whose trash that flow never saw, such as a team folder deleted as a whole, a file deleted past the trash, a user deleted, or a file cache rebuilt. +- The row of a `.pad` file seen leaving Files gets `trashed_at` (`MarkLeavingPadsListener`): a file or folder moved to a trash (`MoveToTrashEvent`), one deleted past it (`BeforeNodeDeletedEvent`), and every file of a user about to be deleted (`BeforeUserDeletedEvent`). A folder's files are found in the file cache by the folder's storage and path. The listener never stops a trash or a delete: a mark that fails is a warning, and the sweep finds the file later. +- `GoneFileSweep` runs every five minutes (`GoneFileSweepJob`), within 20 s (`RunBudget`): + - Active rows with `trashed_at` whose file the file cache has nothing of are gone for good, however their trash was emptied: by a user, by expiry, by `occ trashbin:cleanup`, or by groupfolders, which sends no event. Pad, then row, the earliest mark first. + - Then a slice of 200 active rows, in id order from where the last one stopped, starting over after the last. It sets `trashed_at` for a file under `files_trashbin/` or `__groupfolders/trash/` and clears it for one back in Files. It sets `missing_since` for a file the file cache has nothing of and that was never seen leaving, and clears it for one found again. Only the listener marks the trash of a team folder with its own storage (a bare `trash/`). + - A file missing without a mark keeps its pad for a grace period (`gone_file_grace_seconds`, seven days). After it, pad and row go, with a warning that names the pad: `The file of a pad has been missing past the grace period without passing a trash; the pad is deleted.` + - The brake holds when more rows have gone missing without a mark since its last release than its threshold (`gone_file_brake_threshold`, 20), as when a file cache is lost or a storage is gone. While it holds, no pad goes after its grace period. + - It is checked once a slice, after the slice's dates are in, so it holds while the files go missing and an admin has the grace period to look. + - `Many .pad files went missing at once without passing a trash; their pads are kept until an admin releases the brake.` is logged once, when it starts to hold. + - The consistency check shows it, and the admin page releases it (`POST /api/v1/admin/gone-file-brake/release`). Rows missing until then no longer count, and their pads go once past their grace period. + - Files seen leaving are never held. + - Right before a pad goes, the file cache is asked once more. + - A pad Etherpad no longer has counts as deleted, and its row goes. + - A pad Etherpad refuses to delete keeps its row and is tried again next run, with a warning. + - Etherpad not answering ends the run with one `info` line, and the slice it cut short is taken again. + - With `delete_on_trash` off no pad goes here, but the dates and the brake are kept, so switching it on does not start a grace period over. +- The settings live in the app config: `gone_file_grace_seconds` and `gone_file_brake_threshold`. The sweep keeps `gone_file_brake_released_at`, `gone_file_brake_engaged` and `gone_file_sweep_cursor` there too. + ### 6) Admin Integrity Check (optional) 1. Admin runs `POST /api/v1/admin/consistency-check`. 2. Service scans DB/file metadata consistency. -3. Returns aggregate counters and bounded sample lists for diagnostics. +3. Returns aggregate counters and bounded sample lists for diagnostics: rows whose file is gone, and among the active ones those missing without a mark (in their grace period, or held), with the state of the brake. While the brake holds, the admin page offers to release it. 4. External `.pad` files without bindings are expected and are excluded from missing-binding diagnostics. ## Main Frontend Modules @@ -332,6 +353,8 @@ Primary flow (native viewer): - Register the viewer handler on public-share pages and load the one-shot opener for public single-file `.pad` shares or existing compatibility links. - `OCA\Files_Trashbin\Events\MoveToTrashEvent`, and the legacy event `OCA\Files_Trashbin::moveToTrash` - Trash lifecycle. +- `OCA\Files_Trashbin\Events\MoveToTrashEvent`, `OCP\Files\Events\Node\BeforeNodeDeletedEvent`, `OCP\User\Events\BeforeUserDeletedEvent` + - Mark the rows of `.pad` files leaving Files (see "Files gone for good"). - `OCA\Files_Trashbin\Events\NodeRestoredEvent` - Restore lifecycle. - `\OCA\Files_Trashbin\Trashbin::post_restore` (legacy hook) diff --git a/js/etherpad_nextcloud-admin-settings.mjs b/js/etherpad_nextcloud-admin-settings.mjs index 63e238fb..8315a020 100644 --- a/js/etherpad_nextcloud-admin-settings.mjs +++ b/js/etherpad_nextcloud-admin-settings.mjs @@ -1,2 +1,2 @@ -(function(){const s=document.getElementById("etherpad-nextcloud-admin-settings"),r=document.getElementById("etherpad-nextcloud-admin-form"),f=document.getElementById("etherpad-nextcloud-admin-status"),d=document.getElementById("etherpad-nextcloud-diagnostics-status"),h=document.getElementById("etherpad-nextcloud-connection-status"),H=document.getElementById("etherpad-nextcloud-health-check"),M=document.getElementById("etherpad-nextcloud-consistency-check"),y=document.getElementById("etherpad-nextcloud-settle-pending"),B=document.getElementById("etherpad-nextcloud-pending-actions"),I=document.getElementById("etherpad-nextcloud-pending-count"),$=document.getElementById("etherpad-nextcloud-restore-pending-count"),_=r?r.querySelector('input[name="allow_external_pads"]'):null,w=r?r.querySelector('input[name="enable_protected_pads"]'):null,L=r?r.querySelector('input[name="enable_public_pads"]'):null,b=document.getElementById("pad-types-none-hint"),F=document.getElementById("external-pad-allowlist-row"),D=document.getElementById("external-pad-allowlist-hint"),v=document.getElementById("external-pad-allowlist"),E=document.getElementById("epnc-template-list"),O=document.getElementById("epnc-template-empty"),p=document.getElementById("epnc-template-file"),R=document.getElementById("epnc-template-upload"),c=document.getElementById("epnc-template-status"),x={etherpad_host:r.querySelector('[name="etherpad_host"]'),etherpad_api_host:r.querySelector('[name="etherpad_api_host"]'),etherpad_cookie_domain:r.querySelector('[name="etherpad_cookie_domain"]'),etherpad_api_key:r.querySelector('[name="etherpad_api_key"]'),sync_interval_seconds:r.querySelector('[name="sync_interval_seconds"]'),external_pad_allowlist:r.querySelector('[name="external_pad_allowlist"]'),trusted_embed_origins:r.querySelector('[name="trusted_embed_origins"]')};if(!s||!r||!f||!H)return;const N=s.getAttribute("data-save-url")||"",P=s.getAttribute("data-health-url")||"",j=s.getAttribute("data-consistency-url")||"",U=s.getAttribute("data-settle-pending-url")||"",i={saving:s.getAttribute("data-l10n-saving")||"Saving settings...",saved:s.getAttribute("data-l10n-saved")||"Settings saved.",checking:s.getAttribute("data-l10n-checking")||"Testing Etherpad connection...",consistencyRunning:s.getAttribute("data-l10n-consistency-running")||"Running consistency check...",consistencyOk:s.getAttribute("data-l10n-consistency-ok")||"Consistency check successful.",requestFailed:s.getAttribute("data-l10n-request-failed")||"Request failed.",savingFailed:s.getAttribute("data-l10n-saving-failed")||"Failed to save settings.",healthFailed:s.getAttribute("data-l10n-health-failed")||"Etherpad connection test failed.",consistencyFailed:s.getAttribute("data-l10n-consistency-failed")||"Consistency check failed.",pendingDeleteLabel:s.getAttribute("data-l10n-pending-delete-label")||"Pending Etherpad deletes",restorePendingLabel:s.getAttribute("data-l10n-restore-pending-label")||"Unresolved restores",settleFailed:s.getAttribute("data-l10n-settle-failed")||"Pending pad check failed.",templateUploading:s.getAttribute("data-l10n-template-uploading")||"Uploading template...",templateDelete:s.getAttribute("data-l10n-template-delete")||"Delete",templateTooLarge:s.getAttribute("data-l10n-template-too-large")||"Template file is too large.",templateDeleteLabel:s.getAttribute("data-l10n-template-delete-label")||"Delete template {name}",templateConfirmDelete:s.getAttribute("data-l10n-template-confirm-delete")||"Delete this template for everyone?",templateFailed:s.getAttribute("data-l10n-template-failed")||"Template request failed.",templateConfirmReplace:s.getAttribute("data-l10n-template-confirm-replace")||"Replace the existing template of that name?"},T=s.getAttribute("data-templates-url")||"",Z=s.getAttribute("data-templates-delete-url")||"";if(N===""||P===""||j===""||U==="")return;function z(){const e=!!(_&&_.checked);if(F&&(F.style.display=e?"":"none"),D&&(D.style.display=e?"":"none"),v instanceof HTMLTextAreaElement&&(v.disabled=!e,!e)){v.classList.remove("ep-input-error");const t=r.querySelector('[data-field-error="external_pad_allowlist"]');t instanceof HTMLElement&&(t.textContent="",t.classList.remove("is-visible"))}}function J(){const e=!!(w&&L)&&!w.checked&&!L.checked;if(b instanceof HTMLElement){const t=e&&b.dataset.message||"";b.textContent!==t&&(b.textContent=t)}}function K(){const e=new FormData(r);return{etherpad_host:String(e.get("etherpad_host")||"").trim(),etherpad_api_host:String(e.get("etherpad_api_host")||"").trim(),etherpad_cookie_domain:String(e.get("etherpad_cookie_domain")||"").trim(),etherpad_api_key:String(e.get("etherpad_api_key")||"").trim(),sync_interval_seconds:Number(e.get("sync_interval_seconds")||120),delete_on_trash:e.has("delete_on_trash"),enable_protected_pads:e.has("enable_protected_pads"),allow_legacy_protected_import:e.has("allow_legacy_protected_import"),enable_public_pads:e.has("enable_public_pads"),allow_external_pads:e.has("allow_external_pads"),external_pad_allowlist:String(e.get("external_pad_allowlist")||""),trusted_embed_origins:String(e.get("trusted_embed_origins")||"")}}function l(e,t,n=f){n instanceof HTMLElement&&(n.textContent=e,n.classList.remove("ep-status-success","ep-status-warning","ep-status-error"),(t==="success"||t==="warning"||t==="error")&&n.classList.add(`ep-status-${t}`))}function m(e,t=f){const n=t===c?[c]:[f,d,h];for(const a of n)a instanceof HTMLElement&&a!==t&&(a.textContent="",a.classList.remove("ep-status-success","ep-status-warning","ep-status-error"));l(e,null,t)}const V={skipped:0,ok:1,warning:2};function k(e){for(const t of document.querySelectorAll("[data-check-result]"))t.textContent="",t.className="ep-check-result",delete t.dataset.checkStatus;if(Array.isArray(e))for(const t of e){if(!t||typeof t.label!="string")continue;const n=String(t.status||"skipped"),a=typeof t.detail=="string"?t.detail:"",o=t.field?document.querySelector(`[data-check-result="${CSS.escape(String(t.field))}"]`):null;if(!(o instanceof HTMLElement))continue;const Y=o.dataset.checkStatus;Y&&(V[Y]??0)>(V[n]??0)||(o.dataset.checkStatus=n,o.className=`ep-check-result ep-check-${n}`,o.textContent=n==="ok"?t.label:a||t.label)}}function ee(e){if(!(E instanceof HTMLElement))return;E.replaceChildren();const t=Array.isArray(e)?e:[];A(t.length===0);for(const n of t)!n||typeof n.name!="string"||E.appendChild(te(n.name))}function te(e){const t=document.createElement("li");t.className="ep-template-row";const n=document.createElement("span");n.className="ep-template-name",n.textContent=e,t.appendChild(n);const a=document.createElement("button");return a.type="button",a.textContent=i.templateDelete,a.setAttribute("aria-label",i.templateDeleteLabel.replace("{name}",e)),a.addEventListener("click",()=>{window.confirm(i.templateConfirmDelete)&&ae(e)}),t.appendChild(a),t}function A(e){O instanceof HTMLElement&&(O.style.display=e?"":"none")}let G=0;async function C(){if(T===""||!(E instanceof HTMLElement))return;const e=++G,t=()=>e===G;A(!1);try{const n=await fetch(T,{credentials:"same-origin",headers:{requesttoken:String(OC.requestToken||"")}}),a=await q(n);if(!t())return;ee(a.templates)}catch(n){if(!t())return;A(!1),l(n instanceof Error?n.message:i.templateFailed,"error",c)}}const ne=2*1024*1024;async function Q(e,t=!1){if(typeof e.size=="number"&&e.size>ne){l(i.templateTooLarge,"error",c);return}m(i.templateUploading,c);try{const n=await e.text(),a=await se(T,{name:e.name,content:n,replace:t});l(String(a.message||""),"success",c),await C()}catch(n){if(!t&&n&&n.field==="template_exists"){if(window.confirm(i.templateConfirmReplace)){await Q(e,!0);return}l("",null,c);return}l(n instanceof Error?n.message:i.templateFailed,"error",c)}}async function ae(e){try{const t=await g(Z,{name:e});l(String(t.message||""),"success",c),await C()}catch(t){l(t instanceof Error?t.message:i.templateFailed,"error",c)}}function S(){Object.keys(x).forEach(e=>{const t=x[e];t instanceof HTMLElement&&t.classList.remove("ep-input-error");const n=r.querySelector(`[data-field-error="${e}"]`);n instanceof HTMLElement&&(n.textContent="",n.classList.remove("is-visible"))})}function W(e,t){if(!e||!t)return;const n=x[e];n instanceof HTMLElement&&n.classList.add("ep-input-error");const a=r.querySelector(`[data-field-error="${e}"]`);a instanceof HTMLElement&&(a.textContent=t,a.classList.add("is-visible")),n instanceof HTMLElement&&typeof n.focus=="function"&&n.focus()}const u={pendingDeletes:0,pendingRestores:0};function X(e){const t={};if(typeof e.pending_delete_count<"u"&&(t.pendingDeletes=e.pending_delete_count),typeof e.restore_pending_count<"u"&&(t.pendingRestores=e.restore_pending_count),Object.keys(t).length!==0){for(const[n,a]of Object.entries(t))u[n]=Number.isFinite(Number(a))?Number(a):0;B instanceof HTMLElement&&(B.style.display=u.pendingDeletes>0||u.pendingRestores>0?"":"none"),I instanceof HTMLElement&&(I.textContent=`${i.pendingDeleteLabel}: ${String(u.pendingDeletes)}`),$ instanceof HTMLElement&&($.textContent=`${i.restorePendingLabel}: ${String(u.pendingRestores)}`),y instanceof HTMLButtonElement&&(y.disabled=u.pendingDeletes<=0&&u.pendingRestores<=0)}}async function q(e){let t=null;const n=await e.text();try{t=n!==""?JSON.parse(n):null}catch{t=null}if(!e.ok||!t||t.ok!==!0){const a=t&&t.message?String(t.message):`${i.requestFailed} (HTTP ${e.status})`,o=new Error(a);throw o.field=t&&typeof t.field=="string"?t.field:"",o}return t}async function se(e,t){const n=await fetch(e,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json",requesttoken:String(OC.requestToken||"")},body:JSON.stringify(t)});return q(n)}async function g(e,t){const n=new URLSearchParams;Object.keys(t).forEach(o=>{n.set(o,String(t[o]))});const a=await fetch(e,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8",requesttoken:String(OC.requestToken||"")},body:n.toString()});return q(a)}r.addEventListener("submit",async e=>{e.preventDefault(),S(),k([]),m(i.saving);try{const t=await g(N,K());k(t.checks);const n=t&&t.api_version?` api=${String(t.api_version)}`:"";l(`${String(t.message||i.saved)}${n}`,"success")}catch(t){t&&typeof t.field=="string"&&t.field!==""&&W(t.field,t.message||i.savingFailed),l(t instanceof Error?t.message:i.savingFailed,"error")}}),H.addEventListener("click",async()=>{S(),k([]),m(i.checking,h);try{const e=await g(P,K());X(e),k(e.checks);const t=Array.isArray(e.checks)&&e.checks.some(n=>n&&n.status==="warning");l(String(e.message),t?"warning":"success",h)}catch(e){e&&typeof e.field=="string"&&e.field!==""&&W(e.field,e.message||i.healthFailed),l(e instanceof Error?e.message:i.healthFailed,"error",h)}}),M instanceof HTMLElement&&M.addEventListener("click",async()=>{S(),m(i.consistencyRunning,d);try{const e=await g(j,{}),t=Number(e.binding_without_file_count||0),n=`${String(e.message||i.consistencyOk)} binding_without_file=${String(t)}`;l(n,t>0?"error":"success",d)}catch(e){l(e instanceof Error?e.message:i.consistencyFailed,"error",d)}}),y instanceof HTMLElement&&y.addEventListener("click",async()=>{S(),m(i.checking,d);try{const e=await g(U,{}),t=[];for(const a of["checked","settled","restore_pending_count","pending_delete_count"])typeof e[a]<"u"&&t.push(`${a}=${String(e[a])}`);X(e);const n=t.length>0?` ${t.join(" | ")}`:"";l(`${String(e.message||"OK")}${n}`,"success",d)}catch(e){l(e instanceof Error?e.message:i.settleFailed,"error",d)}}),R instanceof HTMLElement&&p instanceof HTMLInputElement&&(R.addEventListener("click",()=>{p.click()}),p.addEventListener("change",()=>{const e=p.files&&p.files[0];e&&Q(e),p.value=""}),C()),_&&_.addEventListener("change",z),z();for(const e of[w,L])e&&e.addEventListener("change",J);J()})(); +(function(){const s=document.getElementById("etherpad-nextcloud-admin-settings"),r=document.getElementById("etherpad-nextcloud-admin-form"),h=document.getElementById("etherpad-nextcloud-admin-status"),c=document.getElementById("etherpad-nextcloud-diagnostics-status"),y=document.getElementById("etherpad-nextcloud-connection-status"),q=document.getElementById("etherpad-nextcloud-health-check"),H=document.getElementById("etherpad-nextcloud-consistency-check"),_=document.getElementById("etherpad-nextcloud-settle-pending"),M=document.getElementById("etherpad-nextcloud-pending-actions"),I=document.getElementById("etherpad-nextcloud-pending-count"),F=document.getElementById("etherpad-nextcloud-restore-pending-count"),$=document.getElementById("etherpad-nextcloud-brake-actions"),D=document.getElementById("etherpad-nextcloud-release-brake"),b=r?r.querySelector('input[name="allow_external_pads"]'):null,S=r?r.querySelector('input[name="enable_protected_pads"]'):null,L=r?r.querySelector('input[name="enable_public_pads"]'):null,k=document.getElementById("pad-types-none-hint"),R=document.getElementById("external-pad-allowlist-row"),O=document.getElementById("external-pad-allowlist-hint"),x=document.getElementById("external-pad-allowlist"),E=document.getElementById("epnc-template-list"),N=document.getElementById("epnc-template-empty"),p=document.getElementById("epnc-template-file"),P=document.getElementById("epnc-template-upload"),d=document.getElementById("epnc-template-status"),v={etherpad_host:r.querySelector('[name="etherpad_host"]'),etherpad_api_host:r.querySelector('[name="etherpad_api_host"]'),etherpad_cookie_domain:r.querySelector('[name="etherpad_cookie_domain"]'),etherpad_api_key:r.querySelector('[name="etherpad_api_key"]'),sync_interval_seconds:r.querySelector('[name="sync_interval_seconds"]'),external_pad_allowlist:r.querySelector('[name="external_pad_allowlist"]'),trusted_embed_origins:r.querySelector('[name="trusted_embed_origins"]')};if(!s||!r||!h||!q)return;const j=s.getAttribute("data-save-url")||"",U=s.getAttribute("data-health-url")||"",z=s.getAttribute("data-consistency-url")||"",J=s.getAttribute("data-settle-pending-url")||"",K=s.getAttribute("data-release-brake-url")||"",i={saving:s.getAttribute("data-l10n-saving")||"Saving settings...",saved:s.getAttribute("data-l10n-saved")||"Settings saved.",checking:s.getAttribute("data-l10n-checking")||"Testing Etherpad connection...",consistencyRunning:s.getAttribute("data-l10n-consistency-running")||"Running consistency check...",consistencyOk:s.getAttribute("data-l10n-consistency-ok")||"Consistency check successful.",requestFailed:s.getAttribute("data-l10n-request-failed")||"Request failed.",savingFailed:s.getAttribute("data-l10n-saving-failed")||"Failed to save settings.",healthFailed:s.getAttribute("data-l10n-health-failed")||"Etherpad connection test failed.",consistencyFailed:s.getAttribute("data-l10n-consistency-failed")||"Consistency check failed.",pendingDeleteLabel:s.getAttribute("data-l10n-pending-delete-label")||"Pending Etherpad deletes",restorePendingLabel:s.getAttribute("data-l10n-restore-pending-label")||"Unresolved restores",settleFailed:s.getAttribute("data-l10n-settle-failed")||"Pending pad check failed.",releaseBrakeConfirm:s.getAttribute("data-l10n-release-brake-confirm")||"Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?",releasingBrake:s.getAttribute("data-l10n-releasing-brake")||"Releasing the brake...",releaseBrakeFailed:s.getAttribute("data-l10n-release-brake-failed")||"Releasing the brake failed.",templateUploading:s.getAttribute("data-l10n-template-uploading")||"Uploading template...",templateDelete:s.getAttribute("data-l10n-template-delete")||"Delete",templateTooLarge:s.getAttribute("data-l10n-template-too-large")||"Template file is too large.",templateDeleteLabel:s.getAttribute("data-l10n-template-delete-label")||"Delete template {name}",templateConfirmDelete:s.getAttribute("data-l10n-template-confirm-delete")||"Delete this template for everyone?",templateFailed:s.getAttribute("data-l10n-template-failed")||"Template request failed.",templateConfirmReplace:s.getAttribute("data-l10n-template-confirm-replace")||"Replace the existing template of that name?"},T=s.getAttribute("data-templates-url")||"",ae=s.getAttribute("data-templates-delete-url")||"";if(j===""||U===""||z===""||J==="")return;function V(){const e=!!(b&&b.checked);if(R&&(R.style.display=e?"":"none"),O&&(O.style.display=e?"":"none"),x instanceof HTMLTextAreaElement&&(x.disabled=!e,!e)){x.classList.remove("ep-input-error");const t=r.querySelector('[data-field-error="external_pad_allowlist"]');t instanceof HTMLElement&&(t.textContent="",t.classList.remove("is-visible"))}}function G(){const e=!!(S&&L)&&!S.checked&&!L.checked;if(k instanceof HTMLElement){const t=e&&k.dataset.message||"";k.textContent!==t&&(k.textContent=t)}}function Q(){const e=new FormData(r);return{etherpad_host:String(e.get("etherpad_host")||"").trim(),etherpad_api_host:String(e.get("etherpad_api_host")||"").trim(),etherpad_cookie_domain:String(e.get("etherpad_cookie_domain")||"").trim(),etherpad_api_key:String(e.get("etherpad_api_key")||"").trim(),sync_interval_seconds:Number(e.get("sync_interval_seconds")||120),delete_on_trash:e.has("delete_on_trash"),enable_protected_pads:e.has("enable_protected_pads"),allow_legacy_protected_import:e.has("allow_legacy_protected_import"),enable_public_pads:e.has("enable_public_pads"),allow_external_pads:e.has("allow_external_pads"),external_pad_allowlist:String(e.get("external_pad_allowlist")||""),trusted_embed_origins:String(e.get("trusted_embed_origins")||"")}}function l(e,t,n=h){n instanceof HTMLElement&&(n.textContent=e,n.classList.remove("ep-status-success","ep-status-warning","ep-status-error"),(t==="success"||t==="warning"||t==="error")&&n.classList.add(`ep-status-${t}`))}function m(e,t=h){const n=t===d?[d]:[h,c,y];for(const a of n)a instanceof HTMLElement&&a!==t&&(a.textContent="",a.classList.remove("ep-status-success","ep-status-warning","ep-status-error"));l(e,null,t)}const W={skipped:0,ok:1,warning:2};function w(e){for(const t of document.querySelectorAll("[data-check-result]"))t.textContent="",t.className="ep-check-result",delete t.dataset.checkStatus;if(Array.isArray(e))for(const t of e){if(!t||typeof t.label!="string")continue;const n=String(t.status||"skipped"),a=typeof t.detail=="string"?t.detail:"",o=t.field?document.querySelector(`[data-check-result="${CSS.escape(String(t.field))}"]`):null;if(!(o instanceof HTMLElement))continue;const ne=o.dataset.checkStatus;ne&&(W[ne]??0)>(W[n]??0)||(o.dataset.checkStatus=n,o.className=`ep-check-result ep-check-${n}`,o.textContent=n==="ok"?t.label:a||t.label)}}function se(e){if(!(E instanceof HTMLElement))return;E.replaceChildren();const t=Array.isArray(e)?e:[];A(t.length===0);for(const n of t)!n||typeof n.name!="string"||E.appendChild(ie(n.name))}function ie(e){const t=document.createElement("li");t.className="ep-template-row";const n=document.createElement("span");n.className="ep-template-name",n.textContent=e,t.appendChild(n);const a=document.createElement("button");return a.type="button",a.textContent=i.templateDelete,a.setAttribute("aria-label",i.templateDeleteLabel.replace("{name}",e)),a.addEventListener("click",()=>{window.confirm(i.templateConfirmDelete)&&le(e)}),t.appendChild(a),t}function A(e){N instanceof HTMLElement&&(N.style.display=e?"":"none")}let X=0;async function C(){if(T===""||!(E instanceof HTMLElement))return;const e=++X,t=()=>e===X;A(!1);try{const n=await fetch(T,{credentials:"same-origin",headers:{requesttoken:String(OC.requestToken||"")}}),a=await B(n);if(!t())return;se(a.templates)}catch(n){if(!t())return;A(!1),l(n instanceof Error?n.message:i.templateFailed,"error",d)}}const re=2*1024*1024;async function Y(e,t=!1){if(typeof e.size=="number"&&e.size>re){l(i.templateTooLarge,"error",d);return}m(i.templateUploading,d);try{const n=await e.text(),a=await oe(T,{name:e.name,content:n,replace:t});l(String(a.message||""),"success",d),await C()}catch(n){if(!t&&n&&n.field==="template_exists"){if(window.confirm(i.templateConfirmReplace)){await Y(e,!0);return}l("",null,d);return}l(n instanceof Error?n.message:i.templateFailed,"error",d)}}async function le(e){try{const t=await g(ae,{name:e});l(String(t.message||""),"success",d),await C()}catch(t){l(t instanceof Error?t.message:i.templateFailed,"error",d)}}function f(){Object.keys(v).forEach(e=>{const t=v[e];t instanceof HTMLElement&&t.classList.remove("ep-input-error");const n=r.querySelector(`[data-field-error="${e}"]`);n instanceof HTMLElement&&(n.textContent="",n.classList.remove("is-visible"))})}function Z(e,t){if(!e||!t)return;const n=v[e];n instanceof HTMLElement&&n.classList.add("ep-input-error");const a=r.querySelector(`[data-field-error="${e}"]`);a instanceof HTMLElement&&(a.textContent=t,a.classList.add("is-visible")),n instanceof HTMLElement&&typeof n.focus=="function"&&n.focus()}const u={pendingDeletes:0,pendingRestores:0};function ee(e){const t={};if(typeof e.pending_delete_count<"u"&&(t.pendingDeletes=e.pending_delete_count),typeof e.restore_pending_count<"u"&&(t.pendingRestores=e.restore_pending_count),Object.keys(t).length!==0){for(const[n,a]of Object.entries(t))u[n]=Number.isFinite(Number(a))?Number(a):0;M instanceof HTMLElement&&(M.style.display=u.pendingDeletes>0||u.pendingRestores>0?"":"none"),I instanceof HTMLElement&&(I.textContent=`${i.pendingDeleteLabel}: ${String(u.pendingDeletes)}`),F instanceof HTMLElement&&(F.textContent=`${i.restorePendingLabel}: ${String(u.pendingRestores)}`),_ instanceof HTMLButtonElement&&(_.disabled=u.pendingDeletes<=0&&u.pendingRestores<=0)}}async function B(e){let t=null;const n=await e.text();try{t=n!==""?JSON.parse(n):null}catch{t=null}if(!e.ok||!t||t.ok!==!0){const a=t&&t.message?String(t.message):`${i.requestFailed} (HTTP ${e.status})`,o=new Error(a);throw o.field=t&&typeof t.field=="string"?t.field:"",o}return t}async function oe(e,t){const n=await fetch(e,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json",requesttoken:String(OC.requestToken||"")},body:JSON.stringify(t)});return B(n)}async function g(e,t){const n=new URLSearchParams;Object.keys(t).forEach(o=>{n.set(o,String(t[o]))});const a=await fetch(e,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8",requesttoken:String(OC.requestToken||"")},body:n.toString()});return B(a)}r.addEventListener("submit",async e=>{e.preventDefault(),f(),w([]),m(i.saving);try{const t=await g(j,Q());w(t.checks);const n=t&&t.api_version?` api=${String(t.api_version)}`:"";l(`${String(t.message||i.saved)}${n}`,"success")}catch(t){t&&typeof t.field=="string"&&t.field!==""&&Z(t.field,t.message||i.savingFailed),l(t instanceof Error?t.message:i.savingFailed,"error")}}),q.addEventListener("click",async()=>{f(),w([]),m(i.checking,y);try{const e=await g(U,Q());ee(e),w(e.checks);const t=Array.isArray(e.checks)&&e.checks.some(n=>n&&n.status==="warning");l(String(e.message),t?"warning":"success",y)}catch(e){e&&typeof e.field=="string"&&e.field!==""&&Z(e.field,e.message||i.healthFailed),l(e instanceof Error?e.message:i.healthFailed,"error",y)}}),H instanceof HTMLElement&&H.addEventListener("click",async()=>{f(),m(i.consistencyRunning,c);try{const e=await g(z,{}),t=Number(e.binding_without_file_count||0),n=Number(e.missing_file_count||0),a=e.gone_file_brake_engaged===!0,o=`${String(e.message||i.consistencyOk)} binding_without_file=${String(t)} | missing_file=${String(n)}`;l(o,a||t>0?"error":"success",c),te(a)}catch(e){l(e instanceof Error?e.message:i.consistencyFailed,"error",c)}});function te(e){$ instanceof HTMLElement&&($.style.display=e&&K!==""?"":"none")}D instanceof HTMLElement&&D.addEventListener("click",async()=>{if(window.confirm(i.releaseBrakeConfirm)){f(),m(i.releasingBrake,c);try{const e=await g(K,{});te(!1),l(String(e.message||"OK"),"success",c)}catch(e){l(e instanceof Error?e.message:i.releaseBrakeFailed,"error",c)}}}),_ instanceof HTMLElement&&_.addEventListener("click",async()=>{f(),m(i.checking,c);try{const e=await g(J,{}),t=[];for(const a of["checked","settled","restore_pending_count","pending_delete_count"])typeof e[a]<"u"&&t.push(`${a}=${String(e[a])}`);ee(e);const n=t.length>0?` ${t.join(" | ")}`:"";l(`${String(e.message||"OK")}${n}`,"success",c)}catch(e){l(e instanceof Error?e.message:i.settleFailed,"error",c)}}),P instanceof HTMLElement&&p instanceof HTMLInputElement&&(P.addEventListener("click",()=>{p.click()}),p.addEventListener("change",()=>{const e=p.files&&p.files[0];e&&Y(e),p.value=""}),C()),b&&b.addEventListener("change",V),V();for(const e of[S,L])e&&e.addEventListener("change",G);G()})(); //# sourceMappingURL=etherpad_nextcloud-admin-settings.mjs.map diff --git a/js/etherpad_nextcloud-admin-settings.mjs.map b/js/etherpad_nextcloud-admin-settings.mjs.map index 6a97aa56..62ea73bf 100644 --- a/js/etherpad_nextcloud-admin-settings.mjs.map +++ b/js/etherpad_nextcloud-admin-settings.mjs.map @@ -1 +1 @@ -{"version":3,"file":"etherpad_nextcloud-admin-settings.mjs","sources":["../src/admin-settings.js"],"sourcesContent":["(function () {\n\t'use strict'\n\n\tconst root = document.getElementById('etherpad-nextcloud-admin-settings')\n\tconst form = document.getElementById('etherpad-nextcloud-admin-form')\n\tconst statusNode = document.getElementById('etherpad-nextcloud-admin-status')\n\tconst diagnosticsTarget = document.getElementById('etherpad-nextcloud-diagnostics-status')\n\tconst connectionTarget = document.getElementById('etherpad-nextcloud-connection-status')\n\tconst healthButton = document.getElementById('etherpad-nextcloud-health-check')\n\tconst consistencyButton = document.getElementById('etherpad-nextcloud-consistency-check')\n\tconst settlePendingButton = document.getElementById('etherpad-nextcloud-settle-pending')\n\tconst pendingActions = document.getElementById('etherpad-nextcloud-pending-actions')\n\tconst pendingCountNode = document.getElementById('etherpad-nextcloud-pending-count')\n\tconst restorePendingCountNode = document.getElementById('etherpad-nextcloud-restore-pending-count')\n\tconst allowExternalCheckbox = form ? form.querySelector('input[name=\"allow_external_pads\"]') : null\n\tconst protectedPadsCheckbox = form ? form.querySelector('input[name=\"enable_protected_pads\"]') : null\n\tconst publicPadsCheckbox = form ? form.querySelector('input[name=\"enable_public_pads\"]') : null\n\tconst padTypesNoneHint = document.getElementById('pad-types-none-hint')\n\tconst allowlistRow = document.getElementById('external-pad-allowlist-row')\n\tconst allowlistHint = document.getElementById('external-pad-allowlist-hint')\n\tconst allowlistTextarea = document.getElementById('external-pad-allowlist')\n\tconst templateListNode = document.getElementById('epnc-template-list')\n\tconst templateEmptyNode = document.getElementById('epnc-template-empty')\n\tconst templateFileInput = document.getElementById('epnc-template-file')\n\tconst templateUploadButton = document.getElementById('epnc-template-upload')\n\tconst templateStatusNode = document.getElementById('epnc-template-status')\n\tconst fieldNodes = {\n\t\tetherpad_host: form.querySelector('[name=\"etherpad_host\"]'),\n\t\tetherpad_api_host: form.querySelector('[name=\"etherpad_api_host\"]'),\n\t\tetherpad_cookie_domain: form.querySelector('[name=\"etherpad_cookie_domain\"]'),\n\t\tetherpad_api_key: form.querySelector('[name=\"etherpad_api_key\"]'),\n\t\tsync_interval_seconds: form.querySelector('[name=\"sync_interval_seconds\"]'),\n\t\texternal_pad_allowlist: form.querySelector('[name=\"external_pad_allowlist\"]'),\n\t\ttrusted_embed_origins: form.querySelector('[name=\"trusted_embed_origins\"]'),\n\t}\n\n\tif (!root || !form || !statusNode || !healthButton) {\n\t\treturn\n\t}\n\n\tconst saveUrl = root.getAttribute('data-save-url') || ''\n\tconst healthUrl = root.getAttribute('data-health-url') || ''\n\tconst consistencyUrl = root.getAttribute('data-consistency-url') || ''\n\tconst settlePendingUrl = root.getAttribute('data-settle-pending-url') || ''\n\tconst l10n = {\n\t\tsaving: root.getAttribute('data-l10n-saving') || 'Saving settings...',\n\t\tsaved: root.getAttribute('data-l10n-saved') || 'Settings saved.',\n\t\tchecking: root.getAttribute('data-l10n-checking') || 'Testing Etherpad connection...',\n\t\tconsistencyRunning: root.getAttribute('data-l10n-consistency-running') || 'Running consistency check...',\n\t\tconsistencyOk: root.getAttribute('data-l10n-consistency-ok') || 'Consistency check successful.',\n\t\trequestFailed: root.getAttribute('data-l10n-request-failed') || 'Request failed.',\n\t\tsavingFailed: root.getAttribute('data-l10n-saving-failed') || 'Failed to save settings.',\n\t\thealthFailed: root.getAttribute('data-l10n-health-failed') || 'Etherpad connection test failed.',\n\t\tconsistencyFailed: root.getAttribute('data-l10n-consistency-failed') || 'Consistency check failed.',\n\t\tpendingDeleteLabel: root.getAttribute('data-l10n-pending-delete-label') || 'Pending Etherpad deletes',\n\t\trestorePendingLabel: root.getAttribute('data-l10n-restore-pending-label') || 'Unresolved restores',\n\t\tsettleFailed: root.getAttribute('data-l10n-settle-failed') || 'Pending pad check failed.',\n\t\ttemplateUploading: root.getAttribute('data-l10n-template-uploading') || 'Uploading template...',\n\t\ttemplateDelete: root.getAttribute('data-l10n-template-delete') || 'Delete',\n\t\ttemplateTooLarge: root.getAttribute('data-l10n-template-too-large') || 'Template file is too large.',\n\t\ttemplateDeleteLabel: root.getAttribute('data-l10n-template-delete-label') || 'Delete template {name}',\n\t\ttemplateConfirmDelete: root.getAttribute('data-l10n-template-confirm-delete') || 'Delete this template for everyone?',\n\t\ttemplateFailed: root.getAttribute('data-l10n-template-failed') || 'Template request failed.',\n\t\ttemplateConfirmReplace: root.getAttribute('data-l10n-template-confirm-replace') || 'Replace the existing template of that name?',\n\t}\n\tconst templatesUrl = root.getAttribute('data-templates-url') || ''\n\tconst templatesDeleteUrl = root.getAttribute('data-templates-delete-url') || ''\n\n\tif (saveUrl === '' || healthUrl === '' || consistencyUrl === '' || settlePendingUrl === '') {\n\t\treturn\n\t}\n\n\tfunction updateExternalSettingsVisibility() {\n\t\tconst enabled = !!(allowExternalCheckbox && allowExternalCheckbox.checked)\n\t\tif (allowlistRow) {\n\t\t\tallowlistRow.style.display = enabled ? '' : 'none'\n\t\t}\n\t\tif (allowlistHint) {\n\t\t\tallowlistHint.style.display = enabled ? '' : 'none'\n\t\t}\n\t\tif (allowlistTextarea instanceof HTMLTextAreaElement) {\n\t\t\tallowlistTextarea.disabled = !enabled\n\t\t\tif (!enabled) {\n\t\t\t\tallowlistTextarea.classList.remove('ep-input-error')\n\t\t\t\tconst errorNode = form.querySelector('[data-field-error=\"external_pad_allowlist\"]')\n\t\t\t\tif (errorNode instanceof HTMLElement) {\n\t\t\t\t\terrorNode.textContent = ''\n\t\t\t\t\terrorNode.classList.remove('is-visible')\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction updatePadTypesHint() {\n\t\tconst noneEnabled = !!(protectedPadsCheckbox && publicPadsCheckbox)\n\t\t\t&& !protectedPadsCheckbox.checked\n\t\t\t&& !publicPadsCheckbox.checked\n\t\tif (padTypesNoneHint instanceof HTMLElement) {\n\t\t\t// Write the text instead of toggling visibility: a live region only\n\t\t\t// announces content changes, and an element returning from\n\t\t\t// `display: none` reads as initial content. `.ep-field-hint:empty`\n\t\t\t// collapses the empty paragraph, so it looks the same.\n\t\t\tconst message = noneEnabled ? (padTypesNoneHint.dataset.message || '') : ''\n\t\t\t// Rewriting the same text would announce it again on load.\n\t\t\tif (padTypesNoneHint.textContent !== message) {\n\t\t\t\tpadTypesNoneHint.textContent = message\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction getPayload() {\n\t\tconst data = new FormData(form)\n\t\treturn {\n\t\t\tetherpad_host: String(data.get('etherpad_host') || '').trim(),\n\t\t\tetherpad_api_host: String(data.get('etherpad_api_host') || '').trim(),\n\t\t\tetherpad_cookie_domain: String(data.get('etherpad_cookie_domain') || '').trim(),\n\t\t\tetherpad_api_key: String(data.get('etherpad_api_key') || '').trim(),\n\t\t\tsync_interval_seconds: Number(data.get('sync_interval_seconds') || 120),\n\t\t\tdelete_on_trash: data.has('delete_on_trash'),\n\t\t\tenable_protected_pads: data.has('enable_protected_pads'),\n\t\t\tallow_legacy_protected_import: data.has('allow_legacy_protected_import'),\n\t\t\tenable_public_pads: data.has('enable_public_pads'),\n\t\t\tallow_external_pads: data.has('allow_external_pads'),\n\t\t\texternal_pad_allowlist: String(data.get('external_pad_allowlist') || ''),\n\t\t\ttrusted_embed_origins: String(data.get('trusted_embed_origins') || ''),\n\t\t}\n\t}\n\n\t// Writing a status touches only its own area, so a late response cannot\n\t// wipe out the other action's result.\n\tfunction setStatus(message, state, node = statusNode) {\n\t\tif (!(node instanceof HTMLElement)) {\n\t\t\treturn\n\t\t}\n\t\tnode.textContent = message\n\t\tnode.classList.remove('ep-status-success', 'ep-status-warning', 'ep-status-error')\n\t\tif (state === 'success' || state === 'warning' || state === 'error') {\n\t\t\tnode.classList.add(`ep-status-${state}`)\n\t\t}\n\t}\n\n\t// Starting one does clear the other: its result predates this action and\n\t// would be read as belonging to it. The template status is its own group —\n\t// uploading a template makes neither a connection test nor a save result\n\t// stale, and vice versa.\n\tfunction beginStatus(message, node = statusNode) {\n\t\tconst group = node === templateStatusNode\n\t\t\t? [templateStatusNode]\n\t\t\t: [statusNode, diagnosticsTarget, connectionTarget]\n\t\tfor (const other of group) {\n\t\t\tif (other instanceof HTMLElement && other !== node) {\n\t\t\t\tother.textContent = ''\n\t\t\t\tother.classList.remove('ep-status-success', 'ep-status-warning', 'ep-status-error')\n\t\t\t}\n\t\t}\n\t\tsetStatus(message, null, node)\n\t}\n\n\t// Higher wins when two checks land on the same field, so a problem is\n\t// never hidden by a passing verdict that happened to come later.\n\tconst CHECK_SEVERITY = { skipped: 0, ok: 1, warning: 2 }\n\n\t// Each result is shown at the field it came from: a green tick where\n\t// things are fine, the message itself where they are not.\n\tfunction renderConnectionChecks(checks) {\n\t\tfor (const slot of document.querySelectorAll('[data-check-result]')) {\n\t\t\tslot.textContent = ''\n\t\t\tslot.className = 'ep-check-result'\n\t\t\tdelete slot.dataset.checkStatus\n\t\t}\n\t\tif (!Array.isArray(checks)) {\n\t\t\treturn\n\t\t}\n\t\tfor (const check of checks) {\n\t\t\tif (!check || typeof check.label !== 'string') {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst status = String(check.status || 'skipped')\n\t\t\tconst detail = typeof check.detail === 'string' ? check.detail : ''\n\t\t\tconst slot = check.field\n\t\t\t\t? document.querySelector(`[data-check-result=\"${CSS.escape(String(check.field))}\"]`)\n\t\t\t\t: null\n\t\t\tif (!(slot instanceof HTMLElement)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst shown = slot.dataset.checkStatus\n\t\t\tif (shown && (CHECK_SEVERITY[shown] ?? 0) > (CHECK_SEVERITY[status] ?? 0)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tslot.dataset.checkStatus = status\n\t\t\tslot.className = `ep-check-result ep-check-${status}`\n\t\t\t// A passing field needs no prose — the tick and the label say it.\n\t\t\t// A failing one needs the reason right there.\n\t\t\tslot.textContent = status === 'ok' ? check.label : (detail || check.label)\n\t\t}\n\t}\n\n\t// Shared templates: an admin uploads .pad files that everyone then sees as\n\t// tiles in Nextcloud's picker.\n\tfunction renderTemplates(templates) {\n\t\tif (!(templateListNode instanceof HTMLElement)) {\n\t\t\treturn\n\t\t}\n\t\ttemplateListNode.replaceChildren()\n\t\tconst list = Array.isArray(templates) ? templates : []\n\t\tshowTemplateEmptyState(list.length === 0)\n\t\tfor (const template of list) {\n\t\t\tif (!template || typeof template.name !== 'string') {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttemplateListNode.appendChild(buildTemplateRow(template.name))\n\t\t}\n\t}\n\n\tfunction buildTemplateRow(name) {\n\t\tconst row = document.createElement('li')\n\t\trow.className = 'ep-template-row'\n\n\t\tconst label = document.createElement('span')\n\t\tlabel.className = 'ep-template-name'\n\t\tlabel.textContent = name\n\t\trow.appendChild(label)\n\n\t\tconst remove = document.createElement('button')\n\t\tremove.type = 'button'\n\t\tremove.textContent = l10n.templateDelete\n\t\t// The visible label repeats on every row, so the accessible name has to\n\t\t// carry the name of the template this one removes.\n\t\tremove.setAttribute('aria-label', l10n.templateDeleteLabel.replace('{name}', name))\n\t\tremove.addEventListener('click', () => {\n\t\t\tif (!window.confirm(l10n.templateConfirmDelete)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tvoid deleteTemplate(name)\n\t\t})\n\t\trow.appendChild(remove)\n\t\treturn row\n\t}\n\n\tfunction showTemplateEmptyState(visible) {\n\t\tif (templateEmptyNode instanceof HTMLElement) {\n\t\t\ttemplateEmptyNode.style.display = visible ? '' : 'none'\n\t\t}\n\t}\n\n\t// Every upload and delete starts another listing while the first may still\n\t// be in flight. Without this the slower answer wins whenever it happens to\n\t// arrive last, putting a deleted template back on the page or replacing a\n\t// fresh result with an old error.\n\tlet templateListGeneration = 0\n\n\tasync function loadTemplates() {\n\t\tif (templatesUrl === '' || !(templateListNode instanceof HTMLElement)) {\n\t\t\treturn\n\t\t}\n\t\tconst generation = ++templateListGeneration\n\t\tconst isCurrent = () => generation === templateListGeneration\n\n\t\t// \"No shared templates yet\" is an answer, and we do not have one until\n\t\t// the request comes back — showing it next to an error would claim the\n\t\t// list is empty when we simply could not read it.\n\t\tshowTemplateEmptyState(false)\n\t\ttry {\n\t\t\tconst response = await fetch(templatesUrl, {\n\t\t\t\tcredentials: 'same-origin',\n\t\t\t\theaders: { requesttoken: String(OC.requestToken || '') },\n\t\t\t})\n\t\t\tconst data = await readJsonResponse(response)\n\t\t\tif (!isCurrent()) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\trenderTemplates(data.templates)\n\t\t} catch (error) {\n\t\t\tif (!isCurrent()) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tshowTemplateEmptyState(false)\n\t\t\tsetStatus(error instanceof Error ? error.message : l10n.templateFailed, 'error', templateStatusNode)\n\t\t}\n\t}\n\n\t// The same limit the server enforces. Without it a mis-picked large file is\n\t// read into memory and posted, only to hit post_max_size or the memory\n\t// limit — a failure that says nothing, instead of the sentence below.\n\tconst MAX_TEMPLATE_BYTES = 2 * 1024 * 1024\n\n\tasync function uploadTemplate(file, replace = false) {\n\t\tif (typeof file.size === 'number' && file.size > MAX_TEMPLATE_BYTES) {\n\t\t\tsetStatus(l10n.templateTooLarge, 'error', templateStatusNode)\n\t\t\treturn\n\t\t}\n\t\tbeginStatus(l10n.templateUploading, templateStatusNode)\n\t\ttry {\n\t\t\tconst content = await file.text()\n\t\t\tconst data = await postJsonBody(templatesUrl, { name: file.name, content, replace })\n\t\t\tsetStatus(String(data.message || ''), 'success', templateStatusNode)\n\t\t\tawait loadTemplates()\n\t\t} catch (error) {\n\t\t\t// The server refuses to overwrite unless asked, so a name that is\n\t\t\t// already taken comes back as a question rather than a failure.\n\t\t\tif (!replace && error && error.field === 'template_exists') {\n\t\t\t\tif (window.confirm(l10n.templateConfirmReplace)) {\n\t\t\t\t\tawait uploadTemplate(file, true)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tsetStatus('', null, templateStatusNode)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetStatus(error instanceof Error ? error.message : l10n.templateFailed, 'error', templateStatusNode)\n\t\t}\n\t}\n\n\tasync function deleteTemplate(name) {\n\t\ttry {\n\t\t\tconst data = await postJson(templatesDeleteUrl, { name })\n\t\t\tsetStatus(String(data.message || ''), 'success', templateStatusNode)\n\t\t\tawait loadTemplates()\n\t\t} catch (error) {\n\t\t\tsetStatus(error instanceof Error ? error.message : l10n.templateFailed, 'error', templateStatusNode)\n\t\t}\n\t}\n\n\tfunction clearFieldErrors() {\n\t\tObject.keys(fieldNodes).forEach((field) => {\n\t\t\tconst input = fieldNodes[field]\n\t\t\tif (input instanceof HTMLElement) {\n\t\t\t\tinput.classList.remove('ep-input-error')\n\t\t\t}\n\t\t\tconst errorNode = form.querySelector(`[data-field-error=\"${field}\"]`)\n\t\t\tif (errorNode instanceof HTMLElement) {\n\t\t\t\terrorNode.textContent = ''\n\t\t\t\terrorNode.classList.remove('is-visible')\n\t\t\t}\n\t\t})\n\t}\n\n\tfunction showFieldError(field, message) {\n\t\tif (!field || !message) {\n\t\t\treturn\n\t\t}\n\t\tconst input = fieldNodes[field]\n\t\tif (input instanceof HTMLElement) {\n\t\t\tinput.classList.add('ep-input-error')\n\t\t}\n\t\tconst errorNode = form.querySelector(`[data-field-error=\"${field}\"]`)\n\t\tif (errorNode instanceof HTMLElement) {\n\t\t\terrorNode.textContent = message\n\t\t\terrorNode.classList.add('is-visible')\n\t\t}\n\t\tif (input instanceof HTMLElement && typeof input.focus === 'function') {\n\t\t\tinput.focus()\n\t\t}\n\t}\n\n\tconst bindingCounts = { pendingDeletes: 0, pendingRestores: 0 }\n\n\t// Only the counts a response carries; one that carries none leaves the\n\t// panel as it was rather than repainting it from what this page assumed.\n\t// The health check and the pending pad check name them the same.\n\tfunction updateBindingCounts(data) {\n\t\tconst counts = {}\n\t\tif (typeof data.pending_delete_count !== 'undefined') {\n\t\t\tcounts.pendingDeletes = data.pending_delete_count\n\t\t}\n\t\tif (typeof data.restore_pending_count !== 'undefined') {\n\t\t\tcounts.pendingRestores = data.restore_pending_count\n\t\t}\n\t\tif (Object.keys(counts).length === 0) {\n\t\t\treturn\n\t\t}\n\t\tfor (const [key, value] of Object.entries(counts)) {\n\t\t\tbindingCounts[key] = Number.isFinite(Number(value)) ? Number(value) : 0\n\t\t}\n\t\tif (pendingActions instanceof HTMLElement) {\n\t\t\tpendingActions.style.display = bindingCounts.pendingDeletes > 0 || bindingCounts.pendingRestores > 0 ? '' : 'none'\n\t\t}\n\t\tif (pendingCountNode instanceof HTMLElement) {\n\t\t\tpendingCountNode.textContent = `${l10n.pendingDeleteLabel}: ${String(bindingCounts.pendingDeletes)}`\n\t\t}\n\t\tif (restorePendingCountNode instanceof HTMLElement) {\n\t\t\trestorePendingCountNode.textContent = `${l10n.restorePendingLabel}: ${String(bindingCounts.pendingRestores)}`\n\t\t}\n\t\t// The check deletes a pad only once its file is gone for good, and\n\t\t// nothing here is more than a nudge to the job that does the same.\n\t\tif (settlePendingButton instanceof HTMLButtonElement) {\n\t\t\tsettlePendingButton.disabled = bindingCounts.pendingDeletes <= 0 && bindingCounts.pendingRestores <= 0\n\t\t}\n\t}\n\n\t// One place decides what a response means, so a caller cannot forget to\n\t// look at the status or at `ok` in the body.\n\tasync function readJsonResponse(response) {\n\t\tlet data = null\n\t\tconst responseText = await response.text()\n\t\ttry {\n\t\t\tdata = responseText !== '' ? JSON.parse(responseText) : null\n\t\t} catch (error) {\n\t\t\tdata = null\n\t\t}\n\n\t\tif (!response.ok || !data || data.ok !== true) {\n\t\t\t// Only our own JSON is quoted: another body came from something\n\t\t\t// between the browser and the app, and showing its markup reads\n\t\t\t// as an app failure. The status says more than the markup does.\n\t\t\tconst message = (data && data.message)\n\t\t\t\t? String(data.message)\n\t\t\t\t: `${l10n.requestFailed} (HTTP ${response.status})`\n\t\t\tconst err = new Error(message)\n\t\t\terr.field = (data && typeof data.field === 'string') ? data.field : ''\n\t\t\tthrow err\n\t\t}\n\n\t\treturn data\n\t}\n\n\t/**\n\t * POST a JSON body. Used for the template upload, where URL-encoding a\n\t * whole file would inflate it several times over.\n\t */\n\tasync function postJsonBody(url, payload) {\n\t\tconst response = await fetch(url, {\n\t\t\tmethod: 'POST',\n\t\t\tcredentials: 'same-origin',\n\t\t\theaders: {\n\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\trequesttoken: String(OC.requestToken || ''),\n\t\t\t},\n\t\t\tbody: JSON.stringify(payload),\n\t\t})\n\t\treturn readJsonResponse(response)\n\t}\n\n\tasync function postJson(url, payload) {\n\t\tconst body = new URLSearchParams()\n\t\tObject.keys(payload).forEach((key) => {\n\t\t\tbody.set(key, String(payload[key]))\n\t\t})\n\n\t\tconst response = await fetch(url, {\n\t\t\tmethod: 'POST',\n\t\t\tcredentials: 'same-origin',\n\t\t\theaders: {\n\t\t\t\t'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',\n\t\t\t\trequesttoken: String(OC.requestToken || ''),\n\t\t\t},\n\t\t\tbody: body.toString(),\n\t\t})\n\n\t\treturn readJsonResponse(response)\n\t}\n\n\tform.addEventListener('submit', async (event) => {\n\t\tevent.preventDefault()\n\t\tclearFieldErrors()\n\t\t// Verdicts describe the values of the previous run: keeping them up\n\t\t// while this one is in flight puts a green tick next to a field that\n\t\t// is being changed, and a failed save would leave them there.\n\t\trenderConnectionChecks([])\n\t\tbeginStatus(l10n.saving)\n\t\ttry {\n\t\t\tconst data = await postJson(saveUrl, getPayload())\n\t\t\t// Saving answers with the cookie verdict alone; rendering it clears\n\t\t\t// the other fields' results, which the save just invalidated.\n\t\t\trenderConnectionChecks(data.checks)\n\t\t\tconst versionSuffix = data && data.api_version ? ` api=${String(data.api_version)}` : ''\n\t\t\tsetStatus(`${String(data.message || l10n.saved)}${versionSuffix}`, 'success')\n\t\t} catch (error) {\n\t\t\tif (error && typeof error.field === 'string' && error.field !== '') {\n\t\t\t\tshowFieldError(error.field, error.message || l10n.savingFailed)\n\t\t\t}\n\t\t\tsetStatus(error instanceof Error ? error.message : l10n.savingFailed, 'error')\n\t\t}\n\t})\n\n\thealthButton.addEventListener('click', async () => {\n\t\tclearFieldErrors()\n\t\trenderConnectionChecks([])\n\t\tbeginStatus(l10n.checking, connectionTarget)\n\t\ttry {\n\t\t\tconst data = await postJson(healthUrl, getPayload())\n\t\t\tupdateBindingCounts(data)\n\t\t\t// The per-field results carry the target and latency and the\n\t\t\t// protected-pads verdict, so the summary stays a summary.\n\t\t\trenderConnectionChecks(data.checks)\n\t\t\tconst needsAttention = Array.isArray(data.checks)\n\t\t\t\t&& data.checks.some((check) => check && check.status === 'warning')\n\t\t\tsetStatus(String(data.message), needsAttention ? 'warning' : 'success', connectionTarget)\n\t\t} catch (error) {\n\t\t\tif (error && typeof error.field === 'string' && error.field !== '') {\n\t\t\t\tshowFieldError(error.field, error.message || l10n.healthFailed)\n\t\t\t}\n\t\t\tsetStatus(error instanceof Error ? error.message : l10n.healthFailed, 'error', connectionTarget)\n\t\t}\n\t})\n\n\tif (consistencyButton instanceof HTMLElement) {\n\t\tconsistencyButton.addEventListener('click', async () => {\n\t\t\tclearFieldErrors()\n\t\t\tbeginStatus(l10n.consistencyRunning, diagnosticsTarget)\n\t\t\ttry {\n\t\t\t\tconst data = await postJson(consistencyUrl, {})\n\t\t\t\tconst bindingWithoutFile = Number(data.binding_without_file_count || 0)\n\t\t\t\tconst message = `${String(data.message || l10n.consistencyOk)} binding_without_file=${String(bindingWithoutFile)}`\n\t\t\t\tsetStatus(message, bindingWithoutFile > 0 ? 'error' : 'success', diagnosticsTarget)\n\t\t\t} catch (error) {\n\t\t\t\tsetStatus(error instanceof Error ? error.message : l10n.consistencyFailed, 'error', diagnosticsTarget)\n\t\t\t}\n\t\t})\n\t}\n\n\tif (settlePendingButton instanceof HTMLElement) {\n\t\tsettlePendingButton.addEventListener('click', async () => {\n\t\t\tclearFieldErrors()\n\t\t\tbeginStatus(l10n.checking, diagnosticsTarget)\n\t\t\ttry {\n\t\t\t\tconst data = await postJson(settlePendingUrl, {})\n\t\t\t\tconst details = []\n\t\t\t\tfor (const key of ['checked', 'settled', 'restore_pending_count', 'pending_delete_count']) {\n\t\t\t\t\tif (typeof data[key] !== 'undefined') {\n\t\t\t\t\t\tdetails.push(`${key}=${String(data[key])}`)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tupdateBindingCounts(data)\n\t\t\t\tconst suffix = details.length > 0 ? ` ${details.join(' | ')}` : ''\n\t\t\t\tsetStatus(`${String(data.message || 'OK')}${suffix}`, 'success', diagnosticsTarget)\n\t\t\t} catch (error) {\n\t\t\t\tsetStatus(error instanceof Error ? error.message : l10n.settleFailed, 'error', diagnosticsTarget)\n\t\t\t}\n\t\t})\n\t}\n\n\tif (templateUploadButton instanceof HTMLElement && templateFileInput instanceof HTMLInputElement) {\n\t\ttemplateUploadButton.addEventListener('click', () => {\n\t\t\ttemplateFileInput.click()\n\t\t})\n\t\ttemplateFileInput.addEventListener('change', () => {\n\t\t\tconst file = templateFileInput.files && templateFileInput.files[0]\n\t\t\tif (file) {\n\t\t\t\tvoid uploadTemplate(file)\n\t\t\t}\n\t\t\t// Reset so picking the same file twice fires again.\n\t\t\ttemplateFileInput.value = ''\n\t\t})\n\t\tvoid loadTemplates()\n\t}\n\n\tif (allowExternalCheckbox) {\n\t\tallowExternalCheckbox.addEventListener('change', updateExternalSettingsVisibility)\n\t}\n\tupdateExternalSettingsVisibility()\n\n\tfor (const checkbox of [protectedPadsCheckbox, publicPadsCheckbox]) {\n\t\tif (checkbox) {\n\t\t\tcheckbox.addEventListener('change', updatePadTypesHint)\n\t\t}\n\t}\n\tupdatePadTypesHint()\n})()\n"],"names":["root","form","statusNode","diagnosticsTarget","connectionTarget","healthButton","consistencyButton","settlePendingButton","pendingActions","pendingCountNode","restorePendingCountNode","allowExternalCheckbox","protectedPadsCheckbox","publicPadsCheckbox","padTypesNoneHint","allowlistRow","allowlistHint","allowlistTextarea","templateListNode","templateEmptyNode","templateFileInput","templateUploadButton","templateStatusNode","fieldNodes","saveUrl","healthUrl","consistencyUrl","settlePendingUrl","l10n","templatesUrl","templatesDeleteUrl","updateExternalSettingsVisibility","enabled","errorNode","updatePadTypesHint","noneEnabled","message","getPayload","data","setStatus","state","node","beginStatus","group","other","CHECK_SEVERITY","renderConnectionChecks","checks","slot","check","status","detail","shown","renderTemplates","templates","list","showTemplateEmptyState","template","buildTemplateRow","name","row","label","remove","deleteTemplate","visible","templateListGeneration","loadTemplates","generation","isCurrent","response","readJsonResponse","error","MAX_TEMPLATE_BYTES","uploadTemplate","file","replace","content","postJsonBody","postJson","clearFieldErrors","field","input","showFieldError","bindingCounts","updateBindingCounts","counts","key","value","responseText","err","url","payload","body","event","versionSuffix","needsAttention","bindingWithoutFile","details","suffix","checkbox"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"etherpad_nextcloud-admin-settings.mjs","sources":["../src/admin-settings.js"],"sourcesContent":["(function () {\n\t'use strict'\n\n\tconst root = document.getElementById('etherpad-nextcloud-admin-settings')\n\tconst form = document.getElementById('etherpad-nextcloud-admin-form')\n\tconst statusNode = document.getElementById('etherpad-nextcloud-admin-status')\n\tconst diagnosticsTarget = document.getElementById('etherpad-nextcloud-diagnostics-status')\n\tconst connectionTarget = document.getElementById('etherpad-nextcloud-connection-status')\n\tconst healthButton = document.getElementById('etherpad-nextcloud-health-check')\n\tconst consistencyButton = document.getElementById('etherpad-nextcloud-consistency-check')\n\tconst settlePendingButton = document.getElementById('etherpad-nextcloud-settle-pending')\n\tconst pendingActions = document.getElementById('etherpad-nextcloud-pending-actions')\n\tconst pendingCountNode = document.getElementById('etherpad-nextcloud-pending-count')\n\tconst restorePendingCountNode = document.getElementById('etherpad-nextcloud-restore-pending-count')\n\tconst brakeActions = document.getElementById('etherpad-nextcloud-brake-actions')\n\tconst releaseBrakeButton = document.getElementById('etherpad-nextcloud-release-brake')\n\tconst allowExternalCheckbox = form ? form.querySelector('input[name=\"allow_external_pads\"]') : null\n\tconst protectedPadsCheckbox = form ? form.querySelector('input[name=\"enable_protected_pads\"]') : null\n\tconst publicPadsCheckbox = form ? form.querySelector('input[name=\"enable_public_pads\"]') : null\n\tconst padTypesNoneHint = document.getElementById('pad-types-none-hint')\n\tconst allowlistRow = document.getElementById('external-pad-allowlist-row')\n\tconst allowlistHint = document.getElementById('external-pad-allowlist-hint')\n\tconst allowlistTextarea = document.getElementById('external-pad-allowlist')\n\tconst templateListNode = document.getElementById('epnc-template-list')\n\tconst templateEmptyNode = document.getElementById('epnc-template-empty')\n\tconst templateFileInput = document.getElementById('epnc-template-file')\n\tconst templateUploadButton = document.getElementById('epnc-template-upload')\n\tconst templateStatusNode = document.getElementById('epnc-template-status')\n\tconst fieldNodes = {\n\t\tetherpad_host: form.querySelector('[name=\"etherpad_host\"]'),\n\t\tetherpad_api_host: form.querySelector('[name=\"etherpad_api_host\"]'),\n\t\tetherpad_cookie_domain: form.querySelector('[name=\"etherpad_cookie_domain\"]'),\n\t\tetherpad_api_key: form.querySelector('[name=\"etherpad_api_key\"]'),\n\t\tsync_interval_seconds: form.querySelector('[name=\"sync_interval_seconds\"]'),\n\t\texternal_pad_allowlist: form.querySelector('[name=\"external_pad_allowlist\"]'),\n\t\ttrusted_embed_origins: form.querySelector('[name=\"trusted_embed_origins\"]'),\n\t}\n\n\tif (!root || !form || !statusNode || !healthButton) {\n\t\treturn\n\t}\n\n\tconst saveUrl = root.getAttribute('data-save-url') || ''\n\tconst healthUrl = root.getAttribute('data-health-url') || ''\n\tconst consistencyUrl = root.getAttribute('data-consistency-url') || ''\n\tconst settlePendingUrl = root.getAttribute('data-settle-pending-url') || ''\n\tconst releaseBrakeUrl = root.getAttribute('data-release-brake-url') || ''\n\tconst l10n = {\n\t\tsaving: root.getAttribute('data-l10n-saving') || 'Saving settings...',\n\t\tsaved: root.getAttribute('data-l10n-saved') || 'Settings saved.',\n\t\tchecking: root.getAttribute('data-l10n-checking') || 'Testing Etherpad connection...',\n\t\tconsistencyRunning: root.getAttribute('data-l10n-consistency-running') || 'Running consistency check...',\n\t\tconsistencyOk: root.getAttribute('data-l10n-consistency-ok') || 'Consistency check successful.',\n\t\trequestFailed: root.getAttribute('data-l10n-request-failed') || 'Request failed.',\n\t\tsavingFailed: root.getAttribute('data-l10n-saving-failed') || 'Failed to save settings.',\n\t\thealthFailed: root.getAttribute('data-l10n-health-failed') || 'Etherpad connection test failed.',\n\t\tconsistencyFailed: root.getAttribute('data-l10n-consistency-failed') || 'Consistency check failed.',\n\t\tpendingDeleteLabel: root.getAttribute('data-l10n-pending-delete-label') || 'Pending Etherpad deletes',\n\t\trestorePendingLabel: root.getAttribute('data-l10n-restore-pending-label') || 'Unresolved restores',\n\t\tsettleFailed: root.getAttribute('data-l10n-settle-failed') || 'Pending pad check failed.',\n\t\treleaseBrakeConfirm: root.getAttribute('data-l10n-release-brake-confirm') || 'Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?',\n\t\treleasingBrake: root.getAttribute('data-l10n-releasing-brake') || 'Releasing the brake...',\n\t\treleaseBrakeFailed: root.getAttribute('data-l10n-release-brake-failed') || 'Releasing the brake failed.',\n\t\ttemplateUploading: root.getAttribute('data-l10n-template-uploading') || 'Uploading template...',\n\t\ttemplateDelete: root.getAttribute('data-l10n-template-delete') || 'Delete',\n\t\ttemplateTooLarge: root.getAttribute('data-l10n-template-too-large') || 'Template file is too large.',\n\t\ttemplateDeleteLabel: root.getAttribute('data-l10n-template-delete-label') || 'Delete template {name}',\n\t\ttemplateConfirmDelete: root.getAttribute('data-l10n-template-confirm-delete') || 'Delete this template for everyone?',\n\t\ttemplateFailed: root.getAttribute('data-l10n-template-failed') || 'Template request failed.',\n\t\ttemplateConfirmReplace: root.getAttribute('data-l10n-template-confirm-replace') || 'Replace the existing template of that name?',\n\t}\n\tconst templatesUrl = root.getAttribute('data-templates-url') || ''\n\tconst templatesDeleteUrl = root.getAttribute('data-templates-delete-url') || ''\n\n\tif (saveUrl === '' || healthUrl === '' || consistencyUrl === '' || settlePendingUrl === '') {\n\t\treturn\n\t}\n\n\tfunction updateExternalSettingsVisibility() {\n\t\tconst enabled = !!(allowExternalCheckbox && allowExternalCheckbox.checked)\n\t\tif (allowlistRow) {\n\t\t\tallowlistRow.style.display = enabled ? '' : 'none'\n\t\t}\n\t\tif (allowlistHint) {\n\t\t\tallowlistHint.style.display = enabled ? '' : 'none'\n\t\t}\n\t\tif (allowlistTextarea instanceof HTMLTextAreaElement) {\n\t\t\tallowlistTextarea.disabled = !enabled\n\t\t\tif (!enabled) {\n\t\t\t\tallowlistTextarea.classList.remove('ep-input-error')\n\t\t\t\tconst errorNode = form.querySelector('[data-field-error=\"external_pad_allowlist\"]')\n\t\t\t\tif (errorNode instanceof HTMLElement) {\n\t\t\t\t\terrorNode.textContent = ''\n\t\t\t\t\terrorNode.classList.remove('is-visible')\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction updatePadTypesHint() {\n\t\tconst noneEnabled = !!(protectedPadsCheckbox && publicPadsCheckbox)\n\t\t\t&& !protectedPadsCheckbox.checked\n\t\t\t&& !publicPadsCheckbox.checked\n\t\tif (padTypesNoneHint instanceof HTMLElement) {\n\t\t\t// Write the text instead of toggling visibility: a live region only\n\t\t\t// announces content changes, and an element returning from\n\t\t\t// `display: none` reads as initial content. `.ep-field-hint:empty`\n\t\t\t// collapses the empty paragraph, so it looks the same.\n\t\t\tconst message = noneEnabled ? (padTypesNoneHint.dataset.message || '') : ''\n\t\t\t// Rewriting the same text would announce it again on load.\n\t\t\tif (padTypesNoneHint.textContent !== message) {\n\t\t\t\tpadTypesNoneHint.textContent = message\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction getPayload() {\n\t\tconst data = new FormData(form)\n\t\treturn {\n\t\t\tetherpad_host: String(data.get('etherpad_host') || '').trim(),\n\t\t\tetherpad_api_host: String(data.get('etherpad_api_host') || '').trim(),\n\t\t\tetherpad_cookie_domain: String(data.get('etherpad_cookie_domain') || '').trim(),\n\t\t\tetherpad_api_key: String(data.get('etherpad_api_key') || '').trim(),\n\t\t\tsync_interval_seconds: Number(data.get('sync_interval_seconds') || 120),\n\t\t\tdelete_on_trash: data.has('delete_on_trash'),\n\t\t\tenable_protected_pads: data.has('enable_protected_pads'),\n\t\t\tallow_legacy_protected_import: data.has('allow_legacy_protected_import'),\n\t\t\tenable_public_pads: data.has('enable_public_pads'),\n\t\t\tallow_external_pads: data.has('allow_external_pads'),\n\t\t\texternal_pad_allowlist: String(data.get('external_pad_allowlist') || ''),\n\t\t\ttrusted_embed_origins: String(data.get('trusted_embed_origins') || ''),\n\t\t}\n\t}\n\n\t// Writing a status touches only its own area, so a late response cannot\n\t// wipe out the other action's result.\n\tfunction setStatus(message, state, node = statusNode) {\n\t\tif (!(node instanceof HTMLElement)) {\n\t\t\treturn\n\t\t}\n\t\tnode.textContent = message\n\t\tnode.classList.remove('ep-status-success', 'ep-status-warning', 'ep-status-error')\n\t\tif (state === 'success' || state === 'warning' || state === 'error') {\n\t\t\tnode.classList.add(`ep-status-${state}`)\n\t\t}\n\t}\n\n\t// Starting one does clear the other: its result predates this action and\n\t// would be read as belonging to it. The template status is its own group —\n\t// uploading a template makes neither a connection test nor a save result\n\t// stale, and vice versa.\n\tfunction beginStatus(message, node = statusNode) {\n\t\tconst group = node === templateStatusNode\n\t\t\t? [templateStatusNode]\n\t\t\t: [statusNode, diagnosticsTarget, connectionTarget]\n\t\tfor (const other of group) {\n\t\t\tif (other instanceof HTMLElement && other !== node) {\n\t\t\t\tother.textContent = ''\n\t\t\t\tother.classList.remove('ep-status-success', 'ep-status-warning', 'ep-status-error')\n\t\t\t}\n\t\t}\n\t\tsetStatus(message, null, node)\n\t}\n\n\t// Higher wins when two checks land on the same field, so a problem is\n\t// never hidden by a passing verdict that happened to come later.\n\tconst CHECK_SEVERITY = { skipped: 0, ok: 1, warning: 2 }\n\n\t// Each result is shown at the field it came from: a green tick where\n\t// things are fine, the message itself where they are not.\n\tfunction renderConnectionChecks(checks) {\n\t\tfor (const slot of document.querySelectorAll('[data-check-result]')) {\n\t\t\tslot.textContent = ''\n\t\t\tslot.className = 'ep-check-result'\n\t\t\tdelete slot.dataset.checkStatus\n\t\t}\n\t\tif (!Array.isArray(checks)) {\n\t\t\treturn\n\t\t}\n\t\tfor (const check of checks) {\n\t\t\tif (!check || typeof check.label !== 'string') {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst status = String(check.status || 'skipped')\n\t\t\tconst detail = typeof check.detail === 'string' ? check.detail : ''\n\t\t\tconst slot = check.field\n\t\t\t\t? document.querySelector(`[data-check-result=\"${CSS.escape(String(check.field))}\"]`)\n\t\t\t\t: null\n\t\t\tif (!(slot instanceof HTMLElement)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst shown = slot.dataset.checkStatus\n\t\t\tif (shown && (CHECK_SEVERITY[shown] ?? 0) > (CHECK_SEVERITY[status] ?? 0)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tslot.dataset.checkStatus = status\n\t\t\tslot.className = `ep-check-result ep-check-${status}`\n\t\t\t// A passing field needs no prose — the tick and the label say it.\n\t\t\t// A failing one needs the reason right there.\n\t\t\tslot.textContent = status === 'ok' ? check.label : (detail || check.label)\n\t\t}\n\t}\n\n\t// Shared templates: an admin uploads .pad files that everyone then sees as\n\t// tiles in Nextcloud's picker.\n\tfunction renderTemplates(templates) {\n\t\tif (!(templateListNode instanceof HTMLElement)) {\n\t\t\treturn\n\t\t}\n\t\ttemplateListNode.replaceChildren()\n\t\tconst list = Array.isArray(templates) ? templates : []\n\t\tshowTemplateEmptyState(list.length === 0)\n\t\tfor (const template of list) {\n\t\t\tif (!template || typeof template.name !== 'string') {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttemplateListNode.appendChild(buildTemplateRow(template.name))\n\t\t}\n\t}\n\n\tfunction buildTemplateRow(name) {\n\t\tconst row = document.createElement('li')\n\t\trow.className = 'ep-template-row'\n\n\t\tconst label = document.createElement('span')\n\t\tlabel.className = 'ep-template-name'\n\t\tlabel.textContent = name\n\t\trow.appendChild(label)\n\n\t\tconst remove = document.createElement('button')\n\t\tremove.type = 'button'\n\t\tremove.textContent = l10n.templateDelete\n\t\t// The visible label repeats on every row, so the accessible name has to\n\t\t// carry the name of the template this one removes.\n\t\tremove.setAttribute('aria-label', l10n.templateDeleteLabel.replace('{name}', name))\n\t\tremove.addEventListener('click', () => {\n\t\t\tif (!window.confirm(l10n.templateConfirmDelete)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tvoid deleteTemplate(name)\n\t\t})\n\t\trow.appendChild(remove)\n\t\treturn row\n\t}\n\n\tfunction showTemplateEmptyState(visible) {\n\t\tif (templateEmptyNode instanceof HTMLElement) {\n\t\t\ttemplateEmptyNode.style.display = visible ? '' : 'none'\n\t\t}\n\t}\n\n\t// Every upload and delete starts another listing while the first may still\n\t// be in flight. Without this the slower answer wins whenever it happens to\n\t// arrive last, putting a deleted template back on the page or replacing a\n\t// fresh result with an old error.\n\tlet templateListGeneration = 0\n\n\tasync function loadTemplates() {\n\t\tif (templatesUrl === '' || !(templateListNode instanceof HTMLElement)) {\n\t\t\treturn\n\t\t}\n\t\tconst generation = ++templateListGeneration\n\t\tconst isCurrent = () => generation === templateListGeneration\n\n\t\t// \"No shared templates yet\" is an answer, and we do not have one until\n\t\t// the request comes back — showing it next to an error would claim the\n\t\t// list is empty when we simply could not read it.\n\t\tshowTemplateEmptyState(false)\n\t\ttry {\n\t\t\tconst response = await fetch(templatesUrl, {\n\t\t\t\tcredentials: 'same-origin',\n\t\t\t\theaders: { requesttoken: String(OC.requestToken || '') },\n\t\t\t})\n\t\t\tconst data = await readJsonResponse(response)\n\t\t\tif (!isCurrent()) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\trenderTemplates(data.templates)\n\t\t} catch (error) {\n\t\t\tif (!isCurrent()) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tshowTemplateEmptyState(false)\n\t\t\tsetStatus(error instanceof Error ? error.message : l10n.templateFailed, 'error', templateStatusNode)\n\t\t}\n\t}\n\n\t// The same limit the server enforces. Without it a mis-picked large file is\n\t// read into memory and posted, only to hit post_max_size or the memory\n\t// limit — a failure that says nothing, instead of the sentence below.\n\tconst MAX_TEMPLATE_BYTES = 2 * 1024 * 1024\n\n\tasync function uploadTemplate(file, replace = false) {\n\t\tif (typeof file.size === 'number' && file.size > MAX_TEMPLATE_BYTES) {\n\t\t\tsetStatus(l10n.templateTooLarge, 'error', templateStatusNode)\n\t\t\treturn\n\t\t}\n\t\tbeginStatus(l10n.templateUploading, templateStatusNode)\n\t\ttry {\n\t\t\tconst content = await file.text()\n\t\t\tconst data = await postJsonBody(templatesUrl, { name: file.name, content, replace })\n\t\t\tsetStatus(String(data.message || ''), 'success', templateStatusNode)\n\t\t\tawait loadTemplates()\n\t\t} catch (error) {\n\t\t\t// The server refuses to overwrite unless asked, so a name that is\n\t\t\t// already taken comes back as a question rather than a failure.\n\t\t\tif (!replace && error && error.field === 'template_exists') {\n\t\t\t\tif (window.confirm(l10n.templateConfirmReplace)) {\n\t\t\t\t\tawait uploadTemplate(file, true)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tsetStatus('', null, templateStatusNode)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetStatus(error instanceof Error ? error.message : l10n.templateFailed, 'error', templateStatusNode)\n\t\t}\n\t}\n\n\tasync function deleteTemplate(name) {\n\t\ttry {\n\t\t\tconst data = await postJson(templatesDeleteUrl, { name })\n\t\t\tsetStatus(String(data.message || ''), 'success', templateStatusNode)\n\t\t\tawait loadTemplates()\n\t\t} catch (error) {\n\t\t\tsetStatus(error instanceof Error ? error.message : l10n.templateFailed, 'error', templateStatusNode)\n\t\t}\n\t}\n\n\tfunction clearFieldErrors() {\n\t\tObject.keys(fieldNodes).forEach((field) => {\n\t\t\tconst input = fieldNodes[field]\n\t\t\tif (input instanceof HTMLElement) {\n\t\t\t\tinput.classList.remove('ep-input-error')\n\t\t\t}\n\t\t\tconst errorNode = form.querySelector(`[data-field-error=\"${field}\"]`)\n\t\t\tif (errorNode instanceof HTMLElement) {\n\t\t\t\terrorNode.textContent = ''\n\t\t\t\terrorNode.classList.remove('is-visible')\n\t\t\t}\n\t\t})\n\t}\n\n\tfunction showFieldError(field, message) {\n\t\tif (!field || !message) {\n\t\t\treturn\n\t\t}\n\t\tconst input = fieldNodes[field]\n\t\tif (input instanceof HTMLElement) {\n\t\t\tinput.classList.add('ep-input-error')\n\t\t}\n\t\tconst errorNode = form.querySelector(`[data-field-error=\"${field}\"]`)\n\t\tif (errorNode instanceof HTMLElement) {\n\t\t\terrorNode.textContent = message\n\t\t\terrorNode.classList.add('is-visible')\n\t\t}\n\t\tif (input instanceof HTMLElement && typeof input.focus === 'function') {\n\t\t\tinput.focus()\n\t\t}\n\t}\n\n\tconst bindingCounts = { pendingDeletes: 0, pendingRestores: 0 }\n\n\t// Only the counts a response carries; one that carries none leaves the\n\t// panel as it was rather than repainting it from what this page assumed.\n\t// The health check and the pending pad check name them the same.\n\tfunction updateBindingCounts(data) {\n\t\tconst counts = {}\n\t\tif (typeof data.pending_delete_count !== 'undefined') {\n\t\t\tcounts.pendingDeletes = data.pending_delete_count\n\t\t}\n\t\tif (typeof data.restore_pending_count !== 'undefined') {\n\t\t\tcounts.pendingRestores = data.restore_pending_count\n\t\t}\n\t\tif (Object.keys(counts).length === 0) {\n\t\t\treturn\n\t\t}\n\t\tfor (const [key, value] of Object.entries(counts)) {\n\t\t\tbindingCounts[key] = Number.isFinite(Number(value)) ? Number(value) : 0\n\t\t}\n\t\tif (pendingActions instanceof HTMLElement) {\n\t\t\tpendingActions.style.display = bindingCounts.pendingDeletes > 0 || bindingCounts.pendingRestores > 0 ? '' : 'none'\n\t\t}\n\t\tif (pendingCountNode instanceof HTMLElement) {\n\t\t\tpendingCountNode.textContent = `${l10n.pendingDeleteLabel}: ${String(bindingCounts.pendingDeletes)}`\n\t\t}\n\t\tif (restorePendingCountNode instanceof HTMLElement) {\n\t\t\trestorePendingCountNode.textContent = `${l10n.restorePendingLabel}: ${String(bindingCounts.pendingRestores)}`\n\t\t}\n\t\t// The check deletes a pad only once its file is gone for good, and\n\t\t// nothing here is more than a nudge to the job that does the same.\n\t\tif (settlePendingButton instanceof HTMLButtonElement) {\n\t\t\tsettlePendingButton.disabled = bindingCounts.pendingDeletes <= 0 && bindingCounts.pendingRestores <= 0\n\t\t}\n\t}\n\n\t// One place decides what a response means, so a caller cannot forget to\n\t// look at the status or at `ok` in the body.\n\tasync function readJsonResponse(response) {\n\t\tlet data = null\n\t\tconst responseText = await response.text()\n\t\ttry {\n\t\t\tdata = responseText !== '' ? JSON.parse(responseText) : null\n\t\t} catch (error) {\n\t\t\tdata = null\n\t\t}\n\n\t\tif (!response.ok || !data || data.ok !== true) {\n\t\t\t// Only our own JSON is quoted: another body came from something\n\t\t\t// between the browser and the app, and showing its markup reads\n\t\t\t// as an app failure. The status says more than the markup does.\n\t\t\tconst message = (data && data.message)\n\t\t\t\t? String(data.message)\n\t\t\t\t: `${l10n.requestFailed} (HTTP ${response.status})`\n\t\t\tconst err = new Error(message)\n\t\t\terr.field = (data && typeof data.field === 'string') ? data.field : ''\n\t\t\tthrow err\n\t\t}\n\n\t\treturn data\n\t}\n\n\t/**\n\t * POST a JSON body. Used for the template upload, where URL-encoding a\n\t * whole file would inflate it several times over.\n\t */\n\tasync function postJsonBody(url, payload) {\n\t\tconst response = await fetch(url, {\n\t\t\tmethod: 'POST',\n\t\t\tcredentials: 'same-origin',\n\t\t\theaders: {\n\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\trequesttoken: String(OC.requestToken || ''),\n\t\t\t},\n\t\t\tbody: JSON.stringify(payload),\n\t\t})\n\t\treturn readJsonResponse(response)\n\t}\n\n\tasync function postJson(url, payload) {\n\t\tconst body = new URLSearchParams()\n\t\tObject.keys(payload).forEach((key) => {\n\t\t\tbody.set(key, String(payload[key]))\n\t\t})\n\n\t\tconst response = await fetch(url, {\n\t\t\tmethod: 'POST',\n\t\t\tcredentials: 'same-origin',\n\t\t\theaders: {\n\t\t\t\t'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',\n\t\t\t\trequesttoken: String(OC.requestToken || ''),\n\t\t\t},\n\t\t\tbody: body.toString(),\n\t\t})\n\n\t\treturn readJsonResponse(response)\n\t}\n\n\tform.addEventListener('submit', async (event) => {\n\t\tevent.preventDefault()\n\t\tclearFieldErrors()\n\t\t// Verdicts describe the values of the previous run: keeping them up\n\t\t// while this one is in flight puts a green tick next to a field that\n\t\t// is being changed, and a failed save would leave them there.\n\t\trenderConnectionChecks([])\n\t\tbeginStatus(l10n.saving)\n\t\ttry {\n\t\t\tconst data = await postJson(saveUrl, getPayload())\n\t\t\t// Saving answers with the cookie verdict alone; rendering it clears\n\t\t\t// the other fields' results, which the save just invalidated.\n\t\t\trenderConnectionChecks(data.checks)\n\t\t\tconst versionSuffix = data && data.api_version ? ` api=${String(data.api_version)}` : ''\n\t\t\tsetStatus(`${String(data.message || l10n.saved)}${versionSuffix}`, 'success')\n\t\t} catch (error) {\n\t\t\tif (error && typeof error.field === 'string' && error.field !== '') {\n\t\t\t\tshowFieldError(error.field, error.message || l10n.savingFailed)\n\t\t\t}\n\t\t\tsetStatus(error instanceof Error ? error.message : l10n.savingFailed, 'error')\n\t\t}\n\t})\n\n\thealthButton.addEventListener('click', async () => {\n\t\tclearFieldErrors()\n\t\trenderConnectionChecks([])\n\t\tbeginStatus(l10n.checking, connectionTarget)\n\t\ttry {\n\t\t\tconst data = await postJson(healthUrl, getPayload())\n\t\t\tupdateBindingCounts(data)\n\t\t\t// The per-field results carry the target and latency and the\n\t\t\t// protected-pads verdict, so the summary stays a summary.\n\t\t\trenderConnectionChecks(data.checks)\n\t\t\tconst needsAttention = Array.isArray(data.checks)\n\t\t\t\t&& data.checks.some((check) => check && check.status === 'warning')\n\t\t\tsetStatus(String(data.message), needsAttention ? 'warning' : 'success', connectionTarget)\n\t\t} catch (error) {\n\t\t\tif (error && typeof error.field === 'string' && error.field !== '') {\n\t\t\t\tshowFieldError(error.field, error.message || l10n.healthFailed)\n\t\t\t}\n\t\t\tsetStatus(error instanceof Error ? error.message : l10n.healthFailed, 'error', connectionTarget)\n\t\t}\n\t})\n\n\tif (consistencyButton instanceof HTMLElement) {\n\t\tconsistencyButton.addEventListener('click', async () => {\n\t\t\tclearFieldErrors()\n\t\t\tbeginStatus(l10n.consistencyRunning, diagnosticsTarget)\n\t\t\ttry {\n\t\t\t\tconst data = await postJson(consistencyUrl, {})\n\t\t\t\tconst bindingWithoutFile = Number(data.binding_without_file_count || 0)\n\t\t\t\tconst missingFile = Number(data.missing_file_count || 0)\n\t\t\t\tconst brakeEngaged = data.gone_file_brake_engaged === true\n\t\t\t\tconst message = `${String(data.message || l10n.consistencyOk)} binding_without_file=${String(bindingWithoutFile)} | missing_file=${String(missingFile)}`\n\t\t\t\tsetStatus(message, brakeEngaged || bindingWithoutFile > 0 ? 'error' : 'success', diagnosticsTarget)\n\t\t\t\tshowBrakeActions(brakeEngaged)\n\t\t\t} catch (error) {\n\t\t\t\tsetStatus(error instanceof Error ? error.message : l10n.consistencyFailed, 'error', diagnosticsTarget)\n\t\t\t}\n\t\t})\n\t}\n\n\t/** The brake of the sweep of files gone for good: released only here, and offered only while it holds. */\n\tfunction showBrakeActions(engaged) {\n\t\tif (brakeActions instanceof HTMLElement) {\n\t\t\tbrakeActions.style.display = engaged && releaseBrakeUrl !== '' ? '' : 'none'\n\t\t}\n\t}\n\n\tif (releaseBrakeButton instanceof HTMLElement) {\n\t\treleaseBrakeButton.addEventListener('click', async () => {\n\t\t\tif (!window.confirm(l10n.releaseBrakeConfirm)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tclearFieldErrors()\n\t\t\tbeginStatus(l10n.releasingBrake, diagnosticsTarget)\n\t\t\ttry {\n\t\t\t\tconst data = await postJson(releaseBrakeUrl, {})\n\t\t\t\tshowBrakeActions(false)\n\t\t\t\tsetStatus(String(data.message || 'OK'), 'success', diagnosticsTarget)\n\t\t\t} catch (error) {\n\t\t\t\tsetStatus(error instanceof Error ? error.message : l10n.releaseBrakeFailed, 'error', diagnosticsTarget)\n\t\t\t}\n\t\t})\n\t}\n\n\tif (settlePendingButton instanceof HTMLElement) {\n\t\tsettlePendingButton.addEventListener('click', async () => {\n\t\t\tclearFieldErrors()\n\t\t\tbeginStatus(l10n.checking, diagnosticsTarget)\n\t\t\ttry {\n\t\t\t\tconst data = await postJson(settlePendingUrl, {})\n\t\t\t\tconst details = []\n\t\t\t\tfor (const key of ['checked', 'settled', 'restore_pending_count', 'pending_delete_count']) {\n\t\t\t\t\tif (typeof data[key] !== 'undefined') {\n\t\t\t\t\t\tdetails.push(`${key}=${String(data[key])}`)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tupdateBindingCounts(data)\n\t\t\t\tconst suffix = details.length > 0 ? ` ${details.join(' | ')}` : ''\n\t\t\t\tsetStatus(`${String(data.message || 'OK')}${suffix}`, 'success', diagnosticsTarget)\n\t\t\t} catch (error) {\n\t\t\t\tsetStatus(error instanceof Error ? error.message : l10n.settleFailed, 'error', diagnosticsTarget)\n\t\t\t}\n\t\t})\n\t}\n\n\tif (templateUploadButton instanceof HTMLElement && templateFileInput instanceof HTMLInputElement) {\n\t\ttemplateUploadButton.addEventListener('click', () => {\n\t\t\ttemplateFileInput.click()\n\t\t})\n\t\ttemplateFileInput.addEventListener('change', () => {\n\t\t\tconst file = templateFileInput.files && templateFileInput.files[0]\n\t\t\tif (file) {\n\t\t\t\tvoid uploadTemplate(file)\n\t\t\t}\n\t\t\t// Reset so picking the same file twice fires again.\n\t\t\ttemplateFileInput.value = ''\n\t\t})\n\t\tvoid loadTemplates()\n\t}\n\n\tif (allowExternalCheckbox) {\n\t\tallowExternalCheckbox.addEventListener('change', updateExternalSettingsVisibility)\n\t}\n\tupdateExternalSettingsVisibility()\n\n\tfor (const checkbox of [protectedPadsCheckbox, publicPadsCheckbox]) {\n\t\tif (checkbox) {\n\t\t\tcheckbox.addEventListener('change', updatePadTypesHint)\n\t\t}\n\t}\n\tupdatePadTypesHint()\n})()\n"],"names":["root","form","statusNode","diagnosticsTarget","connectionTarget","healthButton","consistencyButton","settlePendingButton","pendingActions","pendingCountNode","restorePendingCountNode","brakeActions","releaseBrakeButton","allowExternalCheckbox","protectedPadsCheckbox","publicPadsCheckbox","padTypesNoneHint","allowlistRow","allowlistHint","allowlistTextarea","templateListNode","templateEmptyNode","templateFileInput","templateUploadButton","templateStatusNode","fieldNodes","saveUrl","healthUrl","consistencyUrl","settlePendingUrl","releaseBrakeUrl","l10n","templatesUrl","templatesDeleteUrl","updateExternalSettingsVisibility","enabled","errorNode","updatePadTypesHint","noneEnabled","message","getPayload","data","setStatus","state","node","beginStatus","group","other","CHECK_SEVERITY","renderConnectionChecks","checks","slot","check","status","detail","shown","renderTemplates","templates","list","showTemplateEmptyState","template","buildTemplateRow","name","row","label","remove","deleteTemplate","visible","templateListGeneration","loadTemplates","generation","isCurrent","response","readJsonResponse","error","MAX_TEMPLATE_BYTES","uploadTemplate","file","replace","content","postJsonBody","postJson","clearFieldErrors","field","input","showFieldError","bindingCounts","updateBindingCounts","counts","key","value","responseText","err","url","payload","body","event","versionSuffix","needsAttention","bindingWithoutFile","missingFile","brakeEngaged","showBrakeActions","engaged","details","suffix","checkbox"],"mappings":"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"} \ No newline at end of file diff --git a/l10n/de.js b/l10n/de.js index a196c82c..2890efa1 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -20,6 +20,7 @@ OC.L10N.register( "Authentication required.": "Authentifizierung erforderlich.", "Back": "Zurück", "Back to shared files": "Zurück zu den freigegebenen Dateien", + "Brake released. Pads whose .pad file has been missing past the grace period are deleted over the next runs.": "Bremse gelöst. Pads, deren .pad-Datei länger als die Schonfrist fehlt, werden in den nächsten Durchläufen gelöscht.", "Cannot open selected .pad file.": "Ausgewählte .pad-Datei konnte nicht geöffnet werden.", "Cannot resolve file path for file ID.": "Dateipfad zur Datei-ID konnte nicht ermittelt werden.", "Cannot resolve selected parent folder.": "Ausgewählter Zielordner konnte nicht aufgelöst werden.", @@ -115,6 +116,7 @@ OC.L10N.register( "Loading pad content...": "Pad-Inhalt wird geladen …", "Loading pad...": "Pad wird geladen ...", "Looks for pad links whose .pad file no longer exists.": "Sucht nach Pad-Verknüpfungen, deren .pad-Datei nicht mehr existiert.", + "Many .pad files went missing at once without passing a trash. Their pads are kept until the brake is released.": "Viele .pad-Dateien sind auf einmal verschwunden, ohne durch einen Papierkorb zu gehen. Ihre Pads bleiben erhalten, bis die Bremse gelöst wird.", "New pad": "Neues Pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud und Etherpad haben keine gemeinsame übergeordnete Domain. Der Browser kann das Sitzungscookie deshalb nicht an Etherpad senden, und geschützte Pads lassen sich nicht öffnen. Wenn ein Proxy Etherpad unter der Nextcloud-Domain bereitstellt, trage diese Adresse als Basis-URL ein. Andernfalls betreibe beide Dienste unter einer gemeinsamen übergeordneten Domain oder schalte geschützte Pads ab.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud hat nicht geantwortet. Prüfe deine Verbindung und versuche es erneut.", @@ -134,6 +136,7 @@ OC.L10N.register( "Pad name is required.": "Pad-Name ist erforderlich.", "Pad types and behaviour": "Pad-Typen und Verhalten", "Pads": "Pads", + "Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?": "Pads, deren .pad-Datei länger als die Schonfrist fehlt, werden gelöscht. Bremse lösen?", "Pending Etherpad deletes": "Ausstehende Etherpad-Löschungen", "Pending pad check failed.": "Prüfung ausstehender Pads fehlgeschlagen.", "Pending pad check finished.": "Prüfung ausstehender Pads abgeschlossen.", @@ -143,6 +146,9 @@ OC.L10N.register( "Public pads": "Öffentliche Pads", "Refresh": "Aktualisieren", "Refreshing...": "Wird aktualisiert …", + "Release the brake": "Bremse lösen", + "Releasing the brake failed.": "Die Bremse konnte nicht gelöst werden.", + "Releasing the brake...": "Bremse wird gelöst ...", "Replace the existing template of that name?": "Die vorhandene Vorlage dieses Namens ersetzen?", "Request failed.": "Anfrage fehlgeschlagen.", "Required for protected pads unless Nextcloud and Etherpad run on the same host: the session cookie must be valid for both. Auto-filled from the two hosts when they share a parent domain. Leave empty for a host-only cookie.": "Für geschützte Pads erforderlich, sofern Nextcloud und Etherpad nicht auf demselben Host laufen: Das Sitzungscookie muss für beide gültig sein. Wird automatisch aus den beiden Hosts ausgefüllt, wenn sie eine gemeinsame übergeordnete Domain haben. Leer lassen für ein Cookie nur für diesen Host.", diff --git a/l10n/de.json b/l10n/de.json index 69b467bd..af64659b 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -19,6 +19,7 @@ "Authentication required.": "Authentifizierung erforderlich.", "Back": "Zurück", "Back to shared files": "Zurück zu den freigegebenen Dateien", + "Brake released. Pads whose .pad file has been missing past the grace period are deleted over the next runs.": "Bremse gelöst. Pads, deren .pad-Datei länger als die Schonfrist fehlt, werden in den nächsten Durchläufen gelöscht.", "Cannot open selected .pad file.": "Ausgewählte .pad-Datei konnte nicht geöffnet werden.", "Cannot resolve file path for file ID.": "Dateipfad zur Datei-ID konnte nicht ermittelt werden.", "Cannot resolve selected parent folder.": "Ausgewählter Zielordner konnte nicht aufgelöst werden.", @@ -114,6 +115,7 @@ "Loading pad content...": "Pad-Inhalt wird geladen …", "Loading pad...": "Pad wird geladen ...", "Looks for pad links whose .pad file no longer exists.": "Sucht nach Pad-Verknüpfungen, deren .pad-Datei nicht mehr existiert.", + "Many .pad files went missing at once without passing a trash. Their pads are kept until the brake is released.": "Viele .pad-Dateien sind auf einmal verschwunden, ohne durch einen Papierkorb zu gehen. Ihre Pads bleiben erhalten, bis die Bremse gelöst wird.", "New pad": "Neues Pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud und Etherpad haben keine gemeinsame übergeordnete Domain. Der Browser kann das Sitzungscookie deshalb nicht an Etherpad senden, und geschützte Pads lassen sich nicht öffnen. Wenn ein Proxy Etherpad unter der Nextcloud-Domain bereitstellt, trage diese Adresse als Basis-URL ein. Andernfalls betreibe beide Dienste unter einer gemeinsamen übergeordneten Domain oder schalte geschützte Pads ab.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud hat nicht geantwortet. Prüfe deine Verbindung und versuche es erneut.", @@ -133,6 +135,7 @@ "Pad name is required.": "Pad-Name ist erforderlich.", "Pad types and behaviour": "Pad-Typen und Verhalten", "Pads": "Pads", + "Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?": "Pads, deren .pad-Datei länger als die Schonfrist fehlt, werden gelöscht. Bremse lösen?", "Pending Etherpad deletes": "Ausstehende Etherpad-Löschungen", "Pending pad check failed.": "Prüfung ausstehender Pads fehlgeschlagen.", "Pending pad check finished.": "Prüfung ausstehender Pads abgeschlossen.", @@ -142,6 +145,9 @@ "Public pads": "Öffentliche Pads", "Refresh": "Aktualisieren", "Refreshing...": "Wird aktualisiert …", + "Release the brake": "Bremse lösen", + "Releasing the brake failed.": "Die Bremse konnte nicht gelöst werden.", + "Releasing the brake...": "Bremse wird gelöst ...", "Replace the existing template of that name?": "Die vorhandene Vorlage dieses Namens ersetzen?", "Request failed.": "Anfrage fehlgeschlagen.", "Required for protected pads unless Nextcloud and Etherpad run on the same host: the session cookie must be valid for both. Auto-filled from the two hosts when they share a parent domain. Leave empty for a host-only cookie.": "Für geschützte Pads erforderlich, sofern Nextcloud und Etherpad nicht auf demselben Host laufen: Das Sitzungscookie muss für beide gültig sein. Wird automatisch aus den beiden Hosts ausgefüllt, wenn sie eine gemeinsame übergeordnete Domain haben. Leer lassen für ein Cookie nur für diesen Host.", diff --git a/l10n/es.js b/l10n/es.js index 83d52475..92babc8d 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -20,6 +20,7 @@ OC.L10N.register( "Authentication required.": "Autenticación requerida.", "Back": "Atrás", "Back to shared files": "Volver a los archivos compartidos", + "Brake released. Pads whose .pad file has been missing past the grace period are deleted over the next runs.": "Freno liberado. Los pads cuyo archivo .pad falta desde hace más que el período de gracia se eliminan en las próximas ejecuciones.", "Cannot open selected .pad file.": "No se puede abrir el archivo .pad seleccionado.", "Cannot resolve file path for file ID.": "No se puede resolver la ruta del archivo para el ID del archivo.", "Cannot resolve selected parent folder.": "No se puede resolver la carpeta principal seleccionada.", @@ -115,6 +116,7 @@ OC.L10N.register( "Loading pad content...": "Cargando el contenido del pad…", "Loading pad...": "Cargando pad...", "Looks for pad links whose .pad file no longer exists.": "Busca enlaces de pad cuyo archivo .pad ya no existe.", + "Many .pad files went missing at once without passing a trash. Their pads are kept until the brake is released.": "Muchos archivos .pad desaparecieron a la vez sin pasar por una papelera. Sus pads se conservan hasta que se libere el freno.", "New pad": "Nuevo pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud y Etherpad no comparten ningún dominio padre común, así que el navegador no puede enviar la cookie de sesión a Etherpad y los pads protegidos no se abrirán. Si un proxy expone Etherpad bajo el dominio de Nextcloud, usa esa dirección como URL base. En caso contrario, sitúa ambos servicios bajo un dominio padre común o desactiva los pads protegidos.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud no ha respondido. Comprueba tu conexión y vuelve a intentarlo.", @@ -134,6 +136,7 @@ OC.L10N.register( "Pad name is required.": "Se requiere el nombre del pad.", "Pad types and behaviour": "Tipos de pad y comportamiento", "Pads": "Pads", + "Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?": "Se eliminarán los pads cuyo archivo .pad falta desde hace más que el período de gracia. ¿Liberar el freno?", "Pending Etherpad deletes": "Eliminaciones pendientes en Etherpad", "Pending pad check failed.": "Error al comprobar los pads pendientes.", "Pending pad check finished.": "Comprobación de pads pendientes finalizada.", @@ -143,6 +146,9 @@ OC.L10N.register( "Public pads": "Pads públicos", "Refresh": "Actualizar", "Refreshing...": "Actualizando…", + "Release the brake": "Liberar el freno", + "Releasing the brake failed.": "No se pudo liberar el freno.", + "Releasing the brake...": "Liberando el freno...", "Replace the existing template of that name?": "¿Reemplazar la plantilla existente con ese nombre?", "Request failed.": "La solicitud falló.", "Required for protected pads unless Nextcloud and Etherpad run on the same host: the session cookie must be valid for both. Auto-filled from the two hosts when they share a parent domain. Leave empty for a host-only cookie.": "Necesario para los pads protegidos, salvo que Nextcloud y Etherpad se ejecuten en el mismo host: la cookie de sesión debe ser válida para ambos. Se rellena automáticamente a partir de los dos hosts cuando comparten un dominio padre común. Déjalo vacío para una cookie limitada al host.", diff --git a/l10n/es.json b/l10n/es.json index 03e6b356..a2616e86 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -19,6 +19,7 @@ "Authentication required.": "Autenticación requerida.", "Back": "Atrás", "Back to shared files": "Volver a los archivos compartidos", + "Brake released. Pads whose .pad file has been missing past the grace period are deleted over the next runs.": "Freno liberado. Los pads cuyo archivo .pad falta desde hace más que el período de gracia se eliminan en las próximas ejecuciones.", "Cannot open selected .pad file.": "No se puede abrir el archivo .pad seleccionado.", "Cannot resolve file path for file ID.": "No se puede resolver la ruta del archivo para el ID del archivo.", "Cannot resolve selected parent folder.": "No se puede resolver la carpeta principal seleccionada.", @@ -114,6 +115,7 @@ "Loading pad content...": "Cargando el contenido del pad…", "Loading pad...": "Cargando pad...", "Looks for pad links whose .pad file no longer exists.": "Busca enlaces de pad cuyo archivo .pad ya no existe.", + "Many .pad files went missing at once without passing a trash. Their pads are kept until the brake is released.": "Muchos archivos .pad desaparecieron a la vez sin pasar por una papelera. Sus pads se conservan hasta que se libere el freno.", "New pad": "Nuevo pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud y Etherpad no comparten ningún dominio padre común, así que el navegador no puede enviar la cookie de sesión a Etherpad y los pads protegidos no se abrirán. Si un proxy expone Etherpad bajo el dominio de Nextcloud, usa esa dirección como URL base. En caso contrario, sitúa ambos servicios bajo un dominio padre común o desactiva los pads protegidos.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud no ha respondido. Comprueba tu conexión y vuelve a intentarlo.", @@ -133,6 +135,7 @@ "Pad name is required.": "Se requiere el nombre del pad.", "Pad types and behaviour": "Tipos de pad y comportamiento", "Pads": "Pads", + "Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?": "Se eliminarán los pads cuyo archivo .pad falta desde hace más que el período de gracia. ¿Liberar el freno?", "Pending Etherpad deletes": "Eliminaciones pendientes en Etherpad", "Pending pad check failed.": "Error al comprobar los pads pendientes.", "Pending pad check finished.": "Comprobación de pads pendientes finalizada.", @@ -142,6 +145,9 @@ "Public pads": "Pads públicos", "Refresh": "Actualizar", "Refreshing...": "Actualizando…", + "Release the brake": "Liberar el freno", + "Releasing the brake failed.": "No se pudo liberar el freno.", + "Releasing the brake...": "Liberando el freno...", "Replace the existing template of that name?": "¿Reemplazar la plantilla existente con ese nombre?", "Request failed.": "La solicitud falló.", "Required for protected pads unless Nextcloud and Etherpad run on the same host: the session cookie must be valid for both. Auto-filled from the two hosts when they share a parent domain. Leave empty for a host-only cookie.": "Necesario para los pads protegidos, salvo que Nextcloud y Etherpad se ejecuten en el mismo host: la cookie de sesión debe ser válida para ambos. Se rellena automáticamente a partir de los dos hosts cuando comparten un dominio padre común. Déjalo vacío para una cookie limitada al host.", diff --git a/l10n/fr.js b/l10n/fr.js index 8a3b2115..4692f660 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -20,6 +20,7 @@ OC.L10N.register( "Authentication required.": "Authentification requise.", "Back": "Retour", "Back to shared files": "Retour aux fichiers partagés", + "Brake released. Pads whose .pad file has been missing past the grace period are deleted over the next runs.": "Frein relâché. Les pads dont le fichier .pad manque depuis plus longtemps que le délai de grâce sont supprimés lors des prochains passages.", "Cannot open selected .pad file.": "Impossible d'ouvrir le fichier .pad sélectionné.", "Cannot resolve file path for file ID.": "Impossible de résoudre le chemin du fichier pour l'identifiant.", "Cannot resolve selected parent folder.": "Impossible de résoudre le dossier parent sélectionné.", @@ -115,6 +116,7 @@ OC.L10N.register( "Loading pad content...": "Chargement du contenu du pad…", "Loading pad...": "Chargement du pad...", "Looks for pad links whose .pad file no longer exists.": "Recherche les liens de pad dont le fichier .pad n'existe plus.", + "Many .pad files went missing at once without passing a trash. Their pads are kept until the brake is released.": "De nombreux fichiers .pad ont disparu d'un coup sans passer par une corbeille. Leurs pads sont conservés jusqu'à ce que le frein soit relâché.", "New pad": "Nouveau pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud et Etherpad ne partagent aucun domaine parent : le navigateur ne peut donc pas envoyer le cookie de session à Etherpad, et les pads protégés ne s'ouvriront pas. Si un proxy expose Etherpad sous le domaine Nextcloud, utilisez cette adresse comme URL de base. Sinon, placez les deux services sous un domaine parent commun ou désactivez les pads protégés.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud n'a pas répondu. Vérifiez votre connexion et réessayez.", @@ -134,6 +136,7 @@ OC.L10N.register( "Pad name is required.": "Le nom du pad est obligatoire.", "Pad types and behaviour": "Types de pads et comportement", "Pads": "Pads", + "Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?": "Les pads dont le fichier .pad manque depuis plus longtemps que le délai de grâce seront supprimés. Relâcher le frein ?", "Pending Etherpad deletes": "Suppressions Etherpad en attente", "Pending pad check failed.": "La vérification des pads en attente a échoué.", "Pending pad check finished.": "Vérification des pads en attente terminée.", @@ -143,6 +146,9 @@ OC.L10N.register( "Public pads": "Pads publics", "Refresh": "Actualiser", "Refreshing...": "Actualisation…", + "Release the brake": "Relâcher le frein", + "Releasing the brake failed.": "Impossible de relâcher le frein.", + "Releasing the brake...": "Relâchement du frein...", "Replace the existing template of that name?": "Remplacer le modèle existant portant ce nom ?", "Request failed.": "La requête a échoué.", "Required for protected pads unless Nextcloud and Etherpad run on the same host: the session cookie must be valid for both. Auto-filled from the two hosts when they share a parent domain. Leave empty for a host-only cookie.": "Nécessaire pour les pads protégés, sauf si Nextcloud et Etherpad tournent sur le même hôte : le cookie de session doit être valide pour les deux. Rempli automatiquement à partir des deux hôtes lorsqu'ils partagent un domaine parent. Laisser vide pour un cookie limité à l'hôte.", diff --git a/l10n/fr.json b/l10n/fr.json index b098f4a0..c0b89523 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -19,6 +19,7 @@ "Authentication required.": "Authentification requise.", "Back": "Retour", "Back to shared files": "Retour aux fichiers partagés", + "Brake released. Pads whose .pad file has been missing past the grace period are deleted over the next runs.": "Frein relâché. Les pads dont le fichier .pad manque depuis plus longtemps que le délai de grâce sont supprimés lors des prochains passages.", "Cannot open selected .pad file.": "Impossible d'ouvrir le fichier .pad sélectionné.", "Cannot resolve file path for file ID.": "Impossible de résoudre le chemin du fichier pour l'identifiant.", "Cannot resolve selected parent folder.": "Impossible de résoudre le dossier parent sélectionné.", @@ -114,6 +115,7 @@ "Loading pad content...": "Chargement du contenu du pad…", "Loading pad...": "Chargement du pad...", "Looks for pad links whose .pad file no longer exists.": "Recherche les liens de pad dont le fichier .pad n'existe plus.", + "Many .pad files went missing at once without passing a trash. Their pads are kept until the brake is released.": "De nombreux fichiers .pad ont disparu d'un coup sans passer par une corbeille. Leurs pads sont conservés jusqu'à ce que le frein soit relâché.", "New pad": "Nouveau pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud et Etherpad ne partagent aucun domaine parent : le navigateur ne peut donc pas envoyer le cookie de session à Etherpad, et les pads protégés ne s'ouvriront pas. Si un proxy expose Etherpad sous le domaine Nextcloud, utilisez cette adresse comme URL de base. Sinon, placez les deux services sous un domaine parent commun ou désactivez les pads protégés.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud n'a pas répondu. Vérifiez votre connexion et réessayez.", @@ -133,6 +135,7 @@ "Pad name is required.": "Le nom du pad est obligatoire.", "Pad types and behaviour": "Types de pads et comportement", "Pads": "Pads", + "Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?": "Les pads dont le fichier .pad manque depuis plus longtemps que le délai de grâce seront supprimés. Relâcher le frein ?", "Pending Etherpad deletes": "Suppressions Etherpad en attente", "Pending pad check failed.": "La vérification des pads en attente a échoué.", "Pending pad check finished.": "Vérification des pads en attente terminée.", @@ -142,6 +145,9 @@ "Public pads": "Pads publics", "Refresh": "Actualiser", "Refreshing...": "Actualisation…", + "Release the brake": "Relâcher le frein", + "Releasing the brake failed.": "Impossible de relâcher le frein.", + "Releasing the brake...": "Relâchement du frein...", "Replace the existing template of that name?": "Remplacer le modèle existant portant ce nom ?", "Request failed.": "La requête a échoué.", "Required for protected pads unless Nextcloud and Etherpad run on the same host: the session cookie must be valid for both. Auto-filled from the two hosts when they share a parent domain. Leave empty for a host-only cookie.": "Nécessaire pour les pads protégés, sauf si Nextcloud et Etherpad tournent sur le même hôte : le cookie de session doit être valide pour les deux. Rempli automatiquement à partir des deux hôtes lorsqu'ils partagent un domaine parent. Laisser vide pour un cookie limité à l'hôte.", diff --git a/l10n/it.js b/l10n/it.js index 738436db..ba5986dd 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -20,6 +20,7 @@ OC.L10N.register( "Authentication required.": "Autenticazione richiesta.", "Back": "Indietro", "Back to shared files": "Torna ai file condivisi", + "Brake released. Pads whose .pad file has been missing past the grace period are deleted over the next runs.": "Freno rilasciato. I pad il cui file .pad manca da più del periodo di tolleranza vengono eliminati nelle prossime esecuzioni.", "Cannot open selected .pad file.": "Impossibile aprire il file .pad selezionato.", "Cannot resolve file path for file ID.": "Impossibile risolvere il percorso del file per l'ID file.", "Cannot resolve selected parent folder.": "Impossibile risolvere la cartella di destinazione selezionata.", @@ -115,6 +116,7 @@ OC.L10N.register( "Loading pad content...": "Caricamento del contenuto del pad…", "Loading pad...": "Caricamento del pad...", "Looks for pad links whose .pad file no longer exists.": "Cerca collegamenti a pad il cui file .pad non esiste più.", + "Many .pad files went missing at once without passing a trash. Their pads are kept until the brake is released.": "Molti file .pad sono scomparsi tutti insieme senza passare dal cestino. I loro pad vengono conservati finché il freno non viene rilasciato.", "New pad": "Nuovo pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud ed Etherpad non condividono alcun parent domain, quindi il browser non può inviare il cookie di sessione a Etherpad e i pad protetti non si apriranno. Se un proxy espone Etherpad sotto il dominio di Nextcloud, usa quell'indirizzo come URL base. Altrimenti colloca entrambi i servizi sotto un parent domain comune, oppure disattiva i pad protetti.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud non ha risposto. Controlla la connessione e riprova.", @@ -134,6 +136,7 @@ OC.L10N.register( "Pad name is required.": "Il nome del pad è obbligatorio.", "Pad types and behaviour": "Tipi di pad e comportamento", "Pads": "Pad", + "Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?": "I pad il cui file .pad manca da più del periodo di tolleranza verranno eliminati. Rilasciare il freno?", "Pending Etherpad deletes": "Eliminazioni Etherpad in sospeso", "Pending pad check failed.": "Controllo dei pad in sospeso non riuscito.", "Pending pad check finished.": "Controllo dei pad in sospeso completato.", @@ -143,6 +146,9 @@ OC.L10N.register( "Public pads": "Pad pubblici", "Refresh": "Aggiorna", "Refreshing...": "Aggiornamento…", + "Release the brake": "Rilascia il freno", + "Releasing the brake failed.": "Impossibile rilasciare il freno.", + "Releasing the brake...": "Rilascio del freno in corso...", "Replace the existing template of that name?": "Sostituire il modello esistente con questo nome?", "Request failed.": "Richiesta non riuscita.", "Required for protected pads unless Nextcloud and Etherpad run on the same host: the session cookie must be valid for both. Auto-filled from the two hosts when they share a parent domain. Leave empty for a host-only cookie.": "Necessario per i pad protetti, a meno che Nextcloud ed Etherpad non siano sullo stesso host: il cookie di sessione deve essere valido per entrambi. Compilato automaticamente dai due host quando condividono un parent domain. Lascia vuoto per un cookie valido solo per l'host.", diff --git a/l10n/it.json b/l10n/it.json index 6cb098d8..05104dcf 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -19,6 +19,7 @@ "Authentication required.": "Autenticazione richiesta.", "Back": "Indietro", "Back to shared files": "Torna ai file condivisi", + "Brake released. Pads whose .pad file has been missing past the grace period are deleted over the next runs.": "Freno rilasciato. I pad il cui file .pad manca da più del periodo di tolleranza vengono eliminati nelle prossime esecuzioni.", "Cannot open selected .pad file.": "Impossibile aprire il file .pad selezionato.", "Cannot resolve file path for file ID.": "Impossibile risolvere il percorso del file per l'ID file.", "Cannot resolve selected parent folder.": "Impossibile risolvere la cartella di destinazione selezionata.", @@ -114,6 +115,7 @@ "Loading pad content...": "Caricamento del contenuto del pad…", "Loading pad...": "Caricamento del pad...", "Looks for pad links whose .pad file no longer exists.": "Cerca collegamenti a pad il cui file .pad non esiste più.", + "Many .pad files went missing at once without passing a trash. Their pads are kept until the brake is released.": "Molti file .pad sono scomparsi tutti insieme senza passare dal cestino. I loro pad vengono conservati finché il freno non viene rilasciato.", "New pad": "Nuovo pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud ed Etherpad non condividono alcun parent domain, quindi il browser non può inviare il cookie di sessione a Etherpad e i pad protetti non si apriranno. Se un proxy espone Etherpad sotto il dominio di Nextcloud, usa quell'indirizzo come URL base. Altrimenti colloca entrambi i servizi sotto un parent domain comune, oppure disattiva i pad protetti.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud non ha risposto. Controlla la connessione e riprova.", @@ -133,6 +135,7 @@ "Pad name is required.": "Il nome del pad è obbligatorio.", "Pad types and behaviour": "Tipi di pad e comportamento", "Pads": "Pad", + "Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?": "I pad il cui file .pad manca da più del periodo di tolleranza verranno eliminati. Rilasciare il freno?", "Pending Etherpad deletes": "Eliminazioni Etherpad in sospeso", "Pending pad check failed.": "Controllo dei pad in sospeso non riuscito.", "Pending pad check finished.": "Controllo dei pad in sospeso completato.", @@ -142,6 +145,9 @@ "Public pads": "Pad pubblici", "Refresh": "Aggiorna", "Refreshing...": "Aggiornamento…", + "Release the brake": "Rilascia il freno", + "Releasing the brake failed.": "Impossibile rilasciare il freno.", + "Releasing the brake...": "Rilascio del freno in corso...", "Replace the existing template of that name?": "Sostituire il modello esistente con questo nome?", "Request failed.": "Richiesta non riuscita.", "Required for protected pads unless Nextcloud and Etherpad run on the same host: the session cookie must be valid for both. Auto-filled from the two hosts when they share a parent domain. Leave empty for a host-only cookie.": "Necessario per i pad protetti, a meno che Nextcloud ed Etherpad non siano sullo stesso host: il cookie di sessione deve essere valido per entrambi. Compilato automaticamente dai due host quando condividono un parent domain. Lascia vuoto per un cookie valido solo per l'host.", diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 621d19f2..87e11274 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -19,6 +19,7 @@ use OCA\EtherpadNextcloud\Service\CookieDomainMessages; use OCA\EtherpadNextcloud\Service\CookieDomainPolicy; use OCA\EtherpadNextcloud\Service\EtherpadHealthCheckService; +use OCA\EtherpadNextcloud\Service\GoneFileSweep; use OCA\EtherpadNextcloud\Service\HealthCheckItem; use OCA\EtherpadNextcloud\Service\HealthCheckResult; use OCA\EtherpadNextcloud\Service\PadTemplateAdminService; @@ -57,6 +58,7 @@ public function __construct( private CookieDomainMessages $cookieDomainMessages, private IURLGenerator $urlGenerator, private PadTemplateAdminService $padTemplateAdmin, + private GoneFileSweep $goneFileSweep, ) { parent::__construct($appName, $request); } @@ -162,6 +164,24 @@ function (): array { ); } + public function releaseGoneFileBrake(): DataResponse { + return $this->errors->run( + function (): bool { + $this->requireAdmin(); + $this->goneFileSweep->releaseBrake(); + return true; + }, + fn(): DataResponse => new DataResponse([ + 'ok' => true, + 'message' => $this->l10n->t('Brake released. Pads whose .pad file has been missing past the grace period are deleted over the next runs.'), + ]), + [ + 'generic' => $this->l10n->t('Releasing the brake failed.'), + 'log_message' => 'Releasing the brake failed', + ], + ); + } + public function setTestFault(): DataResponse { return $this->errors->run( function (): string { diff --git a/lib/Service/AdminConsistencyCheckResponseBuilder.php b/lib/Service/AdminConsistencyCheckResponseBuilder.php index d949c13c..ddf388ab 100644 --- a/lib/Service/AdminConsistencyCheckResponseBuilder.php +++ b/lib/Service/AdminConsistencyCheckResponseBuilder.php @@ -23,14 +23,19 @@ public function __construct( */ public function build(array $result): array { $issues = (int)$result['binding_without_file_count']; - $message = $issues > 0 - ? $this->l10n->t('Consistency check finished with issues.') - : $this->l10n->t('Consistency check successful. No issues found.'); + $brakeEngaged = (bool)$result['gone_file_brake_engaged']; + $message = match (true) { + $brakeEngaged => $this->l10n->t('Many .pad files went missing at once without passing a trash. Their pads are kept until the brake is released.'), + $issues > 0 => $this->l10n->t('Consistency check finished with issues.'), + default => $this->l10n->t('Consistency check successful. No issues found.'), + }; return [ 'ok' => true, 'message' => $message, 'binding_without_file_count' => $issues, + 'missing_file_count' => (int)$result['missing_file_count'], + 'gone_file_brake_engaged' => $brakeEngaged, 'samples' => $result['samples'], ]; } diff --git a/lib/Service/AppConfigService.php b/lib/Service/AppConfigService.php index b2cd24eb..8a16fc23 100644 --- a/lib/Service/AppConfigService.php +++ b/lib/Service/AppConfigService.php @@ -61,7 +61,13 @@ public function getGoneFileBrakeReleasedAt(): int { return $this->appConfig->getValueInt(Application::APP_ID, 'gone_file_brake_released_at'); } - /** Whether the last sweep found the brake on: so it warns once, and the health check can say so. */ + /** An admin lets the sweep go on: files missed until $now no longer hold it. */ + public function releaseGoneFileBrake(int $now): void { + $this->appConfig->setValueInt(Application::APP_ID, 'gone_file_brake_released_at', $now); + $this->setGoneFileBrakeEngaged(false); + } + + /** Whether the last sweep found the brake on: so it warns once, and the consistency check can say so. */ public function isGoneFileBrakeEngaged(): bool { return $this->appConfig->getValueBool(Application::APP_ID, 'gone_file_brake_engaged'); } diff --git a/lib/Service/ConsistencyCheckService.php b/lib/Service/ConsistencyCheckService.php index c70e4849..3fb2a39f 100644 --- a/lib/Service/ConsistencyCheckService.php +++ b/lib/Service/ConsistencyCheckService.php @@ -9,17 +9,21 @@ namespace OCA\EtherpadNextcloud\Service; use OCA\EtherpadNextcloud\Util\DbRows; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; class ConsistencyCheckService { public function __construct( private IDBConnection $db, + private AppConfigService $appConfig, ) { } /** * @return array{ * binding_without_file_count:int, + * missing_file_count:int, + * gone_file_brake_engaged:bool, * samples:array{bindings_without_file:array>} * } */ @@ -28,6 +32,8 @@ public function run(int $sampleLimit = 25): array { return [ 'binding_without_file_count' => $this->countBindingsWithoutFile(), + 'missing_file_count' => $this->countMissingFiles(), + 'gone_file_brake_engaged' => $this->appConfig->isGoneFileBrakeEngaged(), 'samples' => [ 'bindings_without_file' => $this->sampleBindingsWithoutFile($limit), ], @@ -40,7 +46,24 @@ private function countBindingsWithoutFile(): int { ->from(BindingService::TABLE, 'b') ->leftJoin('b', 'filecache', 'fc', $qb->expr()->eq('b.file_id', 'fc.fileid')) ->where($qb->expr()->isNull('fc.fileid')); + return $this->count($qb); + } + + /** + * Active rows whose file went missing without being seen leaving Files: + * in their grace period, or held by the brake (GoneFileSweep). + */ + private function countMissingFiles(): int { + $qb = $this->db->getQueryBuilder(); + $qb->selectAlias($qb->createFunction('COUNT(*)'), 'cnt') + ->from(BindingService::TABLE) + ->where($qb->expr()->isNotNull('missing_since')) + ->andWhere($qb->expr()->isNull('trashed_at')) + ->andWhere($qb->expr()->eq('state', $qb->createNamedParameter(BindingService::STATE_ACTIVE))); + return $this->count($qb); + } + private function count(IQueryBuilder $qb): int { $result = $qb->executeQuery(); $row = DbRows::one($result->fetch()); $result->closeCursor(); diff --git a/lib/Service/GoneFileSweep.php b/lib/Service/GoneFileSweep.php index 4dc574b0..56830a75 100644 --- a/lib/Service/GoneFileSweep.php +++ b/lib/Service/GoneFileSweep.php @@ -70,6 +70,15 @@ public function run(): void { } } + /** + * An admin's release of the brake: the files missing until now no longer + * count, and their pads go once past their grace period. The next slices + * find out whether it holds again. + */ + public function releaseBrake(): void { + $this->appConfig->releaseGoneFileBrake($this->timeFactory->getTime()); + } + /** * One slice of the pass over every active row, from where the last one * stopped. The dates first, then the brake, so files going missing in diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index 6114de2a..654ed2cb 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -92,6 +92,7 @@ public function getForm(): TemplateResponse { 'health_check_url' => $this->urlGenerator->linkToRoute('etherpad_nextcloud.admin.healthCheck'), 'consistency_check_url' => $this->urlGenerator->linkToRoute('etherpad_nextcloud.admin.consistencyCheck'), 'settle_pending_url' => $this->urlGenerator->linkToRoute('etherpad_nextcloud.admin.settlePending'), + 'release_brake_url' => $this->urlGenerator->linkToRoute('etherpad_nextcloud.admin.releaseGoneFileBrake'), 'templates_url' => $this->urlGenerator->linkToRoute('etherpad_nextcloud.admin.listPadTemplates'), 'templates_delete_url' => $this->urlGenerator->linkToRoute('etherpad_nextcloud.admin.deletePadTemplate'), 'l10n' => [ @@ -146,6 +147,10 @@ public function getForm(): TemplateResponse { 'health_button' => $this->l10n->t('Test Etherpad connection'), 'consistency_button' => $this->l10n->t('Consistency check'), 'settle_pending_button' => $this->l10n->t('Check pending pads'), + 'release_brake_button' => $this->l10n->t('Release the brake'), + 'release_brake_confirm' => $this->l10n->t('Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?'), + 'releasing_brake' => $this->l10n->t('Releasing the brake...'), + 'release_brake_failed' => $this->l10n->t('Releasing the brake failed.'), 'pending_delete_label' => $this->l10n->t('Pending Etherpad deletes'), 'restore_pending_label' => $this->l10n->t('Unresolved restores'), 'saving' => $this->l10n->t('Saving settings...'), diff --git a/src/admin-settings.js b/src/admin-settings.js index f3455641..1b51ac96 100644 --- a/src/admin-settings.js +++ b/src/admin-settings.js @@ -12,6 +12,8 @@ const pendingActions = document.getElementById('etherpad-nextcloud-pending-actions') const pendingCountNode = document.getElementById('etherpad-nextcloud-pending-count') const restorePendingCountNode = document.getElementById('etherpad-nextcloud-restore-pending-count') + const brakeActions = document.getElementById('etherpad-nextcloud-brake-actions') + const releaseBrakeButton = document.getElementById('etherpad-nextcloud-release-brake') const allowExternalCheckbox = form ? form.querySelector('input[name="allow_external_pads"]') : null const protectedPadsCheckbox = form ? form.querySelector('input[name="enable_protected_pads"]') : null const publicPadsCheckbox = form ? form.querySelector('input[name="enable_public_pads"]') : null @@ -42,6 +44,7 @@ const healthUrl = root.getAttribute('data-health-url') || '' const consistencyUrl = root.getAttribute('data-consistency-url') || '' const settlePendingUrl = root.getAttribute('data-settle-pending-url') || '' + const releaseBrakeUrl = root.getAttribute('data-release-brake-url') || '' const l10n = { saving: root.getAttribute('data-l10n-saving') || 'Saving settings...', saved: root.getAttribute('data-l10n-saved') || 'Settings saved.', @@ -55,6 +58,9 @@ pendingDeleteLabel: root.getAttribute('data-l10n-pending-delete-label') || 'Pending Etherpad deletes', restorePendingLabel: root.getAttribute('data-l10n-restore-pending-label') || 'Unresolved restores', settleFailed: root.getAttribute('data-l10n-settle-failed') || 'Pending pad check failed.', + releaseBrakeConfirm: root.getAttribute('data-l10n-release-brake-confirm') || 'Pads whose .pad file has been missing past the grace period will be deleted. Release the brake?', + releasingBrake: root.getAttribute('data-l10n-releasing-brake') || 'Releasing the brake...', + releaseBrakeFailed: root.getAttribute('data-l10n-release-brake-failed') || 'Releasing the brake failed.', templateUploading: root.getAttribute('data-l10n-template-uploading') || 'Uploading template...', templateDelete: root.getAttribute('data-l10n-template-delete') || 'Delete', templateTooLarge: root.getAttribute('data-l10n-template-too-large') || 'Template file is too large.', @@ -500,14 +506,41 @@ try { const data = await postJson(consistencyUrl, {}) const bindingWithoutFile = Number(data.binding_without_file_count || 0) - const message = `${String(data.message || l10n.consistencyOk)} binding_without_file=${String(bindingWithoutFile)}` - setStatus(message, bindingWithoutFile > 0 ? 'error' : 'success', diagnosticsTarget) + const missingFile = Number(data.missing_file_count || 0) + const brakeEngaged = data.gone_file_brake_engaged === true + const message = `${String(data.message || l10n.consistencyOk)} binding_without_file=${String(bindingWithoutFile)} | missing_file=${String(missingFile)}` + setStatus(message, brakeEngaged || bindingWithoutFile > 0 ? 'error' : 'success', diagnosticsTarget) + showBrakeActions(brakeEngaged) } catch (error) { setStatus(error instanceof Error ? error.message : l10n.consistencyFailed, 'error', diagnosticsTarget) } }) } + /** The brake of the sweep of files gone for good: released only here, and offered only while it holds. */ + function showBrakeActions(engaged) { + if (brakeActions instanceof HTMLElement) { + brakeActions.style.display = engaged && releaseBrakeUrl !== '' ? '' : 'none' + } + } + + if (releaseBrakeButton instanceof HTMLElement) { + releaseBrakeButton.addEventListener('click', async () => { + if (!window.confirm(l10n.releaseBrakeConfirm)) { + return + } + clearFieldErrors() + beginStatus(l10n.releasingBrake, diagnosticsTarget) + try { + const data = await postJson(releaseBrakeUrl, {}) + showBrakeActions(false) + setStatus(String(data.message || 'OK'), 'success', diagnosticsTarget) + } catch (error) { + setStatus(error instanceof Error ? error.message : l10n.releaseBrakeFailed, 'error', diagnosticsTarget) + } + }) + } + if (settlePendingButton instanceof HTMLElement) { settlePendingButton.addEventListener('click', async () => { clearFieldErrors() diff --git a/templates/admin-settings.php b/templates/admin-settings.php index 62fddf24..f54a1237 100644 --- a/templates/admin-settings.php +++ b/templates/admin-settings.php @@ -10,6 +10,10 @@ class="section" data-health-url="" data-consistency-url="" data-settle-pending-url="" + data-release-brake-url="" + data-l10n-release-brake-confirm="" + data-l10n-releasing-brake="" + data-l10n-release-brake-failed="" data-l10n-saving="" data-l10n-saved="" data-l10n-checking="" @@ -245,6 +249,9 @@ class="section"
+