From da136415dad57383621a9e2ed2429656d37b46e9 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 29 Oct 2026 12:35:31 -0700 Subject: [PATCH] feat(library): ask Archive or Delete permanently for prompts in a saved pair (#482, review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Delete… review asks, for prompts in a saved pair, Archive (checked on every open, D152's review) or Delete permanently, with their saved pairs, in a D90 radio row. The choice rewrites the grid, sentences, title and primary in place (openReviewDialog setRows/setPrimary), and the body carries the chosen ids with their review digests. On the archived shelf, Show archived prompts now reads the concept's way back, "← Prompt library", as a plain button. TESTING.md records D154. --- TESTING.md | 51 ++++- tests/browser/library-delete-concept.spec.mjs | 3 +- .../browser/workspace-library-delete.spec.mjs | 77 +++++++- .../browser/workspace-library-editor.spec.mjs | 5 +- tests/js/workspace-library-delete.test.js | 87 ++++++++- tests/js/workspace-toolbar.test.js | 36 ++++ web/js/workspace/library-delete.mjs | 181 +++++++++++++++--- web/js/workspace/library-selection.mjs | 5 +- web/js/workspace/library.mjs | 12 +- web/js/workspace/workspace-dialogs.mjs | 30 ++- 10 files changed, 432 insertions(+), 55 deletions(-) diff --git a/TESTING.md b/TESTING.md index ff09299f..99fe289f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2662,14 +2662,15 @@ rulings of 2026-10-28): 504: "The server did not confirm the change. The Library was reloaded: check the shelf and Show archived prompts."; other refusals: "The selected prompts could not be changed. Try again.", the selection kept. -- **Show archived prompts (N)** (heading actions, before Export, `aria-pressed`; N the +- **Show archived prompts (N)** (heading actions, before Export, a plain button; N the archived prompts matching the filters, `archived_total`; the shelf counterpart of #235's Show archived) lists only archived prompts (`GET /prompt_manager/library?shelf=archived`, a cursor never crosses shelves), each card with an "Archived" `.chip`, the caption "N archived prompts · M negative variants hidden with them", the bar "N selected", Restore, - Clear, and "No archived prompts." / "No archived prompts match." when empty. The pressed - state stays with the shelf visit, never in a saved view; Browse and a saved view open the - shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST + Clear, and "No archived prompts." / "No archived prompts match." when empty. On the archived + shelf the button reads the concept's own way back, "← Prompt library" (app.js 594; #482), with + no `aria-pressed` (its label names where it goes). The shelf stays with the shelf visit, never + in a saved view; Browse and a saved view open the active shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST /prompt_manager/library/restore` (every requested archived id, protected or not): "Restored N prompts to the Prompt library." - An archived prompt's page shows the concept's `.notice` ("Archived" / "This prompt is archived @@ -2697,6 +2698,48 @@ failed backup, busy, unconfirmed, restore fence); `tests/browser/library-delete- `tests/test_prompt_archive_schema.py`, `tests/test_bulk_library_delete.py`, `tests/test_library_delete_routes.py`. A Python change: ComfyUI needs a restart. +### Deleting a prompt in a saved pair permanently (#482) + +**D154** (beyond legacy, maintainer ruling 2026-10-29; follow-up to D152): when the Delete… selection +holds prompts in a saved pair that no other positive uses as its negative ("deletable"), the review +asks, in a D90 radio row "Prompts in a saved pair" after the grid: **Archive (hidden, restorable)** +(checked on every open, never remembered, so the review is D152's) or **Delete permanently, with +their saved pairs**. Choosing it rewrites the review in place (`openReviewDialog` `setRows`, +`setPrimary`): +- Delete permanently "D prompts: X not in a saved pair, Y in a saved pair" (or "Y prompts in a saved + pair"), Saved pairs removed "S saved pairs (V with a negative prompt, A archived)", Negatives kept + "K negative prompts are kept", Archive "R prompts used as another prompt's negative: hidden, + restorable", then D152's protected, already archived, no longer exist, Image links removed (images + of every deleted prompt) and Backup rows. +- Sentences: the pairs go with their titles, categories, ratings, notes, tags, run counts and image + links; their negative prompts stay; recorded runs stay in History and Activity by their text; D152's + irreversibility (per backup policy) and re-creation sentences, plus "A pair node run with the same + text saves a new pair."; and, when R > 0, why those are archived. +- Primary (`danger`): "Delete D prompts" / "Delete D and archive R prompts"; a short disk keeps it + unavailable (`aria-disabled`) with the disk sentence, while Archive still starts. + +The preview adds `deletable` (a subset of `archive`, which keeps every archive id for D152 clients), +`review_digests` (sha256 of each deletable prompt's row and its pairs: negative, archived flag, +metadata, usage, tags, image-pair links), `paired_counts` and `paired_image_links`, and plans the +disk whenever anything could be deleted. The write takes optional `delete_paired_ids` with their +`paired_digests` (both or neither; ids must be reviewed archive ids). In one transaction, after the +archives, each chosen prompt whose digest is unchanged loses its image-pair links, then its own saved +pairs (pair tags cascade; review decisions keep their rows with `pair_id` cleared), then itself exactly +as D152 deletes. Any drift (pair added or edited, new link, pairs gone, now another prompt's negative) +archives it and counts it `changed`. A paired delete is backed up and disk-checked as a delete. The +toast adds "Removed S saved pairs."; "N changed since the review and was not deleted." replaces "…was +kept.". Negatives are never deleted: one no remaining pair uses is listed in the Library afterwards (as +after the legacy pair delete). Legacy still refuses paired prompts (409). + +Tests: `tests/test_bulk_library_delete.py` `TestPairedDelete` (classes and counts, the 7896 empty +negative, the row diff against the legacy pair deletes then prompt delete, archive-only, a positive +with its own negative, a self-pair, each drift, validation); `tests/test_library_delete_routes.py` +`TestPairedDeleteRoutes` (preview, paired-only backup, a D152 body, validation, short disk); units +`tests/js/workspace-library-delete.test.js`, `tests/js/workspace-toolbar.test.js` (`setRows`, +`setPrimary`); browser `tests/browser/workspace-library-delete.spec.mjs` (the choice, its grid and +copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python +change: ComfyUI needs a restart. + ### No 1–5 prompt rating in the workspace (#447) **D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps diff --git a/tests/browser/library-delete-concept.spec.mjs b/tests/browser/library-delete-concept.spec.mjs index 6f373449..5b457e80 100644 --- a/tests/browser/library-delete-concept.spec.mjs +++ b/tests/browser/library-delete-concept.spec.mjs @@ -88,7 +88,8 @@ function mapDeleteConcept({ danger, review }) { return { ...review, title: 'Delete or archive selected prompts', - tree: ['dl.meta-grid', 'p.small', 'p.small', 'div.dialog-actions'], + // #482: the paired prompt is deletable, so the review asks Archive or Delete permanently (a D90 radio row). + tree: ['dl.meta-grid', 'div.field', 'p.small', 'p.small', 'div.dialog-actions'], grid: { ...review.grid, rows: [['Delete permanently', '1 prompt not in a saved pair'], ['Archive', '1 prompt in a saved pair: hidden, restorable'], ['Image links removed', '0 images will no longer show these prompts (files stay on disk)'], diff --git a/tests/browser/workspace-library-delete.spec.mjs b/tests/browser/workspace-library-delete.spec.mjs index 59b72af8..306e42a6 100644 --- a/tests/browser/workspace-library-delete.spec.mjs +++ b/tests/browser/workspace-library-delete.spec.mjs @@ -13,6 +13,8 @@ const bar=page=>page.locator('#library-bulkbar > .bulkbar'); const toast=page=>page.locator('.toast'); const review=page=>page.getByRole('dialog'); const showArchived=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:/^Show archived prompts/}); +// #482: on the archived shelf the same button reads the concept's way back (app.js 594 "← Prompt library"). +const backToLibrary=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'← Prompt library',exact:true}); async function items(request,shelf='active') { const query=shelf==='archived'?'&shelf=archived':''; @@ -83,9 +85,11 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click(); await expect(toast(page)).toHaveText('Archived 2 prompts.'); await expect(cards(page)).toHaveCount(before.length-2); - await expect(showArchived(page)).toHaveAttribute('aria-pressed','false'); + // #482: a plain button that names where it goes (no pressed state that its changing label would contradict). + await expect(showArchived(page)).not.toHaveAttribute('aria-pressed'); await showArchived(page).click(); - await expect(showArchived(page)).toHaveAttribute('aria-pressed','true'); + await expect(backToLibrary(page)).toBeVisible();await expect(backToLibrary(page)).not.toHaveAttribute('aria-pressed'); + await expect(showArchived(page)).toHaveCount(0); await expect(cards(page)).toHaveCount(2); 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); @@ -94,7 +98,8 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur 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); - await showArchived(page).click(); + await backToLibrary(page).click(); + await expect(showArchived(page)).toHaveText('Show archived prompts (1)'); await expect(cards(page)).toHaveCount(before.length-1); await expect(card(page,cat.id)).toHaveCount(1); expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]); @@ -104,6 +109,72 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur await expect(page.locator('.library-status')).toHaveText('No archived prompts.'); }); +// #482 (D154): a prompt in a saved pair can be deleted permanently, with its own saved pairs; Archive stays the default. +const choice=page=>review(page).getByRole('radiogroup',{name:'Prompts in a saved pair'}); +const row=(page,term)=>review(page).locator('dl.meta-grid > div').filter({has:page.locator('dt',{hasText:new RegExp(`^${term}$`)})}).locator('dd'); +test('Delete permanently removes a paired prompt with its own saved pair; its negative stays (#482)',async({page,request})=>{ + const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait'); + const writes=recordPosts(page,DELETE); + await page.goto(route);await expect(cards(page)).toHaveCount(before.length); + await box(page,dual.id).check();await openReview(page); + // Archive is checked: the review is #443's. + await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked(); + // The D90 radio row (the auto-tag "How to apply"): each radio keeps its own size beside its label. + expect(await choice(page).evaluate(group=>{const label=group.querySelector('label'),radio=label.querySelector('input'); + return {group:group.className,display:getComputedStyle(label).display,gap:getComputedStyle(label).columnGap, + margin:getComputedStyle(radio).marginLeft,full:radio.getBoundingClientRect().width>=label.getBoundingClientRect().width/2};})) + .toEqual({group:'field',display:'flex',gap:'8px',margin:'0px',full:false}); + await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible(); + await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check(); + await expect(review(page)).toHaveAccessibleName('Delete selected prompts'); + await expect(row(page,'Delete permanently')).toHaveText('1 prompt in a saved pair'); + await expect(row(page,'Saved pairs removed')).toHaveText('1 saved pair (1 with a negative prompt)'); + await expect(row(page,'Negatives kept')).toHaveText('1 negative prompt is kept'); + await expect(row(page,'Backup')).toHaveText('Create and verify before deleting'); + await expect(review(page).locator('p.small').first()).toHaveText(/^Deleting a prompt in a saved pair also removes its saved pairs/); + // Back to Archive: the review is #443's again. + await choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'}).check(); + await expect(row(page,'Saved pairs removed')).toHaveCount(0); + await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible(); + await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check(); + 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: /); + await expect(card(page,dual.id)).toHaveCount(0); + expect(writes).toHaveLength(1); + expect(writes[0].delete_paired_ids).toEqual([dual.id]); + expect(Object.keys(writes[0].paired_digests)).toEqual([String(dual.id)]); + const after=await items(request); + expect(byText(after,'Dual role portrait')).toBeUndefined(); + // Its negative (Cat portrait) is kept, with its own variants. + expect(byText(after,'Cat portrait')?.id).toBe(cat.id); + expect((await items(request,'archived')).map(item=>item.id)).toEqual([]); +}); + +test('a prompt used as another prompt\'s negative is archived even when Delete permanently is chosen (#482)',async({page,request})=>{ + const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait'); + await page.goto(route);await expect(cards(page)).toHaveCount(before.length); + await box(page,cat.id).check();await box(page,dual.id).check();await openReview(page); + await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check(); + await expect(row(page,'Archive')).toHaveText('1 prompt used as another prompt\'s negative: hidden, restorable'); + await expect(review(page).locator('p.small',{hasText:'archived instead of deleted'})).toHaveCount(1); + await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Archived 1 prompt\./); + expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]); +}); + +test('the choice resets to Archive on every Delete…, and a selection with no paired prompt has none (#482)',async({page,request})=>{ + const before=await items(request);const dual=byText(before,'Dual role portrait'),dog=byText(before,'Dog landscape'); + await page.goto(route);await box(page,dual.id).check();await openReview(page); + await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check(); + await review(page).getByRole('button',{name:'Cancel',exact:true}).click(); + await openReview(page); + await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked(); + await review(page).getByRole('button',{name:'Cancel',exact:true}).click(); + await bar(page).getByRole('button',{name:'Clear',exact:true}).click(); + await box(page,dog.id).check();await openReview(page); + await expect(choice(page)).toHaveCount(0); +}); + test('an archived prompt\'s page says so and restores it from its notice',async({page,request})=>{ const cat=byText(await items(request),'Cat portrait'); const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch; diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index 2a17b3e3..d646291e 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -493,9 +493,10 @@ test('a restore seen finishing keeps New prompt open and reloads the Library aft test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{ await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible(); const actions=headingActions(page).locator('button'); - // D152 (#443): Show archived prompts (N) comes first, a plain toggle. + // D152 (#443): Show archived prompts (N) comes first, a plain button (#482: no pressed state; on the archived shelf it + // reads "← Prompt library"). expect(await visibleNames(actions)).toEqual(['Show archived prompts (0)','Export','+ New prompt']); - await expect(actions.nth(0)).toHaveAttribute('aria-pressed','false');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/); + await expect(actions.nth(0)).not.toHaveAttribute('aria-pressed');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/); // One image is counted in the singular. const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1); await expect(actions.nth(1)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(1)).not.toHaveClass(/ca-primary/); diff --git a/tests/js/workspace-library-delete.test.js b/tests/js/workspace-library-delete.test.js index b80eb68e..ce3c3aba 100644 --- a/tests/js/workspace-library-delete.test.js +++ b/tests/js/workspace-library-delete.test.js @@ -91,10 +91,10 @@ test('a delete outcome counts each part, singular and plural, and names the back toast: 'Deleted 2 prompts. Archived 3 prompts. 1 protected prompt was kept. Safety backup saved: prompts.db.backup_x (System › Backup & export).'}); assert.equal(deleteOutcome({success: true, deleted: 1, archived: 0, already_archived: 1, not_found: 1, changed: 1, backup: null}, null, reviewOf(preview())).toast, - 'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was kept.'); + 'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was not deleted.'); assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0, already_archived: 2, not_found: 3, changed: 2, backup: null}, null, reviewOf(preview({protected: [1, 2]}))).toast, - 'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were kept. 2 protected prompts were kept. The Library was reloaded.'); + 'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were not deleted. 2 protected prompts were kept. The Library was reloaded.'); assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0}, null, reviewOf(preview())).committed, false); }); @@ -134,7 +134,8 @@ test('reviewOf keeps only well-formed ids and counts from an answer', async () = 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, - imageLinks: 2, backup: 'on', disk: null}); + imageLinks: 2, backup: 'on', disk: null, deletable: [], digests: {}, + pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive'}); }); test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => { @@ -148,3 +149,83 @@ test('the archived notice says why (the saved pair) and names negative variants // An unknown count (an older server) never claims variants. assert.equal(archivedNoticeText(undefined), archivedNoticeText(0)); }); + +// #482 (D154): prompts in a saved pair can be deleted permanently, with their own saved pairs. +const D1 = 'a'.repeat(64), D2 = 'b'.repeat(64); +const paired = (patch = {}) => preview({archive: [3, 4, 5], deletable: [3, 4], review_digests: {3: D1, 4: D2}, + paired_counts: {pairs: 3, archived_pairs: 1, variants: 2, kept_negatives: 2}, paired_image_links: 4, ...patch}); + +test('reviewOf reads the deletable ids, their digests and the pair counts; the choice starts at Archive', async () => { + const {reviewOf, hasPairedChoice} = await source; + const review = reviewOf(paired({deletable: [3, 4, 99], review_digests: {3: D1, 4: 'short', 99: D1}})); + // Only reviewed archive ids with a well-formed digest can be deleted permanently. + assert.deepEqual(review.deletable, [3]); + assert.deepEqual(review.digests, {3: D1}); + assert.deepEqual(review.pairedCounts, {pairs: 3, archivedPairs: 1, variants: 2, keptNegatives: 2}); + assert.equal(review.pairedImageLinks, 4); + assert.equal(review.choice, 'archive'); + assert.equal(hasPairedChoice(review), true); + assert.equal(hasPairedChoice(reviewOf(preview({archive: [5]}))), false); +}); + +test('Archive chosen keeps the #443 review; Delete permanently names the pairs, the kept negatives and archive-only', async () => { + const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel, deleteBody} = await source; + const review = reviewOf(paired({delete: [1], image_links: 2})); + assert.deepEqual(reviewGrid(review).map(([term]) => term), ['Delete permanently', 'Archive', 'Image links removed', 'Backup']); + assert.equal(primaryLabel(review), 'Delete 1 and archive 3 prompts'); + assert.deepEqual(deleteBody(review), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on'}); + const permanent = withChoice(review, 'delete'); + assert.notEqual(permanent, review); + assert.equal(review.choice, 'archive'); + assert.deepEqual(reviewGrid(permanent), [ + ['Delete permanently', '3 prompts: 1 not in a saved pair, 2 in a saved pair'], + ['Saved pairs removed', '3 saved pairs (2 with a negative prompt, 1 archived)'], + ['Negatives kept', '2 negative prompts are kept'], + ['Archive', '1 prompt used as another prompt\'s negative: hidden, restorable'], + ['Image links removed', '6 images will no longer show these prompts (files stay on disk)'], + ['Backup', 'Create and verify before deleting'], + ]); + assert.equal(reviewTitle(permanent), 'Delete or archive selected prompts'); + assert.equal(primaryLabel(permanent), 'Delete 3 and archive 1 prompt'); + assert.deepEqual(deleteBody(permanent), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on', + delete_paired_ids: [3, 4], paired_digests: {3: D1, 4: D2}}); +}); + +test('edge counts: one prompt, no negative, nothing archive-only', async () => { + const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel} = await source; + // Live prompt 7896: one pair with an explicitly empty negative. + const one = withChoice(reviewOf(preview({archive: [7], deletable: [7], review_digests: {7: D1}, + paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}, paired_image_links: 0})), 'delete'); + assert.deepEqual(reviewGrid(one), [ + ['Delete permanently', '1 prompt in a saved pair'], + ['Saved pairs removed', '1 saved pair'], + ['Image links removed', '0 images will no longer show these prompts (files stay on disk)'], + ['Backup', 'Create and verify before deleting'], + ]); + assert.equal(reviewTitle(one), 'Delete selected prompts'); + assert.equal(primaryLabel(one), 'Delete 1 prompt'); +}); + +test('Delete permanently sentences: what goes with the pairs, what stays, and how it comes back', async () => { + const {reviewOf, withChoice, reviewSentences} = await source; + const [pairs, remove, archive] = reviewSentences(withChoice(reviewOf(paired()), 'delete')); + assert.equal(pairs, '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.'); + assert.match(remove, /the only way back is to restore the safety backup/); + assert.match(remove, /A pair node run with the same text saves a new pair\.$/); + assert.equal(archive, 'Prompts used as another prompt\'s negative are archived instead of deleted, so that ' + + 'prompt\'s variant stays.'); + const [, off] = reviewSentences(withChoice(reviewOf(paired({backup: 'off'})), 'delete')); + assert.match(off, /Deleted prompts cannot be brought back\./); +}); + +test('a short disk blocks only the permanent delete; the outcome names removed pairs', async () => { + const {reviewOf, withChoice, blockedReason, deleteOutcome} = await source; + const short = reviewOf(paired({disk: {sufficient: false, message: 'Not enough space. Nothing was changed.'}})); + assert.equal(blockedReason(short), null); + assert.equal(blockedReason(withChoice(short, 'delete')), 'Not enough space. Nothing was changed.'); + const {toast} = deleteOutcome({success: true, deleted: 2, archived: 1, pairs_removed: 3, changed: 1}, null, short); + assert.equal(toast, 'Deleted 2 prompts. Removed 3 saved pairs. Archived 1 prompt. 1 changed since the review and ' + + 'was not deleted.'); +}); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index a776db41..0f47d5d1 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -859,6 +859,42 @@ test('backupNotice is the concept .notice with its copy and one Back up now acti } }); +test('openReviewDialog replaces its rows and its primary in place (#482)', async () => { + const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule; + const { createScope } = await coreModule; + const dom = fakeDOM(); + const scope = createScope(); + try { + const started = []; + const review = openReviewDialog({ scope, title: 'Delete or archive', grid: [['Archive', '2 prompts']], + cancelLabel: 'Cancel', primary: { label: 'Archive 2 prompts', danger: true }, onStart: () => started.push('start') }); + review.setRows([['Delete permanently', '2 prompts'], ['Saved pairs removed', '3 saved pairs']]); + const grid = byId(dom, 'modal-body').children[0]; + assert.deepEqual(grid.children.map(entry => [entry.children[0].textContent, entry.children[1].textContent]), + [['Delete permanently', '2 prompts'], ['Saved pairs removed', '3 saved pairs']]); + // setValue reaches the new rows only. + review.setValue('Saved pairs removed', '4 saved pairs'); + review.setValue('Archive', 'x'); + assert.equal(grid.children[1].children[1].textContent, '4 saved pairs'); + const start = button(byId(dom, 'modal-body'), 'Archive 2 prompts'); + review.setPrimary({ label: 'Delete 2 prompts', disabled: true }); + assert.equal(start.textContent, 'Delete 2 prompts'); + assert.equal(start.getAttribute('aria-disabled'), 'true'); + start.click(); + assert.deepEqual(started, []); + assert.equal(modal(dom).open, true); + review.setPrimary({ label: 'Delete 2 prompts', disabled: false }); + assert.equal(start.getAttribute('aria-disabled'), 'false'); + start.click(); + assert.deepEqual(started, ['start']); + assert.throws(() => review.setRows([['only a term']]), TypeError); + } finally { + scope.dispose(); + closeWorkspaceDialog(); + dom.restore(); + } +}); + test('openReviewDialog lays out heading, grid, options, sentence, backup box and actions', async () => { const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule; const { createScope } = await coreModule; diff --git a/web/js/workspace/library-delete.mjs b/web/js/workspace/library-delete.mjs index 51de4868..52062a79 100644 --- a/web/js/workspace/library-delete.mjs +++ b/web/js/workspace/library-delete.mjs @@ -34,6 +34,7 @@ export const COPY = Object.freeze({ const count = value => (Number.isSafeInteger(value) && value >= 0 ? value : 0); const ids = value => (Array.isArray(value) ? value.filter(id => Number.isSafeInteger(id) && id > 0) : []); 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) { @@ -43,12 +44,43 @@ export function reviewOf(preview) { 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), }; } +/** #482: the archive ids that may be deleted permanently (each with its review digest), and the pair counts. */ +function pairedOf(preview) { + const archive = new Set(ids(preview?.archive)), given = preview?.review_digests ?? {}; + const deletable = ids(preview?.deletable).filter(id => archive.has(id) && DIGEST.test(String(given?.[id] ?? ''))); + const counts = preview?.paired_counts ?? {}; + return { + deletable, digests: Object.fromEntries(deletable.map(id => [id, given[id]])), + pairedCounts: { pairs: count(counts.pairs), archivedPairs: count(counts.archived_pairs), + variants: count(counts.variants), keptNegatives: count(counts.kept_negatives) }, + pairedImageLinks: count(preview?.paired_image_links), choice: 'archive', + }; +} + +/** #482: whether the review asks what to do with prompts in a saved pair. */ +export const hasPairedChoice = 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) { + return { ...review, choice: choice === 'delete' && hasPairedChoice(review) ? 'delete' : 'archive' }; +} + +const permanent = review => review.choice === 'delete'; +/** What is deleted and archived: Delete permanently moves the deletable ids from archive to delete. */ +function plan(review) { + if (!permanent(review)) return { plain: review.delete, paired: [], archive: review.archive }; + const chosen = new Set(review.deletable); + return { plain: review.delete, paired: review.deletable, archive: review.archive.filter(id => !chosen.has(id)) }; +} + export function reviewTitle(review) { - if (!review.archive.length) return 'Delete selected prompts'; - if (!review.delete.length) return 'Archive selected prompts'; + const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length; + if (!archive.length) return 'Delete selected prompts'; + if (!deletes) return 'Archive selected prompts'; return 'Delete or archive selected prompts'; } @@ -70,49 +102,90 @@ export function archivedNoticeText(variantCount) { /** The review's `[term, value]` rows (design §7.2); rows that do not apply are left out. */ export function reviewGrid(review, titleOf = () => null) { + if (permanent(review)) return permanentGrid(review, titleOf); const rows = [ ['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`], // The archive reason is the saved pair (a pair's positive or negative), with or without negative variants. ['Archive', `${countOf(review.archive.length, 'prompt')} in a saved pair: hidden, restorable`], ]; + keptRows(rows, review, titleOf); + if (review.delete.length) deleteRows(rows, review, review.imageLinks); + return rows; +} + +function keptRows(rows, review, titleOf) { if (review.protected.length) { const names = protectedNames(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.notFound) rows.push(['No longer exist', review.notFound.toLocaleString('en-US')]); - if (review.delete.length) { - rows.push(['Image links removed', `${countOf(review.imageLinks, 'image')} will no longer show these prompts (files stay on disk)`]); - rows.push(['Backup', review.backup === 'on' ? 'Create and verify before deleting' - : 'Off: no safety backup (System › Backup & export › Recovery policy)']); - } +} + +function deleteRows(rows, review, images) { + rows.push(['Image links removed', `${countOf(images, 'image')} will no longer show these prompts (files stay on disk)`]); + rows.push(['Backup', review.backup === 'on' ? 'Create and verify before deleting' + : 'Off: no safety backup (System › Backup & export › Recovery policy)']); +} + +/** #482: the grid when Delete permanently is chosen. */ +function permanentGrid(review, titleOf) { + const { plain, paired, archive } = plan(review), counts = review.pairedCounts; + const parts = [plain.length && `${plain.length.toLocaleString('en-US')} not in a saved pair`, + `${paired.length.toLocaleString('en-US')} in a saved pair`].filter(Boolean); + const total = plain.length + paired.length; + 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`]); + keptRows(rows, review, titleOf); + deleteRows(rows, review, review.imageLinks + review.pairedImageLinks); return rows; } /** The review's sentences, in order, each when it applies. */ export function reviewSentences(review) { + if (permanent(review)) return permanentSentences(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.'); } - if (review.delete.length) { - const undo = review.backup === 'on' - ? ' Deleted prompts cannot be brought back one by one: 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.' - : ' Deleted prompts cannot be brought back.'; - sentences.push("Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " + - `image links.${undo} Image files, favorites, collections and recorded runs stay. A later scan, a new image or a ` + - 'node run with the same text adds it again as a new prompt.'); - } - if (review.protected.length) sentences.push('Protected prompts are tagged __protected__; remove the tag to delete them.'); + if (review.delete.length) sentences.push(removeSentence(review)); + if (review.protected.length) sentences.push(PROTECTED_SENTENCE); + return sentences; +} + +const PROTECTED_SENTENCE = 'Protected prompts are tagged __protected__; remove the tag to delete them.'; +function removeSentence(review, pairs = false) { + const undo = review.backup === 'on' + ? ' Deleted prompts cannot be brought back one by one: 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.' + : ' Deleted prompts cannot be brought back.'; + return "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " + + `image links.${undo} Image files, favorites, collections and recorded runs stay. A later scan, a new image or a ` + + `node run with the same text adds it again as a new prompt.${pairs ? ' A pair node run with the same text saves a new pair.' : ''}`; +} + +/** #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, " + + "so that prompt's variant stays."); + if (review.protected.length) sentences.push(PROTECTED_SENTENCE); return sentences; } export function primaryLabel(review) { - const deletes = review.delete.length, archives = review.archive.length; + const { plain, paired, archive } = plan(review); + const deletes = plain.length + paired.length, archives = archive.length; if (deletes && archives) return `Delete ${deletes.toLocaleString('en-US')} and archive ${countOf(archives, 'prompt')}`; return deletes ? `Delete ${countOf(deletes, 'prompt')}` : `Archive ${countOf(archives, 'prompt')}`; } @@ -120,7 +193,8 @@ export function primaryLabel(review) { /** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */ export function blockedReason(review) { if (!review.delete.length && !review.archive.length) return COPY.none; - if (review.delete.length && review.disk && review.disk.sufficient === false) { + const deletes = review.delete.length + plan(review).paired.length; + if (deletes && review.disk && review.disk.sufficient === false) { return typeof review.disk.message === 'string' && review.disk.message ? review.disk.message : 'There is not enough free space for the safety backup. Nothing was changed.'; } @@ -133,21 +207,69 @@ export function blockedReason(review) { * close `onDismiss()`. */ export function openDeleteReview({ scope, trigger, review, titleOf, onStart, onDismiss }) { - const title = reviewTitle(review), grid = reviewGrid(review, titleOf); - const options = reviewSentences(review).map(text => Object.assign(document.createElement('p'), { className: 'small', textContent: text })); - const blocked = blockedReason(review); + let current = withChoice(review, 'archive'); + const paragraphs = target => reviewSentences(target).map(text => paragraph(text)); + const title = reviewTitle(current), grid = reviewGrid(current, titleOf); + let sentences = paragraphs(current); + const blocked = blockedReason(current); if (blocked) { onDismiss(); - openCloseOnlyReview({ scope, trigger, title, grid, options, sentence: blocked }); + openCloseOnlyReview({ scope, trigger, title, grid, options: sentences, sentence: blocked }); return; } - openReviewDialog({ scope, trigger, title, grid, options, cancelLabel: 'Cancel', - primary: { label: primaryLabel(review), danger: true }, onStart, onDismiss }); + // #482: prompts in a saved pair ask Archive (the default, as #443) or Delete permanently, with their pairs. + 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 }); + function update(value) { + current = withChoice(review, value); + dialog.setRows(reviewGrid(current, titleOf)); + const reason = blockedReason(current); + const next = paragraphs(current); + if (reason) next.push(paragraph(reason)); + for (const node of sentences) node.remove(); + choice.after(...next); + sentences = next; + const heading = choice.closest('dialog')?.querySelector('#modal-title'); + if (heading) heading.textContent = reviewTitle(current); + dialog.setPrimary({ label: primaryLabel(current), disabled: Boolean(reason) }); + } +} + +const paragraph = text => Object.assign(document.createElement('p'), { className: 'small', textContent: text }); +export const PAIRED_CHOICES = Object.freeze([ + Object.freeze({ value: 'archive', label: 'Archive (hidden, restorable)' }), + Object.freeze({ value: 'delete', label: 'Delete permanently, with their saved pairs' }), +]); + +/** #482: the D90 radio row ("Prompts in a saved pair"), Archive checked; it is built fresh for every review. */ +function pairedChoice(onChange) { + const group = document.createElement('div'); + group.className = 'field'; + group.setAttribute('role', 'radiogroup'); + const caption = document.createElement('span'); + caption.textContent = 'Prompts in a saved pair'; + caption.id = 'library-delete-choice-label'; + group.setAttribute('aria-labelledby', caption.id); + group.append(caption); + for (const choice of PAIRED_CHOICES) { + const label = document.createElement('label'); + const input = document.createElement('input'); + Object.assign(input, { type: 'radio', name: 'library-delete-choice', value: choice.value, checked: choice.value === 'archive' }); + input.addEventListener('change', () => { if (input.checked) onChange(choice.value); }); + label.append(input, ` ${choice.label}`); + group.append(label); + } + return group; } /** The write's body: the reviewed lists, the epoch and the reviewed backup promise. */ export function deleteBody(review) { - return { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup }; + const body = { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup }; + // #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; } function failureOutcome(failure, copy) { @@ -172,14 +294,15 @@ export function deleteOutcome(result, failure, review) { return failureOutcome(failure, { storage: COPY.storage, unconfirmed: COPY.unconfirmed, failed: COPY.failed }); } const deleted = count(result.deleted), archived = count(result.archived); - const parts = []; + const parts = [], pairs = count(result.pairs_removed); 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); 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.`); 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'} kept.`); + 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; if (kept) parts.push(`${countOf(kept, 'protected prompt')} ${kept === 1 ? 'was' : 'were'} kept.`); if (typeof result.backup === 'string' && result.backup) parts.push(`Safety backup saved: ${result.backup} (System › Backup & export).`); diff --git a/web/js/workspace/library-selection.mjs b/web/js/workspace/library-selection.mjs index 9a7af623..6c5f02a0 100644 --- a/web/js/workspace/library-selection.mjs +++ b/web/js/workspace/library-selection.mjs @@ -291,8 +291,9 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge deleteButton.textContent = DELETE_LABEL; openDeleteReview({ scope, trigger: deleteButton, review, titleOf: nameOf, onDismiss: unlock, - onStart: () => write(DELETE_URL, deleteBody(review), 'delete', (result, error) => deleteOutcome(result, error, review), - review.delete.length ? 'Deleting…' : 'Archiving…') }); + // #482: the review as chosen (Archive, or Delete permanently with the prompts' saved pairs). + onStart: chosen => write(DELETE_URL, deleteBody(chosen), 'delete', (result, error) => deleteOutcome(result, error, chosen), + chosen.delete.length || chosen.choice === 'delete' ? 'Deleting…' : 'Archiving…') }); } /** Restore (#443): the selected archived prompts go back on the shelf. */ function restoreSelected() { diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 712f8fd7..761e810b 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -194,12 +194,15 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true; const actions=headingActions(main,section); const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON'); - // #443 D152: the shelf's counterpart of Negative variants' Show archived (#235); N follows the filters. - const showArchived=createButton('Show archived prompts');showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived')); + // #443 D152: the shelf's counterpart of Negative variants' Show archived (#235); N follows the filters. #482: on + // the archived shelf it reads the concept's own way back ("← Prompt library", app.js 594); a plain button whose + // label names where it goes, so it carries no pressed state its changing label would contradict. + const BACK_TO_LIBRARY='← Prompt library'; + const showArchived=createButton(filters.shelf==='archived'?BACK_TO_LIBRARY:'Show archived prompts'); const newPrompt=createButton('+ New prompt','ca-primary');actions.append(showArchived,exportButton,newPrompt); scope.listen(showArchived,'click',()=>{ if(selection.writing()) return; - filters.shelf=filters.shelf==='archived'?'active':'archived';showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived')); + filters.shelf=filters.shelf==='archived'?'active':'archived'; reset();selection.shelfChanged(); }); const exportStatus=el('p','','library-options-status');exportStatus.hidden=true;exportStatus.setAttribute('role','alert'); @@ -408,7 +411,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on :`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`; if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false; } - const archivedLabel=next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`; + const archivedLabel=filters.shelf==='archived'?BACK_TO_LIBRARY + :next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`; if(showArchived.textContent!==archivedLabel) showArchived.textContent=archivedLabel; const filtered=Boolean(filters.text || filters.category || filters.tags.length || filters.folder || filters.tagMode!=='all'); const empty=filters.shelf==='archived'?(filtered?'No archived prompts match.':'No archived prompts.'):'No matching prompts. Try changing the filters.'; diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 9b114335..25c9f68b 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -491,13 +491,17 @@ export function openReviewDialog({ scope, title, heading = null, grid, options = if (heading) nodes.push(el('h3', heading)); const list = el('dl', undefined, 'meta-grid'); const values = new Map(); - for (const [term, value] of grid) { - const entry = el('div'); - const dd = el('dd', value); - values.set(term, dd); - entry.append(el('dt', term), dd); - list.append(entry); - } + const fill = rows => { + values.clear(); + list.replaceChildren(...rows.map(([term, value]) => { + const entry = el('div'); + const dd = el('dd', value); + values.set(term, dd); + entry.append(el('dt', term), dd); + return entry; + })); + }; + fill(grid); nodes.push(list, ...options); if (sentence) nodes.push(el('p', sentence, 'small')); if (backup) nodes.push(backupNotice(backup)); @@ -506,6 +510,7 @@ export function openReviewDialog({ scope, title, heading = null, grid, options = let started = false; cancel.addEventListener('click', () => finish()); start.addEventListener('click', () => { + if (start.getAttribute('aria-disabled') === 'true') return; started = true; finish(); onStart(); @@ -524,6 +529,17 @@ export function openReviewDialog({ scope, title, heading = null, grid, options = const dd = values.get(term); if (dd) dd.title = text; }, + /** #482: the rows a choice in the review changes (`[term, value]` pairs, as `grid`). */ + setRows(rows) { + requireGrid(rows); + fill(rows); + }, + /** #482: the primary's label, and whether it can start (an unavailable start stays in place, aria-disabled). */ + setPrimary({ label, disabled = false }) { + if (typeof label !== 'string' || !label) throw new TypeError('The review needs its label'); + start.textContent = label; + start.setAttribute('aria-disabled', String(Boolean(disabled))); + }, }; }