diff --git a/containers/libreportal/frontend/components/apps/core/css/apps-layout.css b/containers/libreportal/frontend/components/apps/core/css/apps-layout.css
index 6a6df64..fff6cbf 100644
--- a/containers/libreportal/frontend/components/apps/core/css/apps-layout.css
+++ b/containers/libreportal/frontend/components/apps/core/css/apps-layout.css
@@ -147,3 +147,12 @@
color: rgba(var(--text-rgb), 0.55);
font-size: 13px;
}
+/* Bulk-delete controls on the app Tasks tab — same Clear All / Delete
+ Selected button + master tick the /tasks status bar carries (their
+ styles come from tasks.css, which loads globally). */
+.task-filter-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+}
diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css
index b0f49f0..33f2625 100644
--- a/containers/libreportal/frontend/components/apps/core/css/apps.css
+++ b/containers/libreportal/frontend/components/apps/core/css/apps.css
@@ -846,14 +846,7 @@
.ov-pick-empty { visibility: hidden; }
.ov-pick-box { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent, #29b6f6); }
-/* The bar holds its space even at zero selected: appearing on first tick would
- shift the whole list down under the pointer mid-click. */
-.ov-selbar {
- display: flex; align-items: center; gap: 10px;
- padding: 8px 12px; margin: 0 0 10px;
- border: 1px dashed rgba(var(--text-rgb), 0.18); border-radius: 10px;
- opacity: 0.55; transition: opacity 120ms ease, border-color 120ms ease;
-}
-.ov-selbar.is-on { opacity: 1; border-style: solid; border-color: rgba(var(--text-rgb), 0.3); }
-.ov-selbar-count { font-size: 13px; color: var(--text-secondary); }
-.ov-selbar .updater-btn[disabled] { opacity: 0.45; cursor: not-allowed; }
+/* Selection actions live in the tab header's action slot (#ov-updates-actions
+ inside .ov-tab-header-actions), morphing "Update all" ⇄ "Update Selected
+ (N)" — no separate bar between toolbar and list. */
+#ov-updates-actions { display: inline-flex; align-items: center; gap: 8px; }
diff --git a/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js b/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js
index 41c4fd5..a917c88 100755
--- a/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js
+++ b/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js
@@ -372,6 +372,20 @@ class AppTabbedManager {
${chips.join('')}
+
+
+
+
+
+
+ Clear All
+
+
+
+
+ Select all
+
+
`;
}
@@ -396,6 +410,10 @@ class AppTabbedManager {
? shown.map(t => this.tasksManager.renderTask(t)).join('')
: `No tasks match${q ? ` “${escapeAttr(q)}”` : ''}${status === 'all' ? '' : ` in ${escapeAttr(status)}`}.
`;
this.tasksManager.setupGlobalFunctions();
+ // The filter can hide rows that stay ticked in the set (renderTask
+ // re-checks them when they come back) — keep the master checkbox and
+ // Delete Selected count honest for what's on screen.
+ if (typeof this.tasksManager._updateSelectionUI === 'function') this.tasksManager._updateSelectionUI();
};
bar.querySelectorAll('.task-filter-chip').forEach(chip => {
@@ -484,11 +502,20 @@ class AppTabbedManager {
// Render app-specific tasks, with a filter bar above them. Kept
// client-side over the already-loaded array: the list is per-app and
// small, so filtering is instant and needs no reload.
+ //
+ // Drop any selection carried over from /tasks (or a previous app):
+ // ticks made elsewhere would silently widen this page's Delete
+ // Selected beyond what the user can see here.
this._appTasks = appTasks;
+ // Clear the page-wide set (window.tasksManager's — the one the row
+ // checkboxes write through), not just this instance's copy.
+ if (window.tasksManager) window.tasksManager.selectedTaskIds.clear();
+ this.tasksManager.selectedTaskIds.clear();
tasksContainer.innerHTML =
this._renderTaskFilterBar(appTasks) +
`${appTasks.map(t => this.tasksManager.renderTask(t)).join('')}
`;
this._wireTaskFilterBar();
+ if (typeof this.tasksManager._updateSelectionUI === 'function') this.tasksManager._updateSelectionUI();
// Setup app-specific task interactions (separate from main tasks system)
this.setupAppTaskFunctions();
diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
index 2cd460d..29d0169 100644
--- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
+++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
@@ -193,9 +193,12 @@ class OverviewManager {
.map((d) => d.id.replace(/^ov-detail-/, ''));
const anyUpdate = !!(this.updater && this.updater.apps.some((a) => a.update_available));
// No manual "Check" in the header — the auto-check line at the top of the
- // body carries the (secondary) Check-now nudge. Only the real "Update all"
- // action lives up here, and only when there's actually something to apply.
- const actions = anyUpdate ? `Update all ` : '';
+ // body carries the (secondary) Check-now nudge. Only the real update
+ // action lives up here, and only when there's actually something to
+ // apply. The span is a stable mount so _syncSelectionBar can morph
+ // "Update all" ⇄ "Update Selected (N)" in place as rows get ticked —
+ // same dynamic-button layout as the /tasks Clear All / Delete Selected.
+ const actions = anyUpdate ? `${this._updatesHeaderActions()} ` : '';
pane.innerHTML = this.renderHeader(id, actions) + body(this.renderUpdates());
open.forEach((app) => this._openDetail(app));
this._honorAppDeepLink();
@@ -349,6 +352,11 @@ class OverviewManager {
case 'update-selected':
if (this.updater) this.updater.applySelected([...this.selected]);
this.selected.clear();
+ // Untick in place and flip the header back to "Update all" — the
+ // rows themselves repaint on the updater's own task events.
+ document.querySelectorAll('#overview-view .ov-pick-box').forEach((b) => { b.checked = false; });
+ document.querySelectorAll('#overview-view [data-overview-action="select-all"]').forEach((b) => { b.checked = false; });
+ this._syncSelectionBar();
return;
case 'goto':
// Whole-row navigation is a convenience over the row's own button —
@@ -630,26 +638,25 @@ class OverviewManager {
return avail.length > 0 && avail.every((n) => this.selected.has(n));
}
- // Update the bar in place. Re-rendering the whole tab on every tick would
- // rebuild the checkboxes underneath the pointer and lose focus mid-selection.
- _syncSelectionBar() {
- const bar = document.querySelector('.ov-selbar');
- if (!bar) return;
+ // The tab header's action slot, driven by the selection: no ticks →
+ // "Update all"; any ticks → "Update Selected (N)" + Clear. One dynamic
+ // button atop the list, matching the /tasks page's Clear All ⇄ Delete
+ // Selected morph, instead of a second bar between toolbar and rows.
+ _updatesHeaderActions() {
const n = this._liveSelection().length;
- bar.classList.toggle('is-on', n > 0);
- const label = bar.querySelector('.ov-selbar-count');
- if (label) label.textContent = n === 1 ? '1 app selected' : `${n} apps selected`;
- const btn = bar.querySelector('[data-overview-action="update-selected"]');
- if (btn) btn.disabled = n === 0;
+ if (n > 0) {
+ return `Update Selected (${n})
+ Clear `;
+ }
+ return `Update all `;
}
- renderSelectionBar() {
- const n = this._liveSelection().length;
- return `
- ${n === 1 ? '1 app selected' : `${n} apps selected`}
- Update selected
- Clear
-
`;
+ // Update the header button in place. Re-rendering the whole tab on every
+ // tick would rebuild the checkboxes underneath the pointer and lose focus
+ // mid-selection.
+ _syncSelectionBar() {
+ const mount = document.getElementById('ov-updates-actions');
+ if (mount) mount.innerHTML = this._updatesHeaderActions();
}
renderUpdates() {
@@ -684,7 +691,6 @@ class OverviewManager {
Select all
` : ''}
- ${nUpd ? this.renderSelectionBar() : ''}
${rows}
`;
}
diff --git a/containers/libreportal/frontend/components/backup/core/css/backup.css b/containers/libreportal/frontend/components/backup/core/css/backup.css
index 7dffcdb..295094c 100755
--- a/containers/libreportal/frontend/components/backup/core/css/backup.css
+++ b/containers/libreportal/frontend/components/backup/core/css/backup.css
@@ -1332,6 +1332,17 @@
margin-top: 14px;
}
+/* Bulk-delete controls atop the app card's snapshot list — the Delete All /
+ Delete Selected morphing button + master tick, right-aligned like the
+ /tasks status bar (their styles come from tasks.css, loaded globally). */
+.backup-snapshot-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+ margin-top: 12px;
+}
+
/* Inside the panel the top inset comes from the panel padding, so drop the
list's own top margin to keep the box's internal rhythm even. */
.backup-snapshot-panel .backup-snapshot-rows {
diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-actions.js b/containers/libreportal/frontend/components/tasks/js/tasks-actions.js
index eae82bd..5ceda3f 100644
--- a/containers/libreportal/frontend/components/tasks/js/tasks-actions.js
+++ b/containers/libreportal/frontend/components/tasks/js/tasks-actions.js
@@ -118,17 +118,27 @@ Object.assign(TasksManager.prototype, {
// Routes to one of two modes depending on whether any rows are ticked.
// Both paths share _showClearAllModal — same UX, same modal, different
// input list + title.
- const hasSelection = this.selectedTaskIds.size > 0;
+ //
+ // On an app detail page (#app-tasks-list present, no #tasks-list) the
+ // universe is that app's tasks only — "Clear All" there must never
+ // reach across every app the way it does on /tasks.
+ const appScope = this._selectionAppScope();
+ const universe = appScope ? this.tasks.filter(t => t.app === appScope) : this.tasks;
+ const sel = this._selectionSet();
+ const hasSelection = sel.size > 0;
const targetTasks = hasSelection
- ? this.tasks.filter(t => this.selectedTaskIds.has(t.id))
- : this.tasks;
+ ? universe.filter(t => sel.has(t.id))
+ : universe;
const result = await this._showClearAllModal(targetTasks, hasSelection ? 'selected' : 'all');
if (!result || !result.confirmed) return false;
await this.performClearAll({ cancelRunning: result.cancelRunning, targets: targetTasks });
if (hasSelection) {
- // Drop any ids we just deleted from the selection set, then refresh
- // the button label + master-checkbox state.
- this.selectedTaskIds = new Set([...this.selectedTaskIds].filter(id => this.tasks.find(t => t.id === id)));
+ // Drop any ids we just deleted from the selection set (mutating in
+ // place — the set is shared across instances), then refresh the
+ // button label + master-checkbox state.
+ const alive = [...sel].filter(id => this.tasks.find(t => t.id === id));
+ sel.clear();
+ alive.forEach(id => sel.add(id));
this._updateSelectionUI();
}
return true;
@@ -184,6 +194,11 @@ Object.assign(TasksManager.prototype, {
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
+ // renderTasks no-ops on an app page (#tasks-list absent) — rebuild the
+ // per-app list there so the deleted rows actually leave the screen.
+ if (this._selectionAppScope() && window.appTabbedManager && typeof window.appTabbedManager.loadAppTasks === 'function') {
+ try { await window.appTabbedManager.loadAppTasks(); } catch {}
+ }
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
@@ -214,25 +229,52 @@ Object.assign(TasksManager.prototype, {
}
}
},
- // Selection helpers wired by the row + master checkboxes.
+ // Selection helpers wired by the row + master checkboxes. The same
+ // machinery serves two hosts: the global /tasks page (#tasks-list,
+ // #tasks-clear-btn, #tasks-select-all) and an app detail page's Tasks tab
+ // (#app-tasks-list, #app-tasks-clear-btn, #app-tasks-select-all) — the
+ // resolvers below pick whichever host is actually in the DOM.
+ //
+ // Selection state must be ONE set page-wide. TasksManager is constructed
+ // in several places (system-loader, app-tabbed-manager, …) while the row
+ // checkboxes always write through window.tasksManager — so every reader
+ // and writer resolves to that instance's set. Without this, ticks landed
+ // on one instance and the Delete Selected click read another's empty set,
+ // silently widening the delete to the whole (scoped) list.
+ _selectionSet() {
+ return (window.tasksManager && window.tasksManager.selectedTaskIds) || this.selectedTaskIds;
+ },
+ _selectionListEl() {
+ return document.getElementById('tasks-list') || document.getElementById('app-tasks-list');
+ },
+ // Truthy (the app slug) only when the ACTIVE selection host is an app
+ // page's task list — used to scope Clear All to that app's tasks.
+ _selectionAppScope() {
+ if (document.getElementById('tasks-list')) return null;
+ if (!document.getElementById('app-tasks-list')) return null;
+ return (window.appTabbedManager && window.appTabbedManager.currentApp) || null;
+ },
toggleTaskSelection(taskId, checked) {
- if (checked) this.selectedTaskIds.add(taskId);
- else this.selectedTaskIds.delete(taskId);
+ const sel = this._selectionSet();
+ if (checked) sel.add(taskId);
+ else sel.delete(taskId);
this._updateSelectionUI();
},
toggleSelectAll(checked) {
+ const list = this._selectionListEl();
+ const sel = this._selectionSet();
if (checked) {
// Tick every CURRENTLY VISIBLE row. We use the rendered checkboxes
// rather than this.tasks so category-filtered views only select
// what the user can see.
- const boxes = document.querySelectorAll('#tasks-list [data-task-select]');
+ const boxes = list ? list.querySelectorAll('[data-task-select]') : [];
boxes.forEach((cb) => {
- this.selectedTaskIds.add(cb.dataset.taskSelect);
+ sel.add(cb.dataset.taskSelect);
cb.checked = true;
});
} else {
- this.selectedTaskIds.clear();
- document.querySelectorAll('#tasks-list [data-task-select]').forEach((cb) => { cb.checked = false; });
+ sel.clear();
+ if (list) list.querySelectorAll('[data-task-select]').forEach((cb) => { cb.checked = false; });
}
this._updateSelectionUI();
},
@@ -240,16 +282,17 @@ Object.assign(TasksManager.prototype, {
// state to reflect the current selection. Cheap — only touches a few
// DOM nodes, safe to call from any selection-change path.
_updateSelectionUI() {
- const n = this.selectedTaskIds.size;
- const btn = document.getElementById('tasks-clear-btn');
+ const n = this._selectionSet().size;
+ const btn = document.getElementById('tasks-clear-btn') || document.getElementById('app-tasks-clear-btn');
const btnLabel = btn && btn.querySelector('.clear-btn-label');
if (btnLabel) btnLabel.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All';
if (btn) btn.title = n > 0 ? `Delete ${n} selected task${n === 1 ? '' : 's'}` : 'Clear All Tasks';
// Master checkbox: checked when ALL visible are picked, indeterminate
// when SOME are, unchecked when none.
- const master = document.getElementById('tasks-select-all');
- const visible = document.querySelectorAll('#tasks-list [data-task-select]');
+ const master = document.getElementById('tasks-select-all') || document.getElementById('app-tasks-select-all');
+ const list = this._selectionListEl();
+ const visible = list ? list.querySelectorAll('[data-task-select]') : [];
if (master) {
if (n === 0 || visible.length === 0) {
master.checked = false;
diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-format.js b/containers/libreportal/frontend/components/tasks/js/tasks-format.js
index 643289c..cbc7c88 100644
--- a/containers/libreportal/frontend/components/tasks/js/tasks-format.js
+++ b/containers/libreportal/frontend/components/tasks/js/tasks-format.js
@@ -96,6 +96,10 @@ Object.assign(TasksManager.prototype, {
{ match: /^libreportal backup app schedule (\w+)/, title: (m) => `${displayName(m[1])} - Scheduled Backup` },
{ match: /^libreportal backup app list (\w+)/, title: (m) => `${displayName(m[1])} - List Backups` },
{ match: /^libreportal backup app delete_all (\w+)/, title: (m) => `${displayName(m[1])} - Delete All Backups` },
+ // Bulk form first (comma-separated : pairs from the app
+ // card's Delete Selected) — the single-delete row below would match
+ // the same command and mislabel it.
+ { match: /^libreportal backup app delete (\w+) (\S*,\S*)/, title: (m) => `${displayName(m[1])} - Delete ${m[2].split(',').filter(Boolean).length} Backups` },
{ match: /^libreportal backup app delete (\w+)/, title: (m) => `${displayName(m[1])} - Delete Backup` },
// -- Backup: system / locations ----------------------------------------
diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js
index e8cabb1..02e7dc8 100644
--- a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js
+++ b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js
@@ -213,7 +213,7 @@ Object.assign(TasksManager.prototype, {
Delete
-
diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-manager.js b/containers/libreportal/frontend/components/tasks/js/tasks-manager.js
index a8d1b90..f06a8db 100755
--- a/containers/libreportal/frontend/components/tasks/js/tasks-manager.js
+++ b/containers/libreportal/frontend/components/tasks/js/tasks-manager.js
@@ -187,6 +187,13 @@ class TasksManager {
// state — a constructor-only read goes stale after the first visit.
this.initializeFromURL();
+ // Fresh visit, fresh selection. Ticks survive category switches within
+ // the page on purpose, but ones carried over from an app page's Tasks
+ // tab would widen a bulk delete beyond what this page showed being
+ // ticked. Clear the shared page-wide set too (see _selectionSet).
+ if (window.tasksManager && window.tasksManager !== this) window.tasksManager.selectedTaskIds.clear();
+ this.selectedTaskIds.clear();
+
// Load initial tasks and refresh sidebar counts
await this.loadTasks();
diff --git a/containers/libreportal/frontend/core/backup-card/js/backup-app-card.js b/containers/libreportal/frontend/core/backup-card/js/backup-app-card.js
index 2b5f3b6..0503ab1 100644
--- a/containers/libreportal/frontend/core/backup-card/js/backup-app-card.js
+++ b/containers/libreportal/frontend/core/backup-card/js/backup-app-card.js
@@ -13,6 +13,8 @@ class BackupAppCard {
this.snapshotsByLoc = {};
this.locationsByIdx = {};
this.appStatus = null;
+ // Bulk-delete selection: "locIdx:snapshotId" keys of ticked rows.
+ this.selectedSnaps = new Set();
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this.bindDelegated();
}
@@ -37,6 +39,18 @@ class BackupAppCard {
return;
}
+ const deleteBtn = e.target.closest('[data-action="delete-app-snapshot"]');
+ if (deleteBtn) {
+ e.stopPropagation();
+ card.deleteSnapshot(deleteBtn.dataset.loc, deleteBtn.dataset.snapshot);
+ return;
+ }
+
+ if (e.target.closest('#backup-snaps-delete-btn')) {
+ card.deleteSelected();
+ return;
+ }
+
// Header click → toggle the detail panel. Mirrors the
// .task-header click target Services uses.
const header = e.target.closest('.backup-snapshot-item .task-header');
@@ -48,6 +62,35 @@ class BackupAppCard {
}
}
});
+
+ // Selection checkboxes (per-row + master). Change, not click — the
+ // visible box is a styled sibling of a hidden input, so click targets
+ // vary; change always fires on the input itself.
+ document.addEventListener('change', (e) => {
+ const card = window.backupAppCard;
+ if (!card) return;
+ const row = e.target.closest && e.target.closest('[data-snap-select]');
+ if (row || (e.target.matches && e.target.matches('[data-snap-select]'))) {
+ const cb = row || e.target;
+ card.toggleSnapSelection(cb.dataset.snapSelect, cb.checked);
+ return;
+ }
+ if (e.target.id === 'backup-snaps-select-all') {
+ card.toggleSelectAllSnaps(e.target.checked);
+ }
+ });
+
+ // A finished backup/delete/restore task for this app means the
+ // snapshot list on screen is stale — repaint from the regenerated
+ // JSON. render() no-ops when the Backups tab isn't mounted.
+ window.addEventListener('taskCompleted', (e) => {
+ const card = window.backupAppCard;
+ if (!card) return;
+ const t = e.detail && e.detail.task;
+ if (!t || t.app !== card.appName) return;
+ if (!/^libreportal (backup app (create|delete|delete_all)|restore app start)\b/.test(t.command || '')) return;
+ setTimeout(() => card.render(), 800);
+ });
}
async render() {
@@ -76,7 +119,24 @@ class BackupAppCard {
`;
const iconUrl = `/core/icons/apps/${encodeURIComponent(this.appName)}.svg`;
+ // Fresh data, fresh selection — ticks made against the previous list
+ // could name snapshots that no longer exist.
+ this.selectedSnaps.clear();
snapsEl.innerHTML = `
+
${allSnaps.slice(0, 50).map(s => this._renderRow(s, iconUrl)).join('')}
@@ -118,6 +178,17 @@ class BackupAppCard {
Details
+
+
+
+
+
+ Delete
+
+
+
+
+
@@ -246,6 +317,77 @@ class BackupAppCard {
await this.taskManager.createTask(`libreportal restore app start ${this.appName} ${snapshot} ${locIdx}`, 'restore', this.appName);
}
+ // ---- Deletion (single row, ticked selection, or everything) ----------
+
+ toggleSnapSelection(key, checked) {
+ if (!key) return;
+ if (checked) this.selectedSnaps.add(key);
+ else this.selectedSnaps.delete(key);
+ this._updateSnapSelectionUI();
+ }
+
+ toggleSelectAllSnaps(checked) {
+ const boxes = document.querySelectorAll('#backup-app-card-snapshots [data-snap-select]');
+ if (checked) {
+ boxes.forEach(cb => { this.selectedSnaps.add(cb.dataset.snapSelect); cb.checked = true; });
+ } else {
+ this.selectedSnaps.clear();
+ boxes.forEach(cb => { cb.checked = false; });
+ }
+ this._updateSnapSelectionUI();
+ }
+
+ // Same morph as the /tasks action bar: "Delete All" with nothing ticked,
+ // "Delete Selected (N)" once rows are.
+ _updateSnapSelectionUI() {
+ const n = this.selectedSnaps.size;
+ const btn = document.getElementById('backup-snaps-delete-btn');
+ const label = btn && btn.querySelector('.clear-btn-label');
+ if (label) label.textContent = n > 0 ? `Delete Selected (${n})` : 'Delete All';
+ if (btn) btn.title = n > 0 ? `Delete ${n} selected backup${n === 1 ? '' : 's'}` : 'Delete All Backups';
+ const master = document.getElementById('backup-snaps-select-all');
+ const visible = document.querySelectorAll('#backup-app-card-snapshots [data-snap-select]');
+ if (master) {
+ if (n === 0 || visible.length === 0) { master.checked = false; master.indeterminate = false; }
+ else if (n >= visible.length) { master.checked = true; master.indeterminate = false; }
+ else { master.checked = false; master.indeterminate = true; }
+ }
+ }
+
+ async deleteSnapshot(locIdx, snapshot) {
+ const locName = this.locationsByIdx[locIdx]?.name || `Location ${locIdx}`;
+ if (!confirm(`Delete backup ${snapshot} for ${this.appName} from ${locName}?\n\nThis cannot be undone. Append-only locations will reject the operation.`)) return;
+ await this._queueDelete([`${locIdx}:${snapshot}`]);
+ }
+
+ async deleteSelected() {
+ if (this.selectedSnaps.size === 0) {
+ await this.deleteAllBackups();
+ return;
+ }
+ const pairs = [...this.selectedSnaps];
+ if (!confirm(`Delete ${pairs.length} selected backup${pairs.length === 1 ? '' : 's'} for ${this.appName}?\n\nThis cannot be undone. Append-only locations will reject the operation.`)) return;
+ await this._queueDelete(pairs);
+ }
+
+ async deleteAllBackups() {
+ if (!confirm(`Delete ALL backups for ${this.appName}, on every location?\n\nEvery snapshot of this app will be gone for good. Append-only locations are skipped.`)) return;
+ if (!this.taskManager) return;
+ await this.taskManager.createTask(`libreportal backup app delete_all ${this.appName}`, 'backup', this.appName);
+ if (window.notificationSystem) window.notificationSystem.show(`Deleting all backups for ${this.appName}…`, 'info');
+ }
+
+ // The whole selection rides in ONE task ("delete
1:a,2:b,…") — the
+ // backup surfaces hold one task per subject at a time, so N queued
+ // single-snapshot tasks would be refused after the first anyway.
+ async _queueDelete(pairs) {
+ if (!this.taskManager || !pairs.length) return;
+ await this.taskManager.createTask(`libreportal backup app delete ${this.appName} ${pairs.join(',')}`, 'backup', this.appName);
+ this.selectedSnaps.clear();
+ this._updateSnapSelectionUI();
+ if (window.notificationSystem) window.notificationSystem.show(`Deleting ${pairs.length} backup${pairs.length === 1 ? '' : 's'} for ${this.appName}…`, 'info');
+ }
+
escape(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
diff --git a/scripts/cli/commands/backup/cli_backup_commands.sh b/scripts/cli/commands/backup/cli_backup_commands.sh
index 3c5afba..072c027 100755
--- a/scripts/cli/commands/backup/cli_backup_commands.sh
+++ b/scripts/cli/commands/backup/cli_backup_commands.sh
@@ -32,13 +32,50 @@ cliHandleBackupCommands()
delete)
[[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; }
[[ -z "$extra" ]] && { isNotice "No : provided (e.g. 1:abc123)."; cliShowBackupHelp; return; }
- local idx="${extra%%:*}"
- local snap="${extra##*:}"
- backupAppDeleteSnapshot "$idx" "$snap"
+ # One or many: :[,:…]. The WebUI's
+ # bulk delete sends the whole selection as ONE task — the
+ # backup surfaces allow one task per subject at a time, so
+ # N queued single-snapshot tasks would be refused after
+ # the first.
+ local _del_pair _del_idx _del_snap _del_ok=0 _del_failed=0
+ local _del_pairs
+ IFS=',' read -ra _del_pairs <<< "$extra"
+ for _del_pair in "${_del_pairs[@]}"; do
+ [[ -z "$_del_pair" ]] && continue
+ _del_idx="${_del_pair%%:*}"
+ _del_snap="${_del_pair##*:}"
+ if backupAppDeleteSnapshot "$_del_idx" "$_del_snap"; then
+ _del_ok=$((_del_ok + 1))
+ else
+ _del_failed=$((_del_failed + 1))
+ fi
+ done
+ # The generated JSON is what the WebUI renders — without a
+ # regen the deleted snapshots keep showing until the next
+ # backup rewrites it.
+ if [[ "$CFG_REQUIREMENT_WEBUI" == "true" && $_del_ok -gt 0 ]]; then
+ webuiGenerateBackupDashboard
+ webuiGenerateBackupSnapshots all
+ webuiGenerateBackupAppStatus "$name"
+ fi
+ if [[ $_del_failed -gt 0 ]]; then
+ isError "Deleted $_del_ok snapshot(s); $_del_failed failed"
+ false
+ elif [[ $_del_ok -gt 1 ]]; then
+ isSuccessful "Deleted $_del_ok snapshots for $name"
+ fi
;;
delete_all)
[[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; }
backupAppDeleteAll "$name"
+ # Same staleness rule as `delete`: the WebUI renders the
+ # generated JSON, so regenerate it now that the snapshots
+ # are gone.
+ if [[ "$CFG_REQUIREMENT_WEBUI" == "true" ]]; then
+ webuiGenerateBackupDashboard
+ webuiGenerateBackupSnapshots all
+ webuiGenerateBackupAppStatus "$name"
+ fi
;;
list)
[[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; }