diff --git a/TESTING.md b/TESTING.md index d2fd2d01..f04f7094 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2673,7 +2673,7 @@ Category". It is built only from the concept's Images parts: - `div#library-bulkbar` sits between the toolbar and the caption, empty until something is checked, then the concept's `.bulkbar`: "N selected", Set category, Clear (D151 adds Add tags before Set category, in the Images bar's order; D152 adds Delete… before - Clear, and Restore alone on the archived shelf). The + Clear, and Restore then Delete… on the archived shelf, #492). The selection is a set of prompt ids over the loaded shelf: windowed-out cards keep their state, Load more keeps it, and any shelf reload (search, category, sort, tags, a write) and leaving the page clear it. Clicking a checkbox toggles it and sets the @@ -2888,6 +2888,29 @@ with its own negative, a self-pair, each drift, validation); `tests/test_library copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python change: ComfyUI needs a restart. +### Delete… on the archived shelf (#492) + +Extends **D160** (coordinator ruling 2026-10-29, cutover item): the archived shelf's bar reads "N selected · Restore · +Delete… · Clear". Its Delete… review is the permanent delete only, **without** #482's "Prompts in a saved pair" choice +(on this shelf "Archive" would change nothing; Cancel keeps them archived). Title "Delete archived prompts"; the grid +is #482's Delete permanently grid ("Saved pairs removed" and "Negatives kept" only when a selected prompt is in a saved +pair); an archived prompt used as another prompt's negative cannot be deleted and **stays archived** ("Stays archived: R +prompts used as another prompt's negative"); one restored since the shelf was read is "No longer archived (kept)" and +never written; a review with nothing to delete is close-only ("None of the selected prompts can be deleted."). The +primary is "Delete N prompts"; the review opens with focus on Cancel (it is only a permanent delete; `openReviewDialog`'s +`focusCancel`). The same preview, safety backup, review digests, `records` hold and epoch fence apply. + +The routes take an optional `shelf` (`"active"`, the default, or `"archived"`; anything else 400). Under it an archived +prompt is classified by its pairs and a prompt without an archive row is `not_archived`; the held write classifies the +same shelf, so a permanent delete is backed up. The client sends only what it deletes. On this shelf the write archives +and rewrites nothing; every reviewed id not deleted counts `changed`, and the answer adds `not_archived` ("N were +restored since the review and kept."). The main shelf's archive sentence now ends "To delete them later or bring them +back, press Show archived prompts, select them and press Delete… or Restore." + +Tests: `tests/test_bulk_library_delete.py` TestArchivedShelfDelete, `tests/test_library_delete_routes.py` +TestArchivedShelfDeleteRoutes, `tests/js/workspace-library-delete.test.js` (archived shelf) and +`tests/browser/workspace-library-delete.spec.mjs` (#492 cases). A Python change: ComfyUI needs a restart. + ### Delete on a prompt's own page (#481) **D161** (beyond the concept, coordinator ruling (A) 2026-10-29, open to the maintainer at the live test): the recipe diff --git a/database/library_archive.py b/database/library_archive.py index a1985c2f..49e36f92 100644 --- a/database/library_archive.py +++ b/database/library_archive.py @@ -49,7 +49,26 @@ _NEGATIVE_OF_OTHER = ( "WHERE op.negative_prompt_id=p.id AND op.positive_prompt_id!=p.id)" ) # Classes in the order a prompt is tested; the first that holds wins. -CLASSES = ("not_found", "protected", "already_archived", "archive", "delete") +CLASSES = ( + "not_found", + "protected", + "already_archived", + "not_archived", + "archive", + "delete", +) +# #492: the shelf a Delete… runs on. On the archived shelf an archived prompt is +# classified by its pairs, and one restored since is ``not_archived``. +SHELVES = ("active", "archived") + + +def _check_shelf(shelf: str) -> bool: + """Whether ``shelf`` is the archived shelf (ValueError when unknown).""" + if shelf not in SHELVES: + raise ValueError("shelf must be active or archived") + return shelf == "archived" + + _DIGEST = re.compile(r"^[0-9a-f]{64}$") @@ -86,8 +105,9 @@ class LibraryArchiveOperations: """PromptDatabase mixin: classify, archive or delete, and restore parents.""" def _classify_library_delete( - self, conn: sqlite3.Connection, prompt_ids: list[int] + self, conn: sqlite3.Connection, prompt_ids: list[int], shelf: str = "active" ) -> dict: + on_archived_shelf = _check_shelf(shelf) found, deletable = {}, set() for chunk in _batches(prompt_ids): marks = ",".join("?" for _ in chunk) @@ -100,7 +120,9 @@ class LibraryArchiveOperations: protected, archived, paired, negative_of_other = row[1:5] if protected: found[row[0]] = "protected" - elif archived: + elif on_archived_shelf and not archived: + found[row[0]] = "not_archived" + elif archived and not on_archived_shelf: found[row[0]] = "already_archived" elif paired: found[row[0]] = "archive" @@ -237,18 +259,20 @@ class LibraryArchiveOperations: return list(dict.fromkeys(prompt_ids)) def classify_library_delete( - self, prompt_ids: list[int], *, expected_epoch: str + self, prompt_ids: list[int], *, expected_epoch: str, shelf: str = "active" ) -> dict: """What a delete of ``prompt_ids`` would do now (reads only). Returns the id lists per class, in request order, ``image_links`` (the image link rows the ``delete`` class would remove) and - ``fingerprints`` of the ``delete`` class (``_fingerprints``). + ``fingerprints`` of the ``delete`` class (``_fingerprints``). On the + archived ``shelf`` (#492) archived prompts are classified by their pairs. """ + _check_shelf(shelf) prompt_ids = self._checked_ids(prompt_ids, expected_epoch) with self.library_snapshot() as (conn, _epoch, _ceiling): epoch = self._library_epoch(conn, expected_epoch) - classes = self._classify_library_delete(conn, prompt_ids) + classes = self._classify_library_delete(conn, prompt_ids, shelf) deletable = classes["deletable"] return { "library_epoch": epoch, @@ -290,6 +314,7 @@ class LibraryArchiveOperations: expected_epoch: str, candidates: dict | None, delete_paired: dict | None = None, + shelf: str = "active", ) -> dict: """Archive and delete what the user reviewed, all or nothing. @@ -305,7 +330,12 @@ class LibraryArchiveOperations: Delete permanently to their review digest. Each is deleted with its own saved pairs only while it is still deletable and its digest is unchanged; otherwise it is archived and counted ``changed``. + + On the archived ``shelf`` (#492) the ids are archived already: nothing + is archived or rewritten, every reviewed id not deleted (protected or + changed) counts ``changed``, and one restored since ``not_archived``. """ + on_archived_shelf = _check_shelf(shelf) _validate_epoch(expected_epoch) for ids in (archive_ids, delete_ids): if not isinstance(ids, list) or any( @@ -330,7 +360,7 @@ class LibraryArchiveOperations: prompt_ids = list(dict.fromkeys([*archive_ids, *delete_ids])) with self._pair_transaction() as conn: epoch = self._library_epoch(conn, expected_epoch) - classes = self._classify_library_delete(conn, prompt_ids) + classes = self._classify_library_delete(conn, prompt_ids, shelf) now_prints = self._fingerprints(conn, classes["delete"]) deletes, archives, changed = [], [], 0 paired_now = set(classes["deletable"]) @@ -356,6 +386,11 @@ class LibraryArchiveOperations: else: changed += 1 changed += len(classes["protected"]) + if on_archived_shelf: + # Already archived: the rest stay as they are, each one counted. + archives = [] + changed = len(prompt_ids) - len(deletes) - len(paired_deletes) + changed -= len(classes["not_found"]) + len(classes["not_archived"]) image_links = self._image_links(conn, [*deletes, *paired_deletes]) now = utc_now_iso() for pid in archives: @@ -393,6 +428,7 @@ class LibraryArchiveOperations: "deleted": deleted, "archived": len(archives), "already_archived": len(classes["already_archived"]), + "not_archived": len(classes["not_archived"]), "not_found": len(classes["not_found"]), "changed": changed, "image_links": image_links, diff --git a/py/api/library_delete.py b/py/api/library_delete.py index 85a43405..1a88b3ca 100644 --- a/py/api/library_delete.py +++ b/py/api/library_delete.py @@ -49,6 +49,8 @@ _SELECTION_KEYS = frozenset({"prompt_ids", "expected_epoch"}) _DELETE_KEYS = frozenset({"archive_ids", "delete_ids", "expected_epoch", "backup"}) # #482: Delete permanently, with its digests; both or neither (a #443 body). _PAIRED_KEYS = frozenset({"delete_paired_ids", "paired_digests"}) +# #492: the shelf the Delete… runs on, optional on its own (default "active"). +_SHELF_KEYS = frozenset({"shelf"}) STORAGE_CHANGED = "Prompt storage was restored; refresh the library" @@ -66,8 +68,8 @@ def _storage_changed(exc): return _failure(409, exc.code, STORAGE_CHANGED, library_epoch=exc.epoch) -async def _body(request, keys, optional=frozenset()): - """The JSON object with exactly ``keys`` (plus all or none of ``optional``).""" +async def _body(request, keys, *groups): + """The JSON object with exactly ``keys``, plus all or none of each group.""" try: data = await request.json() except ValueError: @@ -75,11 +77,24 @@ async def _body(request, keys, optional=frozenset()): if not isinstance(data, dict): raise ValueError("Expected a JSON object") extra = set(data) - keys - if not keys <= set(data) or (extra and extra != optional): + partial = False + for group in groups: + if extra & group: + partial = partial or not group <= extra + extra -= group + if not keys <= set(data) or extra or partial: raise ValueError("Request must contain exactly " + ", ".join(sorted(keys))) return data +def _shelf(data): + """#492: the shelf a Delete… was reviewed on.""" + shelf = data.get("shelf", "active") + if shelf not in ("active", "archived"): + raise ValueError("shelf must be active or archived") + return shelf + + def _paired_delete(data, archive_ids): """#482: the reviewed archive ids to delete with their pairs -> digest.""" if "delete_paired_ids" not in data: @@ -119,8 +134,9 @@ class LibraryDeleteRoutesMixin: async def preview_library_delete(self, request): try: - data = await _body(request, _SELECTION_KEYS) + data = await _body(request, _SELECTION_KEYS, _SHELF_KEYS) ids = parse_bulk_prompt_ids(data["prompt_ids"]) + shelf = _shelf(data) except ValueError as exc: return _invalid(str(exc)) db = self.db @@ -131,6 +147,7 @@ class LibraryDeleteRoutesMixin: db.classify_library_delete, ids, expected_epoch=data["expected_epoch"], + shelf=shelf, ) disk = None if (review["delete"] or review["deletable"]) and policy == "on": @@ -165,6 +182,7 @@ class LibraryDeleteRoutesMixin: "archive": review["archive"], "protected": review["protected"], "already_archived": len(review["already_archived"]), + "not_archived": len(review["not_archived"]), "not_found": len(review["not_found"]), "image_links": review["image_links"], # #482: the archive ids Delete permanently may delete with pairs. @@ -181,7 +199,8 @@ class LibraryDeleteRoutesMixin: async def delete_library_prompts(self, request): try: - data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS) + data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS, _SHELF_KEYS) + shelf = _shelf(data) archive_ids = _reviewed_ids(data["archive_ids"], "archive_ids") delete_ids = _reviewed_ids(data["delete_ids"], "delete_ids") if not archive_ids and not delete_ids: @@ -203,7 +222,7 @@ class LibraryDeleteRoutesMixin: PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records" ) as job: response = await self._held_library_delete( - self.db, data, archive_ids, delete_ids, paired + self.db, data, archive_ids, delete_ids, paired, shelf ) if response.status >= 400: job.finish("failed") @@ -211,7 +230,9 @@ class LibraryDeleteRoutesMixin: except JobBusy as busy: return busy_response(busy) - async def _held_library_delete(self, db, data, archive_ids, delete_ids, paired): + async def _held_library_delete( + self, db, data, archive_ids, delete_ids, paired, shelf="active" + ): epoch = data["expected_epoch"] promised = data["backup"] == "on" or self._safety_backup_policy() == "on" backup = None @@ -222,6 +243,8 @@ class LibraryDeleteRoutesMixin: db.classify_library_delete, [*delete_ids, *paired], expected_epoch=epoch, + # #492: the same shelf, or archived ids would skip the backup. + shelf=shelf, ) candidates = review["fingerprints"] # #482: a permanent delete of a prompt in a saved pair is backed up too. @@ -248,6 +271,7 @@ class LibraryDeleteRoutesMixin: expected_epoch=epoch, candidates=candidates if candidates is not None else {}, delete_paired=paired, + shelf=shelf, ) except LibraryCommandError as exc: return _storage_changed(exc) diff --git a/tests/browser/library-delete-concept.spec.mjs b/tests/browser/library-delete-concept.spec.mjs index 5b457e80..ae344edc 100644 --- a/tests/browser/library-delete-concept.spec.mjs +++ b/tests/browser/library-delete-concept.spec.mjs @@ -96,8 +96,8 @@ function mapDeleteConcept({ danger, review }) { ['Backup', 'Create and verify before deleting']] }, notes: [ { ...note, text: 'Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the ' - + 'pair and its runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, ' - + 'select them and press Restore.' }, + + 'pair and its runs stay intact. To delete them later or bring them back, press Show archived prompts, select them ' + + 'and press Delete… or Restore.' }, // A second paragraph takes the concept's own `p + p` margin (style.css 93-95). { ...note, marginTop: '10px', text: "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " + 'image links. Deleted prompts cannot be brought back one by one: the only way back is to restore the safety ' diff --git a/tests/browser/workspace-library-delete.spec.mjs b/tests/browser/workspace-library-delete.spec.mjs index 0891c057..36ca84a4 100644 --- a/tests/browser/workspace-library-delete.spec.mjs +++ b/tests/browser/workspace-library-delete.spec.mjs @@ -94,7 +94,8 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur await expect(page.locator('.section-caption > span').first()).toHaveText(/^2 archived prompts · \d+ negative variants? hidden with them$/); await expect(card(page,cat.id).locator('.card-title .chip',{hasText:'Archived'})).toHaveCount(1); await box(page,cat.id).check(); - await expect(bar(page).getByRole('button')).toHaveText(['Select all 2 loaded','Restore','Clear']); + // #492: the archived shelf offers Delete… after Restore. + await expect(bar(page).getByRole('button')).toHaveText(['Select all 2 loaded','Restore','Delete…','Clear']); await bar(page).getByRole('button',{name:'Restore',exact:true}).click(); await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.'); await expect(cards(page)).toHaveCount(1); @@ -175,6 +176,62 @@ test('the choice resets to Archive on every Delete…, and a selection with no p await expect(choice(page)).toHaveCount(0); }); +// #492: Delete… on the archived shelf deletes archived prompts permanently, with their own saved pairs; no choice. +async function archiveByApi(request,ids) { + const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch; + const preview=await(await request.post(PREVIEW,{data:{prompt_ids:ids,expected_epoch:epoch}})).json(); + expect(preview.archive).toEqual(ids); + await expect(await request.post(DELETE,{data:{archive_ids:ids,delete_ids:[],expected_epoch:epoch,backup:'on'}})).toBeOK(); + return epoch; +} +test('Delete… on the archived shelf deletes an archived prompt and its saved pair permanently (#492)',async({page,request})=>{ + const dual=byText(await items(request),'Dual role portrait'); + const epoch=await archiveByApi(request,[dual.id]); + const previews=recordPosts(page,PREVIEW),writes=recordPosts(page,DELETE); + await page.goto(route);await showArchived(page).click();await expect(card(page,dual.id)).toHaveCount(1); + await box(page,dual.id).check(); + await openReview(page); + await expect(review(page)).toHaveAccessibleName('Delete archived prompts'); + await expect(choice(page)).toHaveCount(0); + // A permanent delete only: the review opens on Cancel, and its copy says it cannot be undone and takes the pairs. + await expect(review(page).getByRole('button',{name:'Cancel',exact:true})).toBeFocused(); + await expect(review(page).locator('p.small').first()).toHaveText(/^Deleting a prompt in a saved pair also removes its saved pairs/); + await expect(review(page).locator('p.small').nth(1)).toContainText('the only way back is to restore the safety backup taken first'); + await expect(row(page,'Delete permanently')).toHaveText('1 prompt in a saved pair'); + await expect(row(page,'Saved pairs removed')).toHaveText(/^1 saved pair/); + await expect(row(page,'Backup')).toHaveText('Create and verify before deleting'); + expect(previews).toEqual([{prompt_ids:[dual.id],expected_epoch:epoch,shelf:'archived'}]); + // Cancel writes nothing and returns focus to Delete…. + await review(page).getByRole('button',{name:'Cancel',exact:true}).click(); + await expect(review(page)).toHaveCount(0);expect(writes).toEqual([]); + await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).toBeFocused(); + await openReview(page); + await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /); + expect(writes).toHaveLength(1); + expect(writes[0]).toMatchObject({archive_ids:[dual.id],delete_ids:[],delete_paired_ids:[dual.id],shelf:'archived'}); + await expect(page.locator('.library-status')).toHaveText('No archived prompts.'); + await backToLibrary(page).click(); + await expect(showArchived(page)).toHaveText('Show archived prompts (0)'); + expect(byText(await items(request),'Dual role portrait')).toBeUndefined(); + expect(await items(request,'archived')).toEqual([]); +}); + +test('an archived prompt restored since the shelf was read is kept, in a close-only review (#492)',async({page,request})=>{ + const dual=byText(await items(request),'Dual role portrait'); + await archiveByApi(request,[dual.id]); + const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch; + await page.goto(route);await showArchived(page).click();await expect(card(page,dual.id)).toHaveCount(1); + await box(page,dual.id).check(); + // Restored elsewhere after the shelf was read: the review counts it and deletes nothing. + await expect(await request.post('/prompt_manager/library/restore',{data:{prompt_ids:[dual.id],expected_epoch:epoch}})).toBeOK(); + await openReview(page); + await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted.'); + await expect(row(page,'No longer archived \\(kept\\)')).toHaveText('1'); + await expect(review(page).getByRole('button',{name:/^Delete \d/})).toHaveCount(0); + expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id); +}); + // #481 (D161): a prompt's own page deletes it from Edit metadata (the concept's recipe editor, app.js 976), through the // same review: "Delete prompt…" beside "Archive variant…" (the variant's archive, renamed so the two cannot be confused). const editMetadata=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'Edit metadata',exact:true}); diff --git a/tests/js/workspace-library-delete.test.js b/tests/js/workspace-library-delete.test.js index 825739fc..991cead6 100644 --- a/tests/js/workspace-library-delete.test.js +++ b/tests/js/workspace-library-delete.test.js @@ -64,7 +64,8 @@ test('the sentences explain archive, irreversibility per backup policy, re-creat // Live test (#470): the archive reason is the saved pair, which a prompt with 0 negative variants has too. assert.match(archive, /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the pair and its runs stay intact\./); assert.doesNotMatch(archive, /variant/); - assert.match(archive, /Archived prompts cannot be deleted\. To bring them back, press Show archived prompts/); + // #492: the archived shelf deletes them too. + assert.match(archive, /stay intact\. To delete them later or bring them back, press Show archived prompts, select them and press Delete… or Restore\.$/); assert.match(remove, /the only way back is to restore the safety backup taken first \(System › Backup & export\), which returns the whole library to that moment\. It is kept among the last 10 safety backups\./); assert.match(remove, /A later scan, a new image or a node run with the same text adds it again as a new prompt\.$/); assert.equal(guarded, 'Protected prompts are tagged __protected__; remove the tag to delete them.'); @@ -132,8 +133,8 @@ test('a failed preview reloads only after a restore; Restore outcomes have their test('reviewOf keeps only well-formed ids and counts from an answer', async () => { const {reviewOf} = await source; const review = reviewOf({library_epoch: 3, delete: [1, 'x', -2, 2], archive: null, protected: [1.5], already_archived: -1, - not_found: 'x', image_links: 2, backup: 'maybe'}); - assert.deepEqual(review, {epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0, + not_found: 'x', image_links: 2, backup: 'maybe', not_archived: -3}); + assert.deepEqual(review, {shelf: 'active', notArchived: 0, epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0, imageLinks: 2, backup: 'on', disk: null, deletable: [], digests: {}, pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive'}); }); @@ -229,3 +230,64 @@ test('a short disk blocks only the permanent delete; the outcome names removed p assert.equal(toast, 'Deleted 2 prompts. Removed 3 saved pairs. Archived 1 prompt. 1 changed since the review and ' + 'was not deleted.'); }); + +// #492: Delete… on the archived shelf: no choice, only the permanent delete; what cannot be deleted stays archived. +const shelfPreview = (patch = {}) => preview({deletable: [], review_digests: {}, paired_counts: {pairs: 0, archived_pairs: 0, + variants: 0, kept_negatives: 0}, paired_image_links: 0, not_archived: 0, ...patch}); + +test('the archived shelf review is the permanent delete, without the choice', async () => { + const {reviewOf, hasPairedChoice, withChoice, reviewTitle, primaryLabel, reviewGrid, deleteBody, blockedReason} = await source; + const review = reviewOf(shelfPreview({delete: [1], archive: [2, 3], deletable: [2], review_digests: {2: D1}, + paired_counts: {pairs: 2, archived_pairs: 1, variants: 1, kept_negatives: 1}, paired_image_links: 1, image_links: 1, + not_archived: 1}), 'archived'); + assert.equal(review.shelf, 'archived'); + assert.equal(review.choice, 'delete'); + assert.equal(hasPairedChoice(review), false); + assert.equal(withChoice(review, 'archive').choice, 'delete'); + assert.equal(reviewTitle(review), 'Delete archived prompts'); + assert.equal(primaryLabel(review), 'Delete 2 prompts'); + assert.equal(blockedReason(review), null); + assert.deepEqual(reviewGrid(review), [ + ['Delete permanently', '2 prompts: 1 not in a saved pair, 1 in a saved pair'], + ['Saved pairs removed', '2 saved pairs (1 with a negative prompt, 1 archived)'], + ['Negatives kept', '1 negative prompt is kept'], + ['Stays archived', "1 prompt used as another prompt's negative"], + ['No longer archived (kept)', '1'], + ['Image links removed', '2 images will no longer show these prompts (files stay on disk)'], + ['Backup', 'Create and verify before deleting'], + ]); + // Only what it deletes is sent: the stays-archived id is left out. + assert.deepEqual(deleteBody(review), {archive_ids: [2], delete_ids: [1], expected_epoch: 'e', backup: 'on', + delete_paired_ids: [2], paired_digests: {2: D1}, shelf: 'archived'}); +}); + +test('the archived shelf: unpaired only, nothing deletable, and its sentences', async () => { + const {reviewOf, reviewGrid, reviewSentences, primaryLabel, blockedReason, COPY} = await source; + const loose = reviewOf(shelfPreview({delete: [1]}), 'archived'); + assert.deepEqual(reviewGrid(loose).map(([term]) => term), ['Delete permanently', 'Image links removed', 'Backup']); + assert.equal(reviewGrid(loose)[0][1], '1 prompt not in a saved pair'); + assert.equal(primaryLabel(loose), 'Delete 1 prompt'); + const [remove] = reviewSentences(loose); + assert.match(remove, /^Deleting removes each prompt's text/); + assert.doesNotMatch(reviewSentences(loose).join(' '), /saved pair/); + const stays = reviewOf(shelfPreview({archive: [3], protected: [4]}), 'archived'); + assert.equal(blockedReason(stays), COPY.noneArchived); + assert.equal(COPY.noneArchived, 'None of the selected prompts can be deleted.'); + const paired = reviewOf(shelfPreview({archive: [2, 3], deletable: [2], review_digests: {2: D1}, + paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}}), 'archived'); + const sentences = reviewSentences(paired); + assert.match(sentences[0], /^Deleting a prompt in a saved pair also removes its saved pairs/); + assert.equal(sentences.at(-1), "Prompts used as another prompt's negative stay archived, so that prompt's variant stays."); + // The active shelf is unchanged: a preview without a shelf is #482's. + assert.equal(reviewOf(shelfPreview({archive: [2], deletable: [2], review_digests: {2: D1}})).choice, 'archive'); +}); + +test('the outcome names prompts restored since the review', async () => { + const {deleteOutcome} = await source; + const outcome = deleteOutcome({success: true, deleted: 1, archived: 0, not_archived: 2, changed: 0}, null, {protected: []}); + assert.equal(outcome.toast, 'Deleted 1 prompt. 2 were restored since the review and kept.'); + const none = deleteOutcome({success: true, deleted: 0, archived: 0, not_archived: 1, changed: 1}, null, + {protected: [], shelf: 'archived'}); + assert.equal(none.toast, 'Nothing was deleted. 1 was restored since the review and kept. 1 changed since the review and ' + + 'was not deleted. The Library was reloaded.'); +}); diff --git a/tests/test_bulk_library_delete.py b/tests/test_bulk_library_delete.py index e8c6b312..7fce13e1 100644 --- a/tests/test_bulk_library_delete.py +++ b/tests/test_bulk_library_delete.py @@ -137,6 +137,7 @@ class TestClassifyAndApply(LibraryDeleteCase): "archive": [positive, both, archived_pair_positive], "protected": [protected, protected_paired], "already_archived": [already], + "not_archived": [], "not_found": [negative_only, 99999], "image_links": 1, "fingerprints": result["fingerprints"], @@ -173,6 +174,7 @@ class TestClassifyAndApply(LibraryDeleteCase): "deleted": 1, "archived": 1, "already_archived": 0, + "not_archived": 0, "not_found": 0, "changed": 0, "image_links": 1, @@ -726,3 +728,162 @@ class TestPairedDelete(LibraryDeleteCase): candidates={}, delete_paired={fox: "not a digest"}, ) + + +class TestArchivedShelfDelete(LibraryDeleteCase): + """#492: Delete… on the archived shelf deletes archived prompts permanently.""" + + def archive(self, *ids): + with self.db.model.get_connection() as conn: + conn.executemany( + f"INSERT INTO {ARCHIVE}(prompt_id, archived_at) VALUES (?, ?)", + [(pid, "2026-10-29T00:00:00Z") for pid in ids], + ) + conn.commit() + + def shelf_classify(self, ids): + return self.db.classify_library_delete( + ids, expected_epoch=self.epoch, shelf="archived" + ) + + def shelf_write(self, review, deletes=None, paired=None): + """What the archived shelf's client sends: only what it deletes.""" + deletes = review["delete"] if deletes is None else deletes + paired = review["deletable"] if paired is None else paired + return self.db.archive_or_delete_library_prompts( + list(paired), + list(deletes), + expected_epoch=self.epoch, + candidates=review["fingerprints"], + delete_paired={pid: review["review_digests"][pid] for pid in paired}, + shelf="archived", + ) + + def test_classify_on_the_archived_shelf(self): + owl, _neg, _pair = self.paired("Owl", "dark") + shared = save(self.db, "Shared") + self.image(shared, "shared.png") + self.db.save_prompt_pair("Dog", "Shared") # another positive's negative + loose = save(self.db, "Loose") + self.image(loose, "loose.png") + guarded = save(self.db, "Guarded", tags=["__protected__"]) + self.image(guarded, "guarded.png") + shown = save(self.db, "Shown") + self.image(shown, "shown.png") + self.archive(owl, shared, loose, guarded) + review = self.shelf_classify([owl, shared, loose, guarded, shown, 999999]) + self.assertEqual(review["archive"], [owl, shared]) + self.assertEqual(review["deletable"], [owl]) + self.assertEqual(review["delete"], [loose]) + self.assertEqual(review["protected"], [guarded]) + self.assertEqual(review["not_archived"], [shown]) + self.assertEqual(review["already_archived"], []) + self.assertEqual(review["not_found"], [999999]) + self.assertEqual(review["image_links"], 1) + # The active shelf is unchanged: archived prompts are only counted there. + active = self.classify([owl, loose, shown]) + self.assertEqual(active["already_archived"], [owl, loose]) + self.assertEqual(active["not_archived"], []) + self.assertEqual(active["delete"], [shown]) + + def test_the_write_deletes_archived_prompts_and_their_pairs(self): + owl, owl_negative, pair = self.paired("Owl", "dark") + loose = save(self.db, "Loose") + self.image(loose, "loose.png") + self.archive(owl, loose) + result = self.shelf_write(self.shelf_classify([owl, loose])) + self.assertEqual( + (result["deleted"], result["archived"], result["changed"]), (2, 0, 0) + ) + self.assertEqual((result["pairs_removed"], result["not_archived"]), (1, 0)) + self.assertNotIn(owl, prompt_ids(self.db)) + self.assertNotIn(loose, prompt_ids(self.db)) + self.assertIn(owl_negative, prompt_ids(self.db)) + self.assertIsNone(self.db.get_prompt_pair(pair)) + self.assertEqual(archived_ids(self.db), []) + self.assertEqual(rows(self.db, "PRAGMA foreign_key_check"), []) + + def test_drift_leaves_the_prompt_archived_and_untouched(self): + owl, _neg, pair = self.paired("Owl", "dark") + hen, _hen_negative, hen_pair = self.paired("Hen", "fog") + loose = save(self.db, "Loose") + self.image(loose, "loose.png") + self.archive(owl, hen, loose) + review = self.shelf_classify([owl, hen, loose]) + before = rows(self.db, "SELECT id,hash,updated_at FROM prompts ORDER BY id") + self.db.update_prompt_pair_metadata(pair, notes="edited after the review") + # Reviewed as unpaired, it gains a pair before the write. + self.db.save_prompt_pair("Loose", "grain") + # A pair removed since: no longer what the review promised. + with self.db.model.get_connection() as conn: + conn.execute("DELETE FROM prompt_pairs WHERE id=?", (hen_pair,)) + conn.commit() + result = self.shelf_write(review) + self.assertEqual( + (result["deleted"], result["archived"], result["changed"]), (0, 0, 3) + ) + self.assertEqual(archived_ids(self.db), sorted([owl, hen, loose])) + self.assertEqual( + rows(self.db, "SELECT id,hash,updated_at FROM prompts ORDER BY id")[ + : len(before) + ], + before, + ) + + def test_a_prompt_restored_since_the_review_is_kept(self): + owl, _neg, _pair = self.paired("Owl", "dark") + self.archive(owl) + review = self.shelf_classify([owl]) + self.restore([owl]) + result = self.shelf_write(review) + self.assertEqual((result["deleted"], result["not_archived"]), (0, 1)) + self.assertIn(owl, prompt_ids(self.db)) + self.assertEqual(archived_ids(self.db), []) + + def test_the_negative_of_a_deleted_positive_stays_archived(self): + # #482's rule, kept on this shelf: X is only Dog's negative. + dog, shared, _pair = self.paired("Dog", "Shared") + self.image(shared, "shared.png") + self.archive(dog, shared) + review = self.shelf_classify([dog, shared]) + self.assertEqual(review["deletable"], [dog]) + self.assertEqual(review["archive"], [dog, shared]) + result = self.shelf_write(review) + self.assertEqual((result["deleted"], result["pairs_removed"]), (1, 1)) + self.assertEqual(archived_ids(self.db), [shared]) + + def test_mixed_outcomes_are_each_counted_once(self): + owl, _neg, _pair = self.paired("Owl", "dark") + hen, _neg2, hen_pair = self.paired("Hen", "fog") + loose = save(self.db, "Loose") + self.image(loose, "loose.png") + guarded = save(self.db, "Guarded", tags=["__protected__"]) + self.image(guarded, "guarded.png") + gone = save(self.db, "Gone") + self.image(gone, "gone.png") + self.archive(owl, hen, loose, guarded, gone) + review = self.shelf_classify([owl, hen, loose, guarded, gone]) + self.db.update_prompt_pair_metadata(hen_pair, notes="drifted") + self.restore([loose]) + with self.db.model.get_connection() as conn: + conn.execute("DELETE FROM generated_images WHERE prompt_id=?", (gone,)) + conn.execute("DELETE FROM prompts WHERE id=?", (gone,)) + conn.commit() + result = self.shelf_write(review, deletes=[loose, gone]) + self.assertEqual( + {k: result[k] for k in ("deleted", "changed", "not_found", "not_archived")}, + {"deleted": 1, "changed": 1, "not_found": 1, "not_archived": 1}, + ) + self.assertNotIn(owl, prompt_ids(self.db)) + self.assertEqual(archived_ids(self.db), sorted([hen, guarded])) + + def test_shelf_must_be_active_or_archived(self): + owl, _neg, _pair = self.paired("Owl") + with self.assertRaises(ValueError): + self.db.classify_library_delete( + [owl], expected_epoch=self.epoch, shelf="trash" + ) + with self.assertRaises(ValueError): + self.db.archive_or_delete_library_prompts( + [owl], [], expected_epoch=self.epoch, candidates={}, shelf="trash" + ) diff --git a/tests/test_library_delete_routes.py b/tests/test_library_delete_routes.py index a3f403be..1582cbc7 100644 --- a/tests/test_library_delete_routes.py +++ b/tests/test_library_delete_routes.py @@ -402,6 +402,98 @@ class TestPairedDeleteRoutes(LibraryDeleteRouteCase): self.assertIn(cat, self.ids()) +class TestArchivedShelfDeleteRoutes(LibraryDeleteRouteCase): + """#492: Delete… on the archived shelf (``shelf: "archived"``).""" + + def archive(self, *ids): + with self.api.db.model.get_connection() as conn: + conn.executemany( + f"INSERT INTO {ARCHIVE}(prompt_id, archived_at) VALUES (?, ?)", + [(pid, "2026-10-29T00:00:00Z") for pid in ids], + ) + conn.commit() + + async def shelf_review(self, ids): + status, body = await self.post( + PREVIEW, + {"prompt_ids": ids, "expected_epoch": self.epoch(), "shelf": "archived"}, + ) + self.assertEqual(status, 200, body) + return body + + async def shelf_confirm(self, review): + paired = review["deletable"] + return await self.post( + DELETE, + { + "archive_ids": paired, + "delete_ids": review["delete"], + "expected_epoch": review["library_epoch"], + "backup": review["backup"], + "delete_paired_ids": paired, + "paired_digests": { + str(pid): review["review_digests"][str(pid)] for pid in paired + }, + "shelf": "archived", + }, + ) + + async def test_an_archived_paired_prompt_is_deleted_after_a_backup(self): + self.policy(True) + cat = self.paired("Cat") + shown = self.save("Shown") + self.api.db.link_image_to_prompt(shown, "/tmp/shown-492.png") + self.archive(cat) + review = await self.shelf_review([cat, shown]) + self.assertEqual( + (review["deletable"], review["delete"], review["not_archived"]), + ([cat], [], 1), + ) + self.assertTrue(review["disk"]["sufficient"]) + status, body = await self.shelf_confirm(review) + self.assertEqual(status, 200, body) + self.assertEqual( + (body["deleted"], body["archived"], body["pairs_removed"]), (1, 0, 1) + ) + # The held write classified the archived shelf too, so it backed up. + self.assertEqual(self.backups(), [body["backup"]]) + self.assertNotIn(cat, self.ids()) + self.assertEqual(self.archived(), []) + + async def test_an_archived_unpaired_prompt_is_deleted_after_a_backup(self): + self.policy(True) + loose = self.save("Loose") + self.api.db.link_image_to_prompt(loose, "/tmp/loose-492.png") + self.archive(loose) + review = await self.shelf_review([loose]) + self.assertEqual(review["delete"], [loose]) + status, body = await self.shelf_confirm(review) + self.assertEqual(status, 200, body) + self.assertEqual((body["deleted"], body["changed"]), (1, 0)) + self.assertEqual(self.backups(), [body["backup"]]) + + async def test_the_shelf_is_validated_and_optional(self): + cat = self.paired("Cat") + status, body = await self.post( + PREVIEW, + {"prompt_ids": [cat], "expected_epoch": self.epoch(), "shelf": "trash"}, + ) + self.assertEqual((status, body["code"]), (400, "invalid-request")) + review = await self.review([cat]) + base = { + "archive_ids": review["archive"], + "delete_ids": review["delete"], + "expected_epoch": review["library_epoch"], + "backup": "off", + } + status, body = await self.post(DELETE, {**base, "shelf": 1}) + self.assertEqual((status, body["code"]), (400, "invalid-request")) + # The active shelf named explicitly is #443's delete. + status, body = await self.post(DELETE, {**base, "shelf": "active"}) + self.assertEqual(status, 200, body) + self.assertEqual(self.archived(), [cat]) + + class TestIngestionAfterArchive(LibraryDeleteRouteCase): def scan(self, name, positive, negative="blur"): meta = {"prompt": json.dumps(graph(positive, negative))} diff --git a/web/js/workspace/library-delete.mjs b/web/js/workspace/library-delete.mjs index e026e220..b658152d 100644 --- a/web/js/workspace/library-delete.mjs +++ b/web/js/workspace/library-delete.mjs @@ -25,6 +25,7 @@ export const COPY = Object.freeze({ failed: 'The selected prompts could not be changed. Try again.', nothing: `${NOTHING} The Library was reloaded.`, none: 'None of the selected prompts can be deleted or archived.', + noneArchived: 'None of the selected prompts can be deleted.', restoreStorage: 'The library was restored or moved since this page loaded. No prompt was returned to the Library.', restoreUnconfirmed: 'Returning the prompts could not be confirmed. The Library was reloaded.', restoreFailed: 'The prompts could not be returned to the Library. Try again.', @@ -36,17 +37,23 @@ const ids = value => (Array.isArray(value) ? value.filter(id => Number.isSafeInt const were = (value, singular, plural) => `${value.toLocaleString('en-US')} ${value === 1 ? singular : plural}`; const DIGEST = /^[0-9a-f]{64}$/; -/** The preview's classes as counts and id lists, whatever else the answer holds. */ -export function reviewOf(preview) { +/** + * The preview's classes as counts and id lists, whatever else the answer holds. On the archived `shelf` (#492) the + * review is the permanent delete only: the choice is fixed to `delete`. + */ +export function reviewOf(preview, shelf = 'active') { + const archivedShelf = shelf === 'archived'; return { + shelf: archivedShelf ? 'archived' : 'active', notArchived: count(preview?.not_archived), epoch: typeof preview?.library_epoch === 'string' ? preview.library_epoch : null, delete: ids(preview?.delete), archive: ids(preview?.archive), protected: ids(preview?.protected), alreadyArchived: count(preview?.already_archived), notFound: count(preview?.not_found), imageLinks: count(preview?.image_links), backup: preview?.backup === 'off' ? 'off' : 'on', disk: preview?.disk && typeof preview.disk === 'object' ? preview.disk : null, - ...pairedOf(preview), + ...pairedOf(preview), ...(archivedShelf ? { choice: 'delete' } : {}), }; } +const onArchivedShelf = review => review.shelf === 'archived'; /** #482: the archive ids that may be deleted permanently (each with its review digest), and the pair counts. */ function pairedOf(preview) { @@ -62,10 +69,11 @@ function pairedOf(preview) { } /** #482: whether the review asks what to do with prompts in a saved pair. */ -export const hasPairedChoice = review => review.deletable.length > 0; +export const hasPairedChoice = review => !onArchivedShelf(review) && review.deletable.length > 0; /** #482: the review with its saved-pair choice (`archive`, the default, or `delete`); the original is unchanged. */ export function withChoice(review, choice) { + if (onArchivedShelf(review)) return { ...review, choice: 'delete' }; return { ...review, choice: choice === 'delete' && hasPairedChoice(review) ? 'delete' : 'archive' }; } @@ -79,6 +87,7 @@ function plan(review) { export function reviewTitle(review) { const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length; + if (onArchivedShelf(review)) return 'Delete archived prompts'; if (!archive.length) return 'Delete selected prompts'; if (!deletes) return 'Archive selected prompts'; return 'Delete or archive selected prompts'; @@ -119,6 +128,7 @@ function keptRows(rows, review, titleOf) { rows.push(['Protected (kept)', `${review.protected.length.toLocaleString('en-US')}${names ? `: ${names}` : ''}`]); } if (review.alreadyArchived) rows.push(['Already archived', review.alreadyArchived.toLocaleString('en-US')]); + if (review.notArchived) rows.push(['No longer archived (kept)', review.notArchived.toLocaleString('en-US')]); if (review.notFound) rows.push(['No longer exist', review.notFound.toLocaleString('en-US')]); } @@ -137,10 +147,14 @@ function permanentGrid(review, titleOf) { const deleted = plain.length ? `${countOf(total, 'prompt')}: ${parts.join(', ')}` : `${countOf(total, 'prompt')} in a saved pair`; const details = [counts.variants && `${counts.variants.toLocaleString('en-US')} with a negative prompt`, counts.archivedPairs && `${counts.archivedPairs.toLocaleString('en-US')} archived`].filter(Boolean); - const rows = [['Delete permanently', deleted], - ['Saved pairs removed', `${countOf(counts.pairs, 'saved pair')}${details.length ? ` (${details.join(', ')})` : ''}`]]; - if (counts.keptNegatives) rows.push(['Negatives kept', `${countOf(counts.keptNegatives, 'negative prompt')} ${counts.keptNegatives === 1 ? 'is' : 'are'} kept`]); - if (archive.length) rows.push(['Archive', `${countOf(archive.length, 'prompt')} used as another prompt's negative: hidden, restorable`]); + const rows = [['Delete permanently', paired.length ? deleted : `${countOf(total, 'prompt')} not in a saved pair`]]; + // #492: on the archived shelf a selection may hold no prompt in a saved pair. + if (paired.length) { + rows.push(['Saved pairs removed', `${countOf(counts.pairs, 'saved pair')}${details.length ? ` (${details.join(', ')})` : ''}`]); + if (counts.keptNegatives) rows.push(['Negatives kept', `${countOf(counts.keptNegatives, 'negative prompt')} ${counts.keptNegatives === 1 ? 'is' : 'are'} kept`]); + } + if (archive.length && onArchivedShelf(review)) rows.push(['Stays archived', `${countOf(archive.length, 'prompt')} used as another prompt's negative`]); + else if (archive.length) rows.push(['Archive', `${countOf(archive.length, 'prompt')} used as another prompt's negative: hidden, restorable`]); keptRows(rows, review, titleOf); deleteRows(rows, review, review.imageLinks + review.pairedImageLinks); return rows; @@ -152,8 +166,8 @@ export function reviewSentences(review) { const sentences = []; if (review.archive.length) { sentences.push('Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the ' + - 'pair and its runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, ' + - 'select them and press Restore.'); + 'pair and its runs stay intact. To delete them later or bring them back, press Show archived prompts, select them ' + + 'and press Delete… or Restore.'); } if (review.delete.length) sentences.push(removeSentence(review)); if (review.protected.length) sentences.push(PROTECTED_SENTENCE); @@ -174,10 +188,12 @@ function removeSentence(review, pairs = false) { /** #482: the sentences when Delete permanently is chosen. */ function permanentSentences(review) { - const sentences = ['Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' + - 'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' + - 'and Activity, listed by their text.', removeSentence(review, true)]; - if (plan(review).archive.length) sentences.push("Prompts used as another prompt's negative are archived instead of deleted, " + + const { paired, archive } = plan(review); + const sentences = paired.length ? ['Deleting a prompt in a saved pair also removes its saved pairs, with their titles, ' + + 'categories, ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay ' + + 'in History and Activity, listed by their text.', removeSentence(review, true)] : [removeSentence(review)]; + if (archive.length && onArchivedShelf(review)) sentences.push("Prompts used as another prompt's negative stay archived, so that prompt's variant stays."); + else if (archive.length) sentences.push("Prompts used as another prompt's negative are archived instead of deleted, " + "so that prompt's variant stays."); if (review.protected.length) sentences.push(PROTECTED_SENTENCE); return sentences; @@ -186,12 +202,15 @@ function permanentSentences(review) { export function primaryLabel(review) { const { plain, paired, archive } = plan(review); const deletes = plain.length + paired.length, archives = archive.length; + // #492: on the archived shelf what cannot be deleted stays archived; the primary names only the deletes. + if (onArchivedShelf(review)) return `Delete ${countOf(deletes, 'prompt')}`; if (deletes && archives) return `Delete ${deletes.toLocaleString('en-US')} and archive ${countOf(archives, 'prompt')}`; return deletes ? `Delete ${countOf(deletes, 'prompt')}` : `Archive ${countOf(archives, 'prompt')}`; } /** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */ export function blockedReason(review) { + if (onArchivedShelf(review) && !review.delete.length && !review.deletable.length) return COPY.noneArchived; if (!review.delete.length && !review.archive.length) return COPY.none; const deletes = review.delete.length + plan(review).paired.length; if (deletes && review.disk && review.disk.sufficient === false) { @@ -221,7 +240,8 @@ export function openDeleteReview({ scope, trigger, review, titleOf, onStart, onD const choice = hasPairedChoice(review) ? pairedChoice(value => update(value)) : null; const dialog = openReviewDialog({ scope, trigger, title, grid, options: choice ? [choice, ...sentences] : sentences, cancelLabel: 'Cancel', primary: { label: primaryLabel(current), danger: true }, - onStart: () => onStart(current), onDismiss }); + // #492: the archived shelf's review is only a permanent delete, so it opens on Cancel. + focusCancel: onArchivedShelf(current), onStart: () => onStart(current), onDismiss }); function update(value) { current = withChoice(review, value); dialog.setRows(reviewGrid(current, titleOf)); @@ -267,6 +287,11 @@ function pairedChoice(onChange) { /** The write's body: the reviewed lists, the epoch and the reviewed backup promise. */ export function deleteBody(review) { const body = { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup }; + // #492: the archived shelf sends only what it deletes (its prompts are archived already). + if (onArchivedShelf(review)) { + return { ...body, archive_ids: review.deletable, delete_paired_ids: review.deletable, paired_digests: review.digests, + shelf: 'archived' }; + } // #482: the archive ids to delete permanently, each with the digest of what the review showed. if (permanent(review)) Object.assign(body, { delete_paired_ids: review.deletable, paired_digests: review.digests }); return body; @@ -298,9 +323,12 @@ export function deleteOutcome(result, failure, review) { if (deleted) parts.push(`Deleted ${countOf(deleted, 'prompt')}.`); if (pairs) parts.push(`Removed ${countOf(pairs, 'saved pair')}.`); if (archived) parts.push(`Archived ${countOf(archived, 'prompt')}.`); - if (!parts.length) parts.push(NOTHING); + // #492: the archived shelf archives nothing, so it says only that nothing was deleted. + if (!parts.length) parts.push(review?.shelf === 'archived' ? 'Nothing was deleted.' : NOTHING); const already = count(result.already_archived), missing = count(result.not_found), changed = count(result.changed); if (already) parts.push(`${were(already, 'was', 'were')} already archived.`); + const restored = count(result.not_archived); + if (restored) parts.push(`${were(restored, 'was', 'were')} restored since the review and kept.`); if (missing) parts.push(`${missing.toLocaleString('en-US')} no longer ${missing === 1 ? 'exists' : 'exist'}.`); if (changed) parts.push(`${changed.toLocaleString('en-US')} changed since the review and ${changed === 1 ? 'was' : 'were'} not deleted.`); const kept = review?.protected?.length || 0; diff --git a/web/js/workspace/library-selection.mjs b/web/js/workspace/library-selection.mjs index 6c5f02a0..b6bfb082 100644 --- a/web/js/workspace/library-selection.mjs +++ b/web/js/workspace/library-selection.mjs @@ -24,7 +24,7 @@ import { COPY as DELETE_COPY, DELETE_LABEL, DELETE_URL, PREVIEW_URL, REVIEWING, * * Delete… (#443, D152) previews the selection, opens its review (`library-delete.mjs`) and posts the reviewed lists: * unpaired prompts are deleted, paired ones archived, protected ones kept. On the archived shelf (`getShelf()` is - * 'archived') the bar offers Restore instead of the other actions. + * 'archived') the bar offers Restore and Delete… (#492: a permanent delete only) instead of the other actions. */ export const CATEGORY_URL = '/prompt_manager/bulk/category'; export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' }); @@ -168,7 +168,8 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge const shelf = getShelf() === 'archived' ? 'archived' : 'active'; if (shelf !== shownShelf) { shownShelf = shelf; - bulk.replaceChildren(count, ...(shelf === 'archived' ? [restoreButton] : [addTags, setCategory, deleteButton]), clearButton); + // #492: the archived shelf deletes too, after Restore. + bulk.replaceChildren(count, ...(shelf === 'archived' ? [restoreButton, deleteButton] : [addTags, setCategory, deleteButton]), clearButton); } const label = selectionLabel(checked.size); if (count.textContent !== label) count.textContent = label; @@ -271,10 +272,12 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge async function openDelete() { const chosen = target(DELETE_COPY.reviewFailed); if (!chosen) return; + // #492: the shelf the selection was made on; the archived one reviews a permanent delete only. + const shelf = getShelf() === 'archived' ? 'archived' : 'active'; lock(REVIEWING); let preview = null, failure = null; try { - preview = await api.post(PREVIEW_URL, chosen, { signal: scope.signal }); + preview = await api.post(PREVIEW_URL, shelf === 'archived' ? { ...chosen, shelf } : chosen, { signal: scope.signal }); } catch (error) { failure = error; } @@ -286,7 +289,7 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge if (outcome.reload) onReload(); return; } - const review = { ...reviewOf(preview), epoch: chosen.expected_epoch }; + const review = { ...reviewOf(preview, shelf), epoch: chosen.expected_epoch }; // The review is open: the bar stays held behind it, under its own name again. deleteButton.textContent = DELETE_LABEL; openDeleteReview({ scope, trigger: deleteButton, review, titleOf: nameOf, diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 25c9f68b..c26d82a0 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -477,7 +477,7 @@ function requireGrid(grid) { * an option, and `{setTitle(term, text)}` for a value's hover title. */ export function openReviewDialog({ scope, title, heading = null, grid, options = [], sentence = null, backup = null, - cancelLabel, primary, onStart, trigger = null, onDismiss = null }) { + cancelLabel, primary, onStart, trigger = null, onDismiss = null, focusCancel = false }) { requireScope(scope); requireCallbacks({ onStart, ...(onDismiss ? { onDismiss } : {}) }); for (const [name, text] of Object.entries({ title, cancelLabel, label: primary?.label })) { @@ -519,7 +519,9 @@ export function openReviewDialog({ scope, title, heading = null, grid, options = actions.append(cancel, start); nodes.push(actions); // Cancel, ×, Escape, a replacing dialog or the page leaving: everything but the primary is a dismissal. - show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null, review: true }); + // `focusCancel` (#492): a review whose primary cannot be undone opens on Cancel instead of ×. + show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null, review: true, + ...(focusCancel ? { focusTarget: cancel } : {}) }); return { setValue(term, text) { const dd = values.get(term);