Pin a restore point and keep a note on it

Both live in the catalog on the location, so a new server sees them; the database holds
a local copy for the lists. The retention policy leaves a pinned restore point alone and
does not count it. Available on the restore page and as occ backup:pin and backup:note.
This commit is contained in:
2026-11-06 23:49:50 +01:00
parent 6540a9314a
commit 193f65940b
23 changed files with 464 additions and 9 deletions
+7
View File
@@ -13,6 +13,13 @@ shown under 1.0.0. The git tag of a release candidate goes after the date.
## [Unreleased]
### Added
- Pin a restore point, on the restore page or with `occ backup:pin`: the retention policy leaves it
alone, for example the one from before an upgrade. Unpinning notifies all administrators.
- A note on a restore point that you can change afterwards, on the restore page or with
`occ backup:note`. Pins and notes are stored encrypted on the location, so a new server sees them.
An older version of NG Backup can still read the location, but drops them when it writes to it.
### Changed
- A file or folder that is left out because of its name now gets a notification of its own, in the
log and in `occ backup:run`. It says the item is missing from the backup, gives example paths and
+1
View File
@@ -333,6 +333,7 @@ On the backlog; know them before you rely on NG Backup:
| `backup:cancel <run>` | stop a running backup |
| `backup:browse`, `backup:restore` | browse and restore |
| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
| `backup:pin`, `backup:note` | keep a restore point, a note you can change later |
| `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) |
| `backup:restore:full` | whole-server restore onto a fresh installation (after `backup:key:import`) |
| `backup:key:import` | import a recovery kit on a fresh installation |
+2
View File
@@ -83,6 +83,8 @@ you need help with restoring a backup after a crash.]]></description>
<command>OCA\NgBackup\Command\ListSnapshots</command>
<command>OCA\NgBackup\Command\Browse</command>
<command>OCA\NgBackup\Command\Restore</command>
<command>OCA\NgBackup\Command\Pin</command>
<command>OCA\NgBackup\Command\Note</command>
<command>OCA\NgBackup\Command\Prune</command>
<command>OCA\NgBackup\Command\Retention</command>
<command>OCA\NgBackup\Command\TargetAppendOnly</command>
+2
View File
@@ -27,6 +27,8 @@ return [
['name' => 'AdminApi#tick', 'url' => '/api/tick', 'verb' => 'POST'],
['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'],
['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
['name' => 'AdminApi#setSnapshotNote', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/note', 'verb' => 'PUT'],
['name' => 'AdminApi#setSnapshotPinned', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/pin', 'verb' => 'PUT'],
['name' => 'AdminApi#browse', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/browse', 'verb' => 'GET'],
['name' => 'AdminApi#startRestore', 'url' => '/api/restores', 'verb' => 'POST'],
],
+5
View File
@@ -173,8 +173,13 @@ occ backup:prune offsite --dry-run # show first
occ backup:prune offsite # apply the policy; forgotten snapshots go to the trash first
occ backup:trash # list the trash
occ backup:trash:restore offsite <id>
occ backup:pin offsite <id> # keep this restore point; --unpin to undo
occ backup:note offsite <id> "text" # a note you can change later; without text it is removed
```
The retention policy leaves a pinned restore point alone, and a pinned one does not take the place of
a daily, weekly or monthly one. Pin and note are stored on the location, so a new server sees them too.
Snapshots are kept according to the policy, and their data is deleted only after the deletion delay
(set with `backup:key:init --delete-delay-days`). `occ backup:prune offsite --dry-run` shows what would be
removed without changing anything.
+2 -2
View File
@@ -40,14 +40,14 @@ final class ListSnapshots extends Command {
foreach ($targets as $t) {
foreach ($this->snapshots->findForTarget($t->getId()) as $s) {
$rows[] = [$t->getName(), $s->getSnapshotId(), Format::time($s->getCreatedAt(), $zone), $s->getKind(),
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getLabel() ?? ''];
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getPinned() ? 'yes' : '', $s->getLabel() ?? '', $s->getNote() ?? ''];
}
}
if ($rows === []) {
$output->writeln('No snapshots yet (occ backup:run).');
return 0;
}
(new Table($output))->setHeaders(['location', 'snapshot', "date ($zone)", 'kind', 'files', 'size', 'label'])->setRows($rows)->render();
(new Table($output))->setHeaders(['location', 'snapshot', "date ($zone)", 'kind', 'files', 'size', 'pinned', 'label', 'note'])->setRows($rows)->render();
return 0;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\PruneService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class Note extends Command {
public function __construct(
private TargetService $targets,
private PruneService $prune,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:note')
->setDescription('Set or remove the note on a snapshot')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
->addArgument('note', InputArgument::OPTIONAL, 'The note, at most ' . PruneService::NOTE_MAX . ' characters; without it the note is removed', '');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$note = (string)$input->getArgument('note');
try {
$this->prune->setNote($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), $note);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln(trim($note) === '' ? 'Note removed.' : 'Note saved.');
return 0;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\PruneService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class Pin extends Command {
public function __construct(
private TargetService $targets,
private PruneService $prune,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:pin')
->setDescription('Keep a snapshot: the retention policy does not remove a pinned one')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
->addOption('unpin', null, InputOption::VALUE_NONE, 'Leave the snapshot to the retention policy again');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$unpin = (bool)$input->getOption('unpin');
try {
$this->prune->setPinned($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), !$unpin);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln($unpin ? 'Snapshot unpinned; the retention policy applies to it again.' : 'Snapshot pinned; the retention policy leaves it alone.');
return 0;
}
}
+20 -2
View File
@@ -292,11 +292,29 @@ final class AdminApiController extends Controller {
$out = [];
foreach ($this->snapshots->findForTarget($targetId) as $s) {
$out[] = ['id' => $s->getSnapshotId(), 'kind' => $s->getKind(), 'label' => $s->getLabel(), 'createdAt' => $s->getCreatedAt(),
'files' => $s->getFiles(), 'bytes' => $s->getBytes()];
'files' => $s->getFiles(), 'bytes' => $s->getBytes(), 'note' => $s->getNote(), 'pinned' => (bool)$s->getPinned()];
}
return new JSONResponse($out);
}
public function setSnapshotNote(int $targetId, string $snapshotId, string $note = ''): JSONResponse {
try {
$this->prune->setNote($this->targets->get((string)$targetId), $snapshotId, $note);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new JSONResponse(['ok' => true]);
}
public function setSnapshotPinned(int $targetId, string $snapshotId, bool $pinned = true): JSONResponse {
try {
$this->prune->setPinned($this->targets->get((string)$targetId), $snapshotId, $pinned);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new JSONResponse(['ok' => true]);
}
public function browse(int $targetId, string $snapshotId, string $path = ''): JSONResponse {
try {
return new JSONResponse($this->restore->browse($this->targets->get((string)$targetId), $snapshotId, $path));
@@ -341,7 +359,7 @@ final class AdminApiController extends Controller {
if ($r->getSnapshotId() !== null) {
try {
$s = $this->snapshots->findOne($r->getTargetId(), $r->getSnapshotId());
$snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel()];
$snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel(), 'note' => $s->getNote(), 'pinned' => (bool)$s->getPinned()];
} catch (\Throwable) {
// Snapshot already pruned or trashed: the run keeps its id, just not the date.
}
+7
View File
@@ -24,6 +24,10 @@ use OCP\AppFramework\Db\Entity;
* @method void setFiles(?int $v)
* @method int|null getBytes()
* @method void setBytes(?int $v)
* @method string|null getNote()
* @method void setNote(?string $v)
* @method bool|null getPinned()
* @method void setPinned(?bool $v)
*/
final class Snapshot extends Entity {
protected int $targetId = 0;
@@ -33,8 +37,11 @@ final class Snapshot extends Entity {
protected int $createdAt = 0;
protected ?int $files = null;
protected ?int $bytes = null;
protected ?string $note = null;
protected ?bool $pinned = null;
public function __construct() {
$this->addType('pinned', 'boolean');
$this->addType('targetId', 'integer');
$this->addType('createdAt', 'integer');
$this->addType('files', 'integer');
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/** Local copy of what the catalog on the location says about a snapshot: its note and pin. */
final class Version000007Date20261107000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$t = $schema->getTable('ngb_snapshots');
$changed = false;
if (!$t->hasColumn('note')) {
$t->addColumn('note', Types::STRING, ['notnull' => false, 'length' => 1000]);
$changed = true;
}
if (!$t->hasColumn('pinned')) {
$t->addColumn('pinned', Types::BOOLEAN, ['notnull' => false, 'default' => false]);
$changed = true;
}
return $changed ? $schema : null;
}
}
+10 -1
View File
@@ -30,6 +30,10 @@ final class Catalog {
public array $trash = [];
/** @var array<string, true> manifest path => true, for a per-user export (users/<uid>/<exportId>) */
public array $userExports = [];
/** @var array<string, true> snapshots the retention policy leaves alone */
public array $pinned = [];
/** @var array<string, string> snapshot id => note, also while the snapshot is in the trash */
public array $notes = [];
public string $hash = '';
public function __construct(
@@ -82,6 +86,8 @@ final class Catalog {
// Catalogs written before user exports existed have no such key: default to empty rather
// than adopting the location's current users/ listing (that would skip verification).
$this->userExports = array_fill_keys($data['userExports'] ?? [], true);
$this->pinned = array_fill_keys($data['pinned'] ?? [], true);
$this->notes = $data['notes'] ?? [];
$this->anchor?->set($this->repositoryId, $this->gen, $this->hash);
}
@@ -93,9 +99,12 @@ final class Catalog {
if ($this->backend->exists($this->path($next))) {
throw new RepositoryException("Catalog generation $next was written by another process at the same time; try again");
}
// A pin ends when its snapshot leaves the list, a note when the snapshot is gone for good.
$this->pinned = array_intersect_key($this->pinned, $this->snapshots);
$this->notes = array_intersect_key($this->notes, $this->snapshots + $this->trash);
$plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash,
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash),
'userExports' => array_keys($this->userExports)], JSON_THROW_ON_ERROR);
'userExports' => array_keys($this->userExports), 'pinned' => array_keys($this->pinned), 'notes' => $this->notes], JSON_THROW_ON_ERROR);
$encrypted = $this->cipher->encryptString($plain, $this->ad($next));
$stream = fopen('php://memory', 'w+b');
fwrite($stream, $encrypted);
+43
View File
@@ -134,6 +134,49 @@ final class Repository {
return $this->catalog->gen;
}
/** @return list<string> snapshots the retention policy leaves alone */
public function pinnedSnapshots(): array {
return array_map('strval', array_keys($this->catalog->pinned));
}
/** @return array<string, string> snapshot id => note */
public function snapshotNotes(): array {
return $this->catalog->notes;
}
public function setPinned(string $snapshotId, bool $pinned): void {
$this->requireSnapshot($snapshotId);
if (isset($this->catalog->pinned[$snapshotId]) === $pinned) {
return;
}
if ($pinned) {
$this->catalog->pinned[$snapshotId] = true;
} else {
unset($this->catalog->pinned[$snapshotId]);
}
$this->catalog->write();
}
/** A note kept in the catalog: unlike the label inside the snapshot, it can change afterwards. */
public function setSnapshotNote(string $snapshotId, string $note): void {
$this->requireSnapshot($snapshotId);
if (($this->catalog->notes[$snapshotId] ?? '') === $note) {
return;
}
if ($note === '') {
unset($this->catalog->notes[$snapshotId]);
} else {
$this->catalog->notes[$snapshotId] = $note;
}
$this->catalog->write();
}
private function requireSnapshot(string $snapshotId): void {
if (!isset($this->catalog->snapshots[$snapshotId])) {
throw new \InvalidArgumentException('There is no snapshot ' . $snapshotId . ' on this location');
}
}
/** Index files downloaded when this repository was opened (0 when the local cache was complete). */
public function indexDownloads(): int {
return $this->index->downloaded;
+51 -1
View File
@@ -11,6 +11,7 @@ use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Db\SnapshotMapper;
use OCA\NgBackup\Db\Target;
use OCA\NgBackup\Repository\Repository;
use OCP\IAppConfig;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
@@ -33,6 +34,54 @@ final class PruneService {
) {
}
/** Characters of a note on a snapshot (the column holds 1000) */
public const NOTE_MAX = 500;
/** Pin or unpin a snapshot: the retention policy leaves a pinned one alone. */
public function setPinned(Target $target, string $snapshotId, bool $pinned): void {
$changed = $this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $pinned): bool {
$was = in_array($snapshotId, $repo->pinnedSnapshots(), true);
$repo->setPinned($snapshotId, $pinned);
return $was !== $pinned;
});
$this->targets->syncSnapshot($target, $snapshotId, pinned: $pinned);
if ($changed) {
// Unpinning opens the way to removing it, so every administrator hears about that.
$this->alerts->securityEvent($pinned ? 'snapshot_pinned' : 'snapshot_unpinned', ['target' => $target->getName(), 'snapshot' => $snapshotId], !$pinned);
}
}
public function setNote(Target $target, string $snapshotId, string $note): void {
$note = trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', $note));
if (!mb_check_encoding($note, 'UTF-8')) {
throw new \InvalidArgumentException('A note must be valid UTF-8');
}
if (mb_strlen($note) > self::NOTE_MAX) {
throw new \InvalidArgumentException('A note has at most ' . self::NOTE_MAX . ' characters');
}
$this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $note): bool {
$repo->setSnapshotNote($snapshotId, $note);
return true;
});
$this->targets->syncSnapshot($target, $snapshotId, note: $note);
}
/**
* Write to the catalog next to a running backup or restore, which only takes it for a moment to
* commit. Not during a cleanup, which holds a repository opened earlier and writes from that.
*
* @param callable(Repository): bool $change
*/
private function changeCatalog(Target $target, callable $change): bool {
try {
return $this->leases->with(self::lockKey($target), LeaseService::SHARED, 300,
fn (): bool => $this->leases->with(self::catalogLockKey($target), LeaseService::EXCLUSIVE, 300,
fn (): bool => $change($this->targets->repository($target))));
} catch (LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' or another change to its restore points is in progress; try again later');
}
}
public function untrash(Target $target, string $snapshotId): void {
$lease = $this->lockExclusive($target, 300);
try {
@@ -87,6 +136,7 @@ final class PruneService {
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
$s->setFiles((int)($meta['stats']['files'] ?? 0));
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
$s->setNote($repo->snapshotNotes()[$snapshotId] ?? null);
try {
$this->snapshots->insert($s);
} catch (\Throwable) {
@@ -208,7 +258,7 @@ final class PruneService {
$meta = $repo->snapshotMeta($id);
$times[$id] = (int)strtotime((string)($meta['time'] ?? '@0'));
}
$keep = $this->policy()->keep($times);
$keep = $this->policy()->keepWithPinned($times, $repo->pinnedSnapshots());
$forget = array_values(array_diff(array_keys($times), array_keys($keep)));
$actor = $scheduled ? 'retention policy' : $this->alerts->actor();
$purged = [];
+13
View File
@@ -28,6 +28,19 @@ final class RetentionPolicy {
return ['last' => $this->last, 'daily' => $this->daily, 'weekly' => $this->weekly, 'monthly' => $this->monthly];
}
/**
* keep() for the snapshots that are not pinned, plus the pinned ones. A pinned snapshot does not
* take the place of a daily, weekly or monthly one.
*
* @param array<string, int> $snapshots id => unix time
* @param list<string> $pinned
* @return array<string, list<string>>
*/
public function keepWithPinned(array $snapshots, array $pinned): array {
$pins = array_intersect_key($snapshots, array_flip($pinned));
return $this->keep(array_diff_key($snapshots, $pins)) + array_fill_keys(array_keys($pins), ['pinned']);
}
/**
* @param array<string, int> $snapshots id => unix time
* @return array<string, list<string>> id => reasons it is kept (ids not in the result are removed)
+20
View File
@@ -208,7 +208,25 @@ final class TargetService {
return $result;
}
/** Update the local copy of a snapshot's note or pin, if the snapshot is listed here at all. */
public function syncSnapshot(Target $target, string $snapshotId, ?string $note = null, ?bool $pinned = null): void {
try {
$s = $this->snapshots->findOne($target->getId(), $snapshotId);
} catch (DoesNotExistException) {
return;
}
if ($note !== null) {
$s->setNote($note !== '' ? $note : null);
}
if ($pinned !== null) {
$s->setPinned($pinned);
}
$this->snapshots->update($s);
}
private function adoptSnapshots(Target $target, Repository $repo): void {
$notes = $repo->snapshotNotes();
$pinned = array_flip($repo->pinnedSnapshots());
foreach ($repo->listSnapshots() as $snapshotId) {
try {
$this->snapshots->findOne($target->getId(), $snapshotId);
@@ -227,6 +245,8 @@ final class TargetService {
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
$s->setFiles((int)($meta['stats']['files'] ?? 0));
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
$s->setNote($notes[$snapshotId] ?? null);
$s->setPinned(isset($pinned[$snapshotId]));
$this->snapshots->insert($s);
} catch (\Throwable $e) {
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: snapshot ' . $snapshotId . ' on ' . $target->getName() . ' not listed: ' . $e->getMessage(), ['exception' => $e]);
+8
View File
@@ -300,6 +300,14 @@
<code><![CDATA[$t]]></code>
</UndefinedDocblockClass>
</file>
<file src="lib/Migration/Version000007Date20261107000000.php">
<UndefinedDocblockClass>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
</UndefinedDocblockClass>
</file>
<file src="lib/Migration/Version000005Date20261002040000.php">
<UndefinedDocblockClass>
<code><![CDATA[$t]]></code>
+2
View File
@@ -36,6 +36,8 @@ export default {
tick: () => call('post', '/tick'),
setSchedule: (time, timezone) => call('put', '/schedule', { time, timezone }),
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
setSnapshotNote: (targetId, snapshotId, note) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/note`, { note }),
setSnapshotPinned: (targetId, snapshotId, pinned) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/pin`, { pinned }),
browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }),
startRestore: (targetId, snapshotId, path, mode) => call('post', '/restores', { targetId, snapshotId, path, mode }),
}
+1 -1
View File
@@ -37,7 +37,7 @@
<td>{{ formatDateTime(run.startedAt) }}</td>
<td>{{ targetName(run.targetId) }}</td>
<td>{{ run.kind }}</td>
<td>{{ run.snapshot ? formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
<td>{{ run.snapshot ? (run.snapshot.pinned ? '📌 ' : '') + formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') + (run.snapshot.note ? ' – ' + run.snapshot.note : '') : '' }}</td>
<td :class="'ngb-' + run.status">{{ runStatus(run) }}{{ run.error ? ': ' + run.error : '' }}</td>
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
<td>{{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }}</td>
+37 -2
View File
@@ -11,6 +11,13 @@
</div>
<template v-if="snapshot">
<div class="ngb-row ngb-marks">
<NcTextField v-model="note" class="ngb-note" :maxlength="500" :label="t('ng_backup', 'Note (can be changed later)')" />
<NcButton :disabled="marking || note.trim() === (snapshot.note ?? '')" @click="saveNote">{{ t('ng_backup', 'Save note') }}</NcButton>
<NcCheckboxRadioSwitch :model-value="snapshot.pinned" :disabled="marking" @update:model-value="savePinned">
{{ t('ng_backup', 'Pin: the retention policy does not remove this restore point') }}
</NcCheckboxRadioSwitch>
</div>
<div class="ngb-crumbs">
<NcButton variant="tertiary" @click="open('data')">data</NcButton>
<template v-for="(crumb, i) in crumbs" :key="crumb.path">
@@ -49,6 +56,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { confirmPassword } from '@nextcloud/password-confirmation'
import '@nextcloud/password-confirmation/style.css'
import api from '../api.js'
@@ -74,7 +82,10 @@ const modes = [
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
]
const snapshotLabel = (s) => formatDateTime(s.createdAt) + (s.label ? ' – ' + s.label : '')
const note = ref('')
const marking = ref(false)
const snapshotLabel = (s) => (s.pinned ? '📌 ' : '') + formatDateTime(s.createdAt) + (s.label ? ' – ' + s.label : '') + (s.note ? ' – ' + s.note : '')
const join = (name) => path.value + '/' + name
const restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
const crumbs = computed(() => {
@@ -88,7 +99,29 @@ watch(target, async (tg) => {
snapshots.value = list.map(s => ({ ...s, displayLabel: snapshotLabel(s) }))
snapshot.value = snapshots.value[0] ?? null
}, { immediate: true })
watch(snapshot, () => { selected.value = ''; open('data') })
watch(snapshot, (s) => { selected.value = ''; note.value = s?.note ?? ''; open('data') })
// Changes the snapshot in place: choosing another one, which opens its folders again, is not needed
async function mark(call, change) {
const s = snapshot.value
marking.value = true
error.value = message.value = ''
try {
await call(target.value.id, s.id)
change(s)
s.displayLabel = snapshotLabel(s)
emit('changed')
} catch (e) {
error.value = e.message
} finally {
marking.value = false
}
}
const saveNote = () => {
const text = note.value.trim()
return mark((targetId, id) => api.setSnapshotNote(targetId, id, text), (s) => { s.note = text })
}
const savePinned = (pinned) => mark((targetId, id) => api.setSnapshotPinned(targetId, id, pinned), (s) => { s.pinned = pinned })
async function open(p) {
if (!snapshot.value) {
@@ -125,6 +158,8 @@ async function restore() {
<style scoped>
.ngb-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.ngb-row :deep(.ngb-snapshot) { min-width: min(360px, 100%); }
.ngb-marks { align-items: flex-end; }
.ngb-note { max-width: 360px; }
.ngb-crumbs { display: flex; align-items: center; flex-wrap: wrap; }
.ngb-entries li { display: flex; align-items: center; gap: 12px; min-height: 36px; }
.ngb-entries li.selected { background: var(--color-primary-element-light); border-radius: var(--border-radius); }
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Repository;
use OCA\NgBackup\Backend\LocalBackend;
use OCA\NgBackup\Crypto\KeyRing;
use OCA\NgBackup\Repository\Repository;
use OCA\NgBackup\Tests\Unit\TempDirTrait;
use PHPUnit\Framework\TestCase;
/** A pin and a note on a snapshot live in the catalog on the location, so a new server sees them too. */
class SnapshotMarksTest extends TestCase {
use TempDirTrait;
private LocalBackend $backend;
private KeyRing $keys;
private string $snapshot;
protected function setUp(): void {
$this->backend = new LocalBackend($this->tempDir());
$this->keys = KeyRing::generate();
$src = $this->tempDir();
file_put_contents("$src/a", 'x');
$this->snapshot = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'))->backupDirectory($src)['snapshot'];
}
private function repo(): Repository {
return Repository::openWithKey($this->backend, $this->keys);
}
public function testTheyAreThereAfterOpeningAgain(): void {
$this->repo()->setPinned($this->snapshot, true);
$this->repo()->setSnapshotNote($this->snapshot, 'before the upgrade');
$this->assertSame([$this->snapshot], $this->repo()->pinnedSnapshots());
$this->assertSame([$this->snapshot => 'before the upgrade'], $this->repo()->snapshotNotes());
}
public function testTheyCanBeTakenOffAgain(): void {
$this->repo()->setPinned($this->snapshot, true);
$this->repo()->setSnapshotNote($this->snapshot, 'x');
$this->repo()->setPinned($this->snapshot, false);
$this->repo()->setSnapshotNote($this->snapshot, '');
$this->assertSame([], $this->repo()->pinnedSnapshots());
$this->assertSame([], $this->repo()->snapshotNotes());
}
public function testNoChangeWritesNoCatalog(): void {
$repo = $this->repo();
$repo->setPinned($this->snapshot, true);
$generation = $repo->catalogGeneration();
$repo->setPinned($this->snapshot, true);
$repo->setSnapshotNote($this->snapshot, '');
$this->assertSame($generation, $repo->catalogGeneration());
}
public function testASnapshotThatIsNotThereIsRefused(): void {
$this->expectException(\InvalidArgumentException::class);
$this->repo()->setPinned('0123456789abcdef0123456789abcdef', true);
}
public function testATrashedSnapshotLosesItsPinAndKeepsItsNote(): void {
$repo = $this->repo();
$repo->setPinned($this->snapshot, true);
$repo->setSnapshotNote($this->snapshot, 'kept');
$repo->forget($this->snapshot);
$this->assertSame([], $this->repo()->pinnedSnapshots());
$this->assertSame([$this->snapshot => 'kept'], $this->repo()->snapshotNotes());
$repo = $this->repo();
$repo->untrash($this->snapshot);
$this->assertSame([], $this->repo()->pinnedSnapshots(), 'back in the list, as a normal one');
$this->assertSame([$this->snapshot => 'kept'], $this->repo()->snapshotNotes());
}
public function testThePurgeTakesTheNoteAlong(): void {
$repo = $this->repo();
$repo->setSnapshotNote($this->snapshot, 'kept');
$repo->forget($this->snapshot);
$repo->purgeTrash(0);
$this->assertSame([], $this->repo()->snapshotNotes());
}
public function testACatalogFromBeforeThisVersionReadsAsNone(): void {
$this->assertSame([], $this->repo()->pinnedSnapshots());
$this->assertSame([], $this->repo()->snapshotNotes());
}
}
@@ -25,6 +25,17 @@ class RetentionPolicyTest extends TestCase {
$this->assertCount(3, $months);
}
public function testAPinnedSnapshotStaysAndDoesNotCount(): void {
$policy = new RetentionPolicy(1, 0, 0, 0);
$snaps = ['old' => 100, 'pinned' => 300, 'new' => 200];
$this->assertSame(['pinned'], array_keys($policy->keep($snaps)), 'without the pin it would take the place of "last"');
$keep = $policy->keepWithPinned($snaps, ['pinned', 'gone-already']);
$this->assertSame(['pinned'], $keep['pinned']);
$this->assertSame(['last'], $keep['new'], 'the newest one that is not pinned');
$this->assertArrayNotHasKey('old', $keep);
$this->assertArrayNotHasKey('gone-already', $keep);
}
public function testKeepsEverythingWhenFewSnapshots(): void {
$keep = (new RetentionPolicy())->keep(['a' => 100, 'b' => 200]);
$this->assertSame(['a', 'b'], array_values(array_intersect(['a', 'b'], array_keys($keep))));
+11
View File
@@ -120,6 +120,17 @@ ok "database dump and restore"
# Retention, trash and catalog.
occ backup:retention --last=1 --daily=0 --weekly=0 --monthly=0 >/dev/null
# A pinned restore point stays, and a note can change afterwards.
FIRST=$(occ backup:list ci | awk -F'|' '!f && / first /{gsub(/ /,"",$3); print $3; f=1}')
occ backup:pin ci "$FIRST" >/dev/null || fail "pin"
occ backup:note ci "$FIRST" "a note made later" >/dev/null || fail "note"
occ backup:list ci | grep " first " | grep " yes " | grep "a note made later" >/dev/null || fail "pin and note not listed"
occ backup:prune ci --dry-run | grep "would forget 1 " >/dev/null || fail "a pinned snapshot would be removed"
occ backup:pin ci "$FIRST" --unpin >/dev/null || fail "unpin"
occ backup:prune ci --dry-run | grep "would forget 2 " >/dev/null || fail "an unpinned snapshot would stay"
occ backup:note ci "$FIRST" >/dev/null || fail "remove note"
occ backup:list ci | grep -q "a note made later" && fail "note not removed"
ok "pin and note on a restore point"
occ backup:prune ci | grep "forgot 2" >/dev/null || fail "prune" # first and second; "page" (above) is the last
occ backup:trash ci | grep " first " >/dev/null || fail "trash"
TID=$(occ backup:trash ci | awk -F'|' '!f && / first /{gsub(/ /,"",$3); print $3; f=1}')