mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[ui] #8: one-click "Delete all" for orphaned storage objects in My Videos
The My Videos tab already lists bucket objects no live note references and offers
per-file delete; this adds the bulk action. A "Delete all (N)" button (shown only when
there's more than one orphan) confirms once, then removes every listed key in a single
deleteStorageKeys({ keys, pubkey }) call — which is already array-capable, unlock-gated,
and scoped to the user's prefix. Refreshes the list on success, reports partial results
honestly ("Removed 2 files (1 could not be deleted)"), and surfaces storage-locked as an
unlock prompt instead of a false success.
Tests: tests/my-videos-bulk-orphan-delete.test.mjs (5 — all keys sent in one call,
cancel deletes nothing, storage-locked prompt, partial-count message, empty no-op).
Build + lint clean; My Videos suite 37/37.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
a673ee96aa
commit
1144bde881
4 files changed
+190
-5
No files matched your search
@@ -1061,9 +1061,18 @@
|
||||
data-orientation="vertical"
|
||||
></ul>
|
||||
<div id="profileMyVideosOrphans" class="space-y-2 hidden">
|
||||
<h4 class="text-sm font-semibold text-text">
|
||||
Orphaned storage objects
|
||||
</h4>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-sm font-semibold text-text">
|
||||
Orphaned storage objects
|
||||
</h4>
|
||||
<button
|
||||
id="profileMyVideosOrphanDeleteAll"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-xs text-status-danger"
|
||||
>
|
||||
Delete all
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-2xs text-muted">
|
||||
Files in your bucket that no current video references — safe to
|
||||
remove to reclaim space.
|
||||
|
||||
@@ -50,6 +50,8 @@ export class MyVideosController {
|
||||
this.refreshBtn = null;
|
||||
this.orphansSection = null;
|
||||
this.orphanListEl = null;
|
||||
this.orphanDeleteAllBtn = null;
|
||||
this.orphanKeys = [];
|
||||
this.autoShareRow = null;
|
||||
this.autoShareInput = null;
|
||||
this.loading = false;
|
||||
@@ -67,6 +69,8 @@ export class MyVideosController {
|
||||
document.getElementById("profileMyVideosOrphans") || null;
|
||||
this.orphanListEl =
|
||||
document.getElementById("profileMyVideosOrphanList") || null;
|
||||
this.orphanDeleteAllBtn =
|
||||
document.getElementById("profileMyVideosOrphanDeleteAll") || null;
|
||||
this.autoShareRow =
|
||||
document.getElementById("profileMyVideosAutoShareRow") || null;
|
||||
this.autoShareInput =
|
||||
@@ -88,6 +92,11 @@ export class MyVideosController {
|
||||
setAutoShareEnabled(pubkey, this.autoShareInput.checked === true);
|
||||
});
|
||||
}
|
||||
if (this.orphanDeleteAllBtn instanceof HTMLElement) {
|
||||
this.orphanDeleteAllBtn.addEventListener("click", () => {
|
||||
void this.handleDeleteAllOrphans();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
refreshAutoShareToggle(pubkey) {
|
||||
@@ -230,9 +239,16 @@ export class MyVideosController {
|
||||
|
||||
renderOrphans(keys) {
|
||||
const list = Array.isArray(keys) ? keys : [];
|
||||
this.orphanKeys = list;
|
||||
if (this.orphansSection instanceof HTMLElement) {
|
||||
this.orphansSection.classList.toggle("hidden", list.length === 0);
|
||||
}
|
||||
if (this.orphanDeleteAllBtn instanceof HTMLElement) {
|
||||
this.orphanDeleteAllBtn.textContent =
|
||||
list.length > 1 ? `Delete all (${list.length})` : "Delete all";
|
||||
// Only worth a bulk action when there's more than one file.
|
||||
this.orphanDeleteAllBtn.classList.toggle("hidden", list.length < 2);
|
||||
}
|
||||
if (!(this.orphanListEl instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
@@ -289,6 +305,56 @@ export class MyVideosController {
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk "delete all orphans" — one call removes every listed orphaned object (each is
|
||||
// already confirmed safe by reconcileStorage: no live note references it, under the
|
||||
// user's prefix only). deleteStorageKeys takes the whole key array at once.
|
||||
async handleDeleteAllOrphans() {
|
||||
const r2Service = this.mainController.services?.r2Service;
|
||||
const keys = Array.isArray(this.orphanKeys) ? this.orphanKeys.slice() : [];
|
||||
if (!r2Service || typeof r2Service.deleteStorageKeys !== "function" || !keys.length) {
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined" && typeof window.confirm === "function") {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Permanently delete all ${keys.length} orphaned files from your bucket?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.orphanDeleteAllBtn instanceof HTMLElement) {
|
||||
this.orphanDeleteAllBtn.disabled = true;
|
||||
this.orphanDeleteAllBtn.textContent = "Deleting…";
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await r2Service.deleteStorageKeys({ keys, pubkey: this.pubkey });
|
||||
} catch (err) {
|
||||
devLogger.warn("[myVideos] bulk deleteStorageKeys failed:", err);
|
||||
this.mainController.showError?.("Failed to delete the files. Please try again.");
|
||||
return;
|
||||
} finally {
|
||||
if (this.orphanDeleteAllBtn instanceof HTMLElement) {
|
||||
this.orphanDeleteAllBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
const deleted = result?.deleted?.length || 0;
|
||||
if (result?.ok && deleted) {
|
||||
const failed = keys.length - deleted;
|
||||
this.mainController.showSuccess?.(
|
||||
failed > 0
|
||||
? `Removed ${deleted} files (${failed} could not be deleted).`
|
||||
: `Removed all ${deleted} orphaned files from your bucket.`,
|
||||
);
|
||||
void this.populate({ forceFetch: false });
|
||||
} else if (result?.reason === "storage-locked") {
|
||||
this.mainController.showError?.("Unlock storage first, then delete the files.");
|
||||
} else {
|
||||
this.mainController.showError?.("Failed to delete the files. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
buildSummary(rows) {
|
||||
if (!rows.length) {
|
||||
return "";
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// #8: a one-click "Delete all" bulk action for orphaned storage objects in My Videos.
|
||||
// It must (a) send EVERY listed orphan key in a single deleteStorageKeys call, (b) be
|
||||
// confirmation-gated, (c) refresh on success, and (d) surface storage-locked + partial
|
||||
// outcomes honestly (never claim success when nothing was deleted).
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import test, { afterEach } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { MyVideosController } from "../js/ui/profileModal/MyVideosController.js";
|
||||
|
||||
const KEYS = ["u/n/a/v.mp4", "u/n/a/v.torrent", "u/n/b/old.mp4"];
|
||||
|
||||
function makeController({ deleteResult, confirm = true } = {}) {
|
||||
const calls = { deleteKeys: [], success: [], error: [], populated: 0 };
|
||||
const mainController = {
|
||||
services: {
|
||||
r2Service: {
|
||||
deleteStorageKeys: async ({ keys, pubkey }) => {
|
||||
calls.deleteKeys.push({ keys, pubkey });
|
||||
return deleteResult;
|
||||
},
|
||||
},
|
||||
},
|
||||
showSuccess: (m) => calls.success.push(m),
|
||||
showError: (m) => calls.error.push(m),
|
||||
normalizeHexPubkey: (v) => v,
|
||||
getActivePubkey: () => "abc",
|
||||
};
|
||||
const c = new MyVideosController(mainController);
|
||||
c.pubkey = "abc";
|
||||
c.orphanKeys = KEYS.slice();
|
||||
c.populate = async () => {
|
||||
calls.populated += 1;
|
||||
};
|
||||
globalThis.window = { confirm: () => confirm };
|
||||
// The controller guards DOM writes with `instanceof HTMLElement`; define a stand-in
|
||||
// so the check evaluates (to false, since we run without a real DOM) instead of
|
||||
// throwing ReferenceError in this non-jsdom test.
|
||||
if (typeof globalThis.HTMLElement === "undefined") {
|
||||
globalThis.HTMLElement = class {};
|
||||
}
|
||||
return { c, calls };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.window;
|
||||
delete globalThis.HTMLElement;
|
||||
});
|
||||
|
||||
test("deletes every orphan key in a single call and refreshes on full success", async () => {
|
||||
const { c, calls } = makeController({
|
||||
deleteResult: { ok: true, deleted: KEYS.slice() },
|
||||
});
|
||||
await c.handleDeleteAllOrphans();
|
||||
|
||||
assert.equal(calls.deleteKeys.length, 1, "exactly one bulk delete call");
|
||||
assert.deepEqual(calls.deleteKeys[0].keys, KEYS, "all keys sent at once");
|
||||
assert.equal(calls.deleteKeys[0].pubkey, "abc");
|
||||
assert.equal(calls.populated, 1, "list refreshed after delete");
|
||||
assert.equal(calls.success.length, 1);
|
||||
assert.match(calls.success[0], /all 3/i);
|
||||
assert.equal(calls.error.length, 0);
|
||||
});
|
||||
|
||||
test("cancelling the confirmation deletes nothing", async () => {
|
||||
const { c, calls } = makeController({
|
||||
deleteResult: { ok: true, deleted: KEYS.slice() },
|
||||
confirm: false,
|
||||
});
|
||||
await c.handleDeleteAllOrphans();
|
||||
assert.equal(calls.deleteKeys.length, 0, "no delete when the user cancels");
|
||||
assert.equal(calls.populated, 0);
|
||||
});
|
||||
|
||||
test("storage-locked surfaces an unlock prompt and does not refresh or claim success", async () => {
|
||||
const { c, calls } = makeController({
|
||||
deleteResult: { ok: false, reason: "storage-locked" },
|
||||
});
|
||||
await c.handleDeleteAllOrphans();
|
||||
assert.equal(calls.success.length, 0);
|
||||
assert.equal(calls.populated, 0);
|
||||
assert.equal(calls.error.length, 1);
|
||||
assert.match(calls.error[0], /unlock storage/i);
|
||||
});
|
||||
|
||||
test("a partial delete reports how many succeeded and how many failed", async () => {
|
||||
const { c, calls } = makeController({
|
||||
deleteResult: { ok: true, deleted: KEYS.slice(0, 2) }, // 2 of 3
|
||||
});
|
||||
await c.handleDeleteAllOrphans();
|
||||
assert.equal(calls.populated, 1);
|
||||
assert.equal(calls.success.length, 1);
|
||||
assert.match(calls.success[0], /2/);
|
||||
assert.match(calls.success[0], /1/);
|
||||
});
|
||||
|
||||
test("an empty orphan list is a no-op (no delete call)", async () => {
|
||||
const { c, calls } = makeController({ deleteResult: { ok: true, deleted: [] } });
|
||||
c.orphanKeys = [];
|
||||
await c.handleDeleteAllOrphans();
|
||||
assert.equal(calls.deleteKeys.length, 0);
|
||||
});
|
||||
@@ -507,8 +507,16 @@ toward freshness and looked identical. Gave each a structural identity:
|
||||
### 8. Orphan storage garbage-collection tool
|
||||
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no
|
||||
live note references and offers per-file delete (under the user's prefix only).
|
||||
- [ ] Remaining (optional): a one-click **bulk** "delete all orphans" action, and/or
|
||||
surfacing the same control from the Storage settings pane.
|
||||
- [x] **Bulk "Delete all" orphans — DONE 2026-06-30.** Added a "Delete all (N)" button to
|
||||
the orphaned-objects header in My Videos (shown only when >1 orphan). One
|
||||
confirmation → a single `deleteStorageKeys({ keys: allOrphans, pubkey })` call
|
||||
(already array-capable + unlock-gated + prefix-scoped). Refreshes the list on
|
||||
success and reports partial outcomes honestly ("Removed 2 files (1 could not be
|
||||
deleted)"); surfaces storage-locked as an unlock prompt. Tests:
|
||||
`tests/my-videos-bulk-orphan-delete.test.mjs` (5 — all-keys-in-one-call, cancel,
|
||||
storage-locked, partial, empty no-op). Build + lint clean; My Videos suite 37/37.
|
||||
- [ ] Remaining (optional): surface the same orphan control from the Storage settings
|
||||
pane (currently only in My Videos).
|
||||
|
||||
### 9. Cap the cold-login relay-REQ storm further
|
||||
- [x] **Biggest source fixed** (`a8e9f520`): the community-blacklist load fired one
|
||||
|
||||
Reference in new issue
Block a user