From 323f2617d8f4ca4bac27beaa59d89434058b0655 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 28 Oct 2026 18:51:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(library):=20Delete=E2=80=A6=20on=20the=20s?= =?UTF-8?q?election=20bar,=20Show=20archived=20prompts=20and=20Restore=20(?= =?UTF-8?q?#443)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Library bar's Delete… previews the selection and opens the shared review: what is deleted (in no saved pair), archived (paired: hidden, restorable) or kept (tagged __protected__), the safety backup and what cannot be undone. Show archived prompts (N) lists archived prompts with Restore on their bar; an archived prompt's page shows an Archived notice with Restore. TESTING.md records D152; the route snapshot gains the three workspace routes. --- TESTING.md | 96 ++++++++- tests/browser/library-concept.spec.mjs | 7 +- tests/browser/library-delete-concept.spec.mjs | 147 +++++++++++++ tests/browser/system-backups-concept.spec.mjs | 4 +- .../browser/workspace-library-delete.spec.mjs | 169 +++++++++++++++ .../workspace-library-selection.spec.mjs | 5 +- .../browser/workspace-system-backups.spec.mjs | 4 +- tests/fixtures/legacy_contract_snapshot.json | 3 + tests/js/workspace-library-delete.test.js | 126 +++++++++++ web/js/workspace/library-delete.mjs | 199 ++++++++++++++++++ web/js/workspace/library-selection.mjs | 84 ++++++-- web/js/workspace/library-state.mjs | 15 +- web/js/workspace/library.mjs | 50 ++++- web/js/workspace/system-backups.mjs | 4 +- 14 files changed, 880 insertions(+), 33 deletions(-) create mode 100644 tests/browser/library-delete-concept.spec.mjs create mode 100644 tests/browser/workspace-library-delete.spec.mjs create mode 100644 tests/js/workspace-library-delete.test.js create mode 100644 web/js/workspace/library-delete.mjs diff --git a/TESTING.md b/TESTING.md index 0f4b3233..c37ae94b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2210,7 +2210,8 @@ Category". It is built only from the concept's Images parts: input[type=checkbox]`); Home, End and the arrow keys still move between titles. - `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). The + 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 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 @@ -2291,6 +2292,95 @@ during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison no Add tags); units `tests/js/workspace-library-selection.test.js`; Python `tests/test_bulk_library_tags.py`. A Python change: ComfyUI needs a restart. +### Deleting and archiving selected prompts (#443) + +**D152** (beyond the concept, on D148's bar; the concept archives a prompt from its recipe +editor's "Archive…", `app.js` 976, reviewed in `maintenancePlan`, 953–958, and maps the legacy +delete to a "reference-safe archive", 783): the Prompt library deletes or archives the +selected prompts in one reviewed step, replacing the legacy admin page's bulk and single +Delete, which refuses a whole batch when any prompt belongs to a saved pair (maintainer +rulings of 2026-10-28): + +- The bar reads "N selected", [Select all N loaded], Add tags, Set category, **Delete…** + (the concept's `danger` class, as its Images bar's "Remove links…"), Clear. +- Delete… posts `{prompt_ids, expected_epoch}` to `POST + /prompt_manager/library/delete/preview`, which classifies each id in this order: not + found (missing or not a Library parent), protected (tagged `__protected__`), already + archived, archive (the positive or negative of any saved pair, archived pair or not), + delete (everything else). From the press until the review opens, every bar action, Clear + too, is `aria-disabled` and Delete… reads "Reviewing…" (a double press sends one preview). +- The review is the shared record-changing review (`openReviewDialog`), titled "Delete + selected prompts", "Archive selected prompts" or "Delete or archive selected prompts", its + `dl.meta-grid` rows Delete permanently and Archive (always), Protected (kept) with up to + three titles, Already archived, No longer exist, and, when something is deleted, Image + links removed and Backup ("Create and verify before deleting", or "Off: no safety backup + (System › Backup & export › Recovery policy)"); then `p.small` sentences: why paired + prompts are archived and how to restore them, what a delete removes and that it cannot be + undone except by restoring the safety backup (which returns the whole library to that + moment; kept among the last 10), that a later scan, new image or node run with the same + text adds a deleted prompt again, and how to unprotect. The primary is `danger`: "Delete D + prompts", "Archive A prompts" or "Delete D and archive A prompts". When nothing can change, + or the disk has no room for the promised backup, the review is close-only with the reason. + The concept's "Review maintenance plan" title, its demo note, Scope/Protected/Media on + disk rows and `ca-primary` start are replaced by these. +- Start posts the reviewed lists `{archive_ids, delete_ids, expected_epoch, backup}` to `POST + /prompt_manager/library/delete` while Delete… reads "Deleting…" (or "Archiving…"). The + server takes a `records` hold ("Delete prompts (Library)", refused 409 `busy` beside a scan, + restore, maintenance, move or Optimize), checks the disk (507 `insufficient-space`) and, + when a delete remains and the reviewed `backup` was "on" or the policy is on now, takes a + verified safety backup (500 `backup-failed`, nothing changed). One `BEGIN IMMEDIATE` + transaction then deletes only reviewed deletes still unpaired whose prompt row, tags, + title and image links are unchanged since the backup (exactly the legacy rows: image + link rows, cascaded tags, Library state and pair links of images, released workflow + documents; files never), archives the paired ones and any reviewed delete now paired, and + counts the rest (`already_archived`, `not_found`, `changed`). Archiving writes a + `prompt_archive` row, the prompt's `updated_at` and, when its stored hash is stale or + missing and no other row holds the canonical one, the canonical hash, so scans, the + monitor and node saves find the archived row instead of adding a copy. A changed epoch + answers 409 `storage-changed`; nothing is written. +- Outcomes: "Deleted D prompts." "Archived A prompts." with already archived, no longer + exist, changed and kept-protected sentences and "Safety backup saved: NAME (System › + Backup & export)."; the shelf, categories, cached Library views and sidebar counts reload. + Storage restored: "The library was restored or moved since this page loaded. Nothing was + deleted or archived."; busy: "NAME is running. Nothing was changed; try again when it + finishes."; a failed backup or a short disk: the server's sentence; no answer, 502, 503 or + 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 + 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 + /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 and its + negative variants are hidden from the Prompt library; nothing was removed.") with Restore; + everything else on the page works. New prompt or Add negative on an archived prompt's + text keeps it archived and toasts "This text is an archived prompt. Restore it to show it + in the Prompt library again." +- Archived prompts: hidden from the shelf, its search, `total`, the sidebar count, Tags & + categories' prompt counts (a value only archived prompts hold stays listed with 0) and + System › Overview and the restore review's positive counts; still counted by rename and + delete impacts and usage statistics; still shown as another prompt's negative variant, on + recorded runs and Images, by the legacy pages and the ComfyUI node's search; still reached + by run counting, scans and the monitor; never pruned by maintenance (a new protection, + "archived in the Library"); exported with `archived: true`. Older builds ignore the table + and show them again; the rows stay. +- Deviation from legacy: a selected `__protected__` prompt is never deleted or archived (the + legacy delete removed it); it is listed in the review and the toast. + +Tests: `tests/browser/workspace-library-delete.spec.mjs` (mixed review and result on the +server, Cancel, Show archived prompts and Restore from the bar and the notice, double press, +failed backup, busy, unconfirmed, restore fence); `tests/browser/library-delete-concept.spec.mjs` +(the review against the concept's `maintenancePlan`, the bar's Delete… against the concept's +"Remove links…"); units `tests/js/workspace-library-delete.test.js`; Python +`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. + ### Prompt history page and recorded deviations (#249 PR F-a) The history page (`#/library/`) takes the concept's heading, summary, negative @@ -3401,8 +3491,8 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati - **D104** (Backup & export, S9, Q4): Recovery policy keeps the concept's "Proposed" chip. "Create a safety backup before maintenance" is the server's `safety_backup` (`maintenance.safety_backup`) policy. Its line says what it does ("Keep a verified copy - of the database on disk before maintenance, cleanups and LoRA imports change records. A - restore always keeps one."); the concept's "Preview a verified backup before applying + of the database on disk before maintenance, cleanups, LoRA imports and prompt deletes + change records. A restore always keeps one."); the concept's "Preview a verified backup before applying operations that change records." describes nothing the server does. It is drafted behind the Save row and saved with `expected_revision` (a configuration changed elsewhere is the conflict status); it is disabled with "Server settings could not be read." when the read failed. "Scheduled diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs index 14de15c5..04e2f335 100644 --- a/tests/browser/library-concept.spec.mjs +++ b/tests/browser/library-concept.spec.mjs @@ -44,7 +44,12 @@ export function mapLibraryConcept(reference) { // and "Used today". The two-line clamp is // applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px). footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}})); - return {...reference,library:{...library,toolbar, + // D152 (#443): Show archived prompts (N) comes first in the heading actions, a plain button like Export (the concept + // fixture has nothing archived, so N is 0). + const actions=reference.heading.actions; + const plain=actions.buttons.find(button=>!button.primary); + const heading={...reference.heading,actions:{...actions,buttons:[{...plain,text:'Show archived prompts (0)'},...actions.buttons]}}; + return {...reference,heading,library:{...library,toolbar, // D35: the caption uses the singular for one. caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))}, ...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})), diff --git a/tests/browser/library-delete-concept.spec.mjs b/tests/browser/library-delete-concept.spec.mjs new file mode 100644 index 00000000..9990507e --- /dev/null +++ b/tests/browser/library-delete-concept.spec.mjs @@ -0,0 +1,147 @@ +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from './fixtures.mjs'; +import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs'; + +// #443 D152: the Library's Delete… review against the concept's own reviewed archive: the recipe editor's "Archive…" +// (app.js 976), which opens `maintenancePlan("archive ")` (953–958, 1309–1310). The concept's shell, grid tokens, +// `p.small` and actions are kept; its title, demo note, rows, note text and `ca-primary` start are replaced by D152's, +// and the start takes the concept's `danger` class, measured on the editor's own "Archive…". The bar's Delete… is +// compared with the concept Images bar's danger action, "Remove links…" (app.js 432). +test.use({ locale: 'en-US', timezoneId: 'UTC' }); + +test.beforeEach(async ({ page, request, browserName }) => { + await startCoverage(page, browserName); + await seedConcept(request); + page.conceptErrors = []; + page.on('pageerror', error => page.conceptErrors.push(error.message)); +}); +test.afterEach(async ({ page, browserName }) => { + await saveCoverage(page, browserName); + expect(page.conceptErrors).toEqual([]); +}); + +/** Runs in the page: the open review dialog's title, body tree, grid, notes and actions with the concept's tokens. */ +function measureReview() { + const dialog = document.querySelector('dialog#modal[open]'); + const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null; + const name = node => [node.localName, ...[...node.classList].sort()].join('.'); + const text = node => node?.textContent.trim() ?? null; + const body = dialog.querySelector('#modal-body'); + const grid = body.querySelector(':scope > dl.meta-grid'); + const notes = [...body.querySelectorAll(':scope > p.small')]; + const actions = body.querySelector(':scope > .dialog-actions'); + return { + title: text(dialog.querySelector('#modal-title')), + tree: [...body.children].map(name).filter(item => item !== 'p.demo-note' && item !== 'h3'), + grid: { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]), + ...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'), + dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') }, + notes: notes.map(note => ({ text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') })), + actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'), + buttons: [...actions.children].map(item => ({ text: text(item), className: item.className, + ...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) }, + }; +} + +/** Runs in the page: a button's class and tokens (the concept's danger button, or production's). */ +function measureButton(selector) { + const node = document.querySelector(selector); + const style = getComputedStyle(node); + return { className: node.className, ...Object.fromEntries(['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop', + 'paddingLeft', 'borderTopColor', 'borderTopLeftRadius', 'height'].map(key => [key, key === 'height' + ? `${Math.round(node.getBoundingClientRect().height * 100) / 100}px` : style[key]])) }; +} + +async function conceptReview(page, theme) { + await openConcept(page, 'prompt/alpine', theme); + await page.locator('#main .heading-actions').getByRole('button', { name: 'Edit metadata', exact: true }).click(); + const archive = page.locator('dialog#modal[open] .dialog-actions button', { hasText: 'Archive…' }); + await expect(archive).toBeVisible();await page.mouse.move(0, 0); + const danger = await page.evaluate(measureButton, 'dialog#modal[open] .dialog-actions button.danger'); + await archive.click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan'); + await page.mouse.move(0, 0); + return { danger, review: await page.evaluate(measureReview) }; +} + +async function seedMixed(request) { + // One unpaired prompt beside the concept's paired ones, so the review deletes one and archives one. + await expect(await request.post('/prompt_manager/save', { data: { text: 'Unpaired study', category: 'Studies' } })).toBeOK(); + const items = (await (await request.get('/prompt_manager/library?limit=100')).json()).items; + return { unpaired: items.find(item => item.text === 'Unpaired study'), paired: items.find(item => item.variant_count > 0) }; +} + +async function productionReview(page, request, theme) { + const { unpaired, paired } = await seedMixed(request); + await openProduction(page, 'library', theme); + for (const item of [unpaired, paired]) await page.locator(`article[data-prompt-id="${item.id}"] .image-overlays input`).check(); + await page.locator('#library-bulkbar .bulkbar').getByRole('button', { name: 'Delete…', exact: true }).click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Delete or archive selected prompts'); + await page.mouse.move(0, 0); + return page.evaluate(measureReview); +} + +/** D152's changes to the concept's review; everything else must equal the concept. */ +function mapDeleteConcept({ danger, review }) { + const [cancel] = review.actions.buttons; + const note = review.notes.at(-1); + return { + ...review, + title: 'Delete or archive selected prompts', + tree: ['dl.meta-grid', 'p.small', 'p.small', 'div.dialog-actions'], + grid: { ...review.grid, rows: [['Delete permanently', '1 prompt not in a saved pair'], + ['Archive', '1 prompt with negative variants: hidden, restorable'], + ['Image links removed', '0 images will no longer show these prompts (files stay on disk)'], + ['Backup', 'Create and verify before deleting']] }, + notes: [ + { ...note, text: 'Prompts with negative variants, or used as one, are archived instead of deleted, so their variants ' + + 'and runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, ' + + 'select them and press 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 ' + + 'backup taken first (System › Backup & export), which returns the whole library to that moment. It is kept ' + + 'among the last 10 safety backups. 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.' }, + ], + actions: { ...review.actions, tree: [review.actions.tree[0], 'button.danger'], buttons: [cancel, { text: 'Delete 1 and archive 1 prompt', className: danger.className, + backgroundColor: danger.backgroundColor, color: danger.color, fontSize: danger.fontSize, paddingTop: danger.paddingTop, + paddingLeft: danger.paddingLeft, borderTopColor: danger.borderTopColor }] }, + }; +} + +for (const theme of THEMES) for (const [width, height] of SIZES) { + test(`the Delete… review matches the concept's reviewed archive ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { + await page.setViewportSize({ width, height }); + const concept = await conceptReview(page, theme); + expect(concept.review.tree).toEqual(['dl.meta-grid', 'p.small', 'div.dialog-actions']); + await page.screenshot({ path: testInfo.outputPath(`concept-archive-review-${theme}-${width}.png`) }); + const production = await productionReview(page, request, theme); + await page.screenshot({ path: testInfo.outputPath(`production-delete-review-${theme}-${width}.png`) }); + expect(diffInventory(mapDeleteConcept(concept), production)).toEqual([]); + }); +} + +test('the Delete… review comparison detects mutations', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const concept = await conceptReview(page, 'dark'); + await productionReview(page, request, 'dark'); + const expected = mapDeleteConcept(concept); + const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path); + expect(await paths()).toEqual([]); + await mutate(page, { css: '#modal-body .dialog-actions .danger { padding-left: 2px }' }); + expect(await paths()).toContain('actions.buttons.1.paddingLeft'); +}); + +test('the bar\'s Delete… matches the concept Images bar\'s danger action', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + await openConcept(page, 'images', 'dark'); + await page.locator('#image-grid .media-card').first().locator('input[data-select]').check(); + await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0); + const reference = await page.evaluate(measureButton, '#bulkbar .bulkbar button.danger'); + const { paired } = await seedMixed(request); + await openProduction(page, 'library', 'dark'); + await page.locator(`article[data-prompt-id="${paired.id}"] .image-overlays input`).check(); + await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0); + expect(await page.evaluate(measureButton, '#library-bulkbar .bulkbar button.danger')).toEqual(reference); +}); diff --git a/tests/browser/system-backups-concept.spec.mjs b/tests/browser/system-backups-concept.spec.mjs index 8655d621..a2c06b1d 100644 --- a/tests/browser/system-backups-concept.spec.mjs +++ b/tests/browser/system-backups-concept.spec.mjs @@ -87,8 +87,8 @@ function measureBackups(mainSelector) { } const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.'; -const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' + - 'records. A restore always keeps one.'; +const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA imports and ' + + 'prompt deletes change records. A restore always keeps one.'; /** * D103: "Back up" (the concept's "Simulate backup"), the status line from /backup/status, "Export JSON" (its "Export diff --git a/tests/browser/workspace-library-delete.spec.mjs b/tests/browser/workspace-library-delete.spec.mjs new file mode 100644 index 00000000..13966d67 --- /dev/null +++ b/tests/browser/workspace-library-delete.spec.mjs @@ -0,0 +1,169 @@ +import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; +import {test,expect} from './fixtures.mjs'; +import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs'; + +// #443 D152: Delete… on the Library bar deletes unpaired prompts, archives paired ones (restorable), keeps protected ones. +const route='/prompt_manager/workspace#/library'; +const PREVIEW='/prompt_manager/library/delete/preview'; +const DELETE='/prompt_manager/library/delete'; +const cards=page=>page.locator('article[data-prompt-id]'); +const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`); +const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]'); +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/}); + +async function items(request,shelf='active') { + const query=shelf==='archived'?'&shelf=archived':''; + return (await(await request.get(`/prompt_manager/library?limit=100${query}`)).json()).items; +} +const byText=(list,text)=>list.find(item=>item.text===text); +function recordPosts(page,path) { + const posts=[]; + page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname===path)posts.push(item.postDataJSON());}); + return posts; +} +async function openReview(page) { + await bar(page).getByRole('button',{name:'Delete…',exact:true}).click(); + await expect(review(page)).toBeVisible(); +} + +test.beforeEach(async({page,request,browserName})=>{ + await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:2}})).toBeOK(); + page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message)); +}); +test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);}); + +test('Delete… reviews the selection, deletes the unpaired, archives the paired and keeps the protected',async({page,request})=>{ + const before=await items(request); + const dog=byText(before,'Dog landscape'),cat=byText(before,'Cat portrait'),guarded=byText(before,'Library extra 0000'); + await expect(await request.post('/prompt_manager/bulk/tags',{data:{prompt_ids:[guarded.id],tags:['__protected__']}})).toBeOK(); + const previews=recordPosts(page,PREVIEW),writes=recordPosts(page,DELETE); + await page.goto(route);await expect(cards(page)).toHaveCount(before.length); + for(const item of [dog,cat,guarded]) await box(page,item.id).check(); + await expect(bar(page).getByRole('button')).toHaveText([`Select all ${before.length} loaded`,'Add tags','Set category','Delete…','Clear']); + await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).toHaveClass(/danger/); + await openReview(page); + await expect(review(page)).toHaveAccessibleName('Delete or archive selected prompts'); + const grid=review(page).locator('dl.meta-grid > div'); + await expect(grid.locator('dt')).toHaveText(['Delete permanently','Archive','Protected (kept)','Image links removed','Backup']); + await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair','1 prompt with negative variants: hidden, restorable', + '1: Library extra 0000','0 images will no longer show these prompts (files stay on disk)','Create and verify before deleting']); + await expect(review(page).locator('p.small')).toHaveCount(3); + await expect(review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true})).toHaveClass(/danger/); + // Cancel writes nothing and releases the bar. + 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})).not.toHaveAttribute('aria-disabled','true'); + await openReview(page); + await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Archived 1 prompt\. 1 protected prompt was kept\. Safety backup saved: \S+\.backup_\S+ \(System › Backup & export\)\.$/); + await expect(page.locator('#library-bulkbar')).toBeEmpty(); + await expect(card(page,dog.id)).toHaveCount(0);await expect(card(page,cat.id)).toHaveCount(0);await expect(card(page,guarded.id)).toHaveCount(1); + const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch; + // One preview per review opened (Cancel, then Delete…). + const reviewed={prompt_ids:[dog.id,cat.id,guarded.id],expected_epoch:epoch}; + expect(previews).toEqual([reviewed,reviewed]); + expect(writes).toEqual([{archive_ids:[cat.id],delete_ids:[dog.id],expected_epoch:epoch,backup:'on'}]); + const after=await items(request); + expect(byText(after,'Dog landscape')).toBeUndefined();expect(byText(after,'Cat portrait')).toBeUndefined(); + expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]); + await expect(showArchived(page)).toHaveText('Show archived prompts (1)'); +}); + +test('Show archived prompts lists archived prompts, and Restore on its bar returns them',async({page,request})=>{ + const before=await items(request);const cat=byText(before,'Cat portrait'),dual=byText(before,'Dual role 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 expect(review(page)).toHaveAccessibleName('Archive selected prompts'); + 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'); + await showArchived(page).click(); + await expect(showArchived(page)).toHaveAttribute('aria-pressed','true'); + 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); + await box(page,cat.id).check(); + await expect(bar(page).getByRole('button')).toHaveText(['Select all 2 loaded','Restore','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); + await showArchived(page).click(); + 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]); + // An empty archived view says so. + await showArchived(page).click();await box(page,dual.id).check(); + await bar(page).getByRole('button',{name:'Restore',exact:true}).click(); + await expect(page.locator('.library-status')).toHaveText('No archived prompts.'); +}); + +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; + await expect(await request.post(DELETE,{data:{archive_ids:[cat.id],delete_ids:[],expected_epoch:epoch,backup:'off'}})).toBeOK(); + await page.goto(`/prompt_manager/workspace#/library/${cat.id}`); + const notice=page.locator('.library-archived-notice'); + await expect(notice.locator('h2')).toHaveText('Archived'); + await expect(notice.locator('p').first()).toHaveText('This prompt and its negative variants are hidden from the Prompt library; nothing was removed.'); + await notice.getByRole('button',{name:'Restore this prompt to the Prompt library'}).click(); + await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.'); + await expect(notice).toHaveCount(0); + expect(byText(await items(request),'Cat portrait')?.id).toBe(cat.id); +}); + +test('a double press sends one preview, and the bar holds while it is reviewed',async({page,request})=>{ + const dog=byText(await items(request),'Dog landscape'); + const previews=recordPosts(page,PREVIEW); + let release;const held=new Promise(resolve=>{release=resolve;}); + await page.route(`**${PREVIEW}`,async item=>{await held;await item.fallback();}); + await page.goto(route);await box(page,dog.id).check(); + const remove=bar(page).getByRole('button',{name:/^(Delete…|Reviewing…)$/}); + await remove.click(); + await expect(remove).toHaveText('Reviewing…');await expect(remove).toHaveAttribute('aria-disabled','true'); + await expect(bar(page).getByRole('button',{name:'Clear',exact:true})).toHaveAttribute('aria-disabled','true'); + await remove.click({force:true}); + release(); + await expect(review(page)).toHaveAccessibleName('Delete selected prompts'); + expect(previews).toHaveLength(1); + await review(page).getByRole('button',{name:'Cancel',exact:true}).click(); + await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true'); +}); + +test('a restore since the page loaded, a failed backup, a busy job and an unconfirmed answer each say what happened',async({page,request})=>{ + const before=await items(request);const dog=byText(before,'Dog landscape'); + await armUnseenRestores(page); + await page.goto(route);await expect(cards(page)).toHaveCount(before.length); + // Failed backup: nothing changed, the selection stays. + await page.route(`**${DELETE}`,item=>item.fulfill({status:500,contentType:'application/json', + body:JSON.stringify({success:false,code:'backup-failed',error:'The safety backup could not be taken and verified; nothing was changed.'})})); + await box(page,dog.id).check();await openReview(page); + await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText('The safety backup failed. The safety backup could not be taken and verified; nothing was changed.'); + await expect(box(page,dog.id)).toBeChecked(); + // Busy: names the job, nothing changed. + await page.unroute(`**${DELETE}`); + await page.route(`**${DELETE}`,item=>item.fulfill({status:409,contentType:'application/json', + body:JSON.stringify({success:false,code:'busy',error:'busy',running:{id:'abc',title:'Scan'}})})); + await openReview(page);await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText('Scan is running. Nothing was changed; try again when it finishes.'); + // Unconfirmed: the shelf reloads. + await page.unroute(`**${DELETE}`); + await page.route(`**${DELETE}`,item=>item.abort()); + await openReview(page);await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText('The server did not confirm the change. The Library was reloaded: check the shelf and Show archived prompts.'); + await expect(page.locator('#library-bulkbar')).toBeEmpty(); + await page.unroute(`**${DELETE}`); + // A restore the page has not seen: the review opened before it is refused on the server. + await box(page,dog.id).check();await openReview(page); + const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body(); + const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}})); + await expect(restore).toBeOK(); + await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText('The library was restored or moved since this page loaded. Nothing was deleted or archived.'); + expect(byText(await items(request),'Dog landscape')).toBeDefined(); +}); diff --git a/tests/browser/workspace-library-selection.spec.mjs b/tests/browser/workspace-library-selection.spec.mjs index d92eeb76..517973db 100644 --- a/tests/browser/workspace-library-selection.spec.mjs +++ b/tests/browser/workspace-library-selection.spec.mjs @@ -62,7 +62,8 @@ test('Set category writes the selected prompts on the server, reloads the shelf await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected'); await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected'); // Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling). - await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Clear']); + // D152 (#443) adds Delete… before Clear. + await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Delete…','Clear']); await setCategory(page,' Studio set '); await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.'); await expect(dialog(page)).toHaveCount(0); @@ -287,7 +288,7 @@ test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed on let release;const held=new Promise(resolve=>{release=resolve;}); await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1}); await addTags(page,'everyone'); - for(const name of ['Add tags','Set category',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true'); + for(const name of ['Add tags','Set category','Delete…','Clear',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true'); release(); await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`); expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1); diff --git a/tests/browser/workspace-system-backups.spec.mjs b/tests/browser/workspace-system-backups.spec.mjs index 3a0404d4..fecbdbcc 100644 --- a/tests/browser/workspace-system-backups.spec.mjs +++ b/tests/browser/workspace-system-backups.spec.mjs @@ -11,8 +11,8 @@ const STATUS_URL = '/prompt_manager/backup/status'; const PREVIEW_URL = '/prompt_manager/restore/preview'; const APPLY_URL = '/prompt_manager/restore/apply'; const WHEN = { dateStyle: 'medium', timeStyle: 'short' }; -const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' + - 'records. A restore always keeps one.'; +const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA imports and ' + + 'prompt deletes change records. A restore always keeps one.'; test.use({ locale: 'en-US', timezoneId: 'UTC' }); diff --git a/tests/fixtures/legacy_contract_snapshot.json b/tests/fixtures/legacy_contract_snapshot.json index 62ee7af2..eef7fb63 100644 --- a/tests/fixtures/legacy_contract_snapshot.json +++ b/tests/fixtures/legacy_contract_snapshot.json @@ -131,6 +131,9 @@ "POST /prompt_manager/jobs/thumbnails", "POST /prompt_manager/jobs/{job_id}/cancel", "POST /prompt_manager/library", + "POST /prompt_manager/library/delete", + "POST /prompt_manager/library/delete/preview", + "POST /prompt_manager/library/restore", "POST /prompt_manager/library/{positive_id}/variants", "POST /prompt_manager/logs/config", "POST /prompt_manager/logs/truncate", diff --git a/tests/js/workspace-library-delete.test.js b/tests/js/workspace-library-delete.test.js new file mode 100644 index 00000000..bd8a97da --- /dev/null +++ b/tests/js/workspace-library-delete.test.js @@ -0,0 +1,126 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +// #443 D152: the Prompt library's Delete… review (delete unpaired, archive paired, keep protected) and Restore. +const source = import('../../web/js/workspace/library-delete.mjs'); + +const preview = (patch = {}) => ({success: true, library_epoch: 'e', delete: [], archive: [], protected: [], + already_archived: 0, not_found: 0, image_links: 0, backup: 'on', disk: null, ...patch}); + +test('the routes are the workspace ones, never the legacy bulk delete', async () => { + const {PREVIEW_URL, DELETE_URL, RESTORE_URL} = await source; + assert.equal(PREVIEW_URL, '/prompt_manager/library/delete/preview'); + assert.equal(DELETE_URL, '/prompt_manager/library/delete'); + assert.equal(RESTORE_URL, '/prompt_manager/library/restore'); +}); + +test('the title, primary and body follow what the review will do', async () => { + const {reviewOf, reviewTitle, primaryLabel, deleteBody} = await source; + const both = reviewOf(preview({delete: [1, 2], archive: [3]})); + assert.equal(reviewTitle(both), 'Delete or archive selected prompts'); + assert.equal(primaryLabel(both), 'Delete 2 and archive 1 prompt'); + assert.deepEqual(deleteBody(both), {archive_ids: [3], delete_ids: [1, 2], expected_epoch: 'e', backup: 'on'}); + const onlyDelete = reviewOf(preview({delete: [1]})); + assert.equal(reviewTitle(onlyDelete), 'Delete selected prompts'); + assert.equal(primaryLabel(onlyDelete), 'Delete 1 prompt'); + const onlyArchive = reviewOf(preview({archive: [4, 5]})); + assert.equal(reviewTitle(onlyArchive), 'Archive selected prompts'); + assert.equal(primaryLabel(onlyArchive), 'Archive 2 prompts'); +}); + +test('the grid shows each row only when it applies, with protected titles in selection order', async () => { + const {reviewOf, reviewGrid} = await source; + const names = {7: 'Seven', 8: 'Eight', 9: 'Nine', 10: 'Ten'}; + const review = reviewOf(preview({delete: [1], archive: [2], protected: [7, 8, 9, 10], already_archived: 1, + not_found: 2, image_links: 3})); + assert.deepEqual(reviewGrid(review, id => names[id]), [ + ['Delete permanently', '1 prompt not in a saved pair'], + ['Archive', '1 prompt with negative variants: hidden, restorable'], + ['Protected (kept)', '4: Seven, Eight, Nine and 1 more'], + ['Already archived', '1'], + ['No longer exist', '2'], + ['Image links removed', '3 images will no longer show these prompts (files stay on disk)'], + ['Backup', 'Create and verify before deleting'], + ]); + const archiveOnly = reviewOf(preview({archive: [2]})); + assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Delete permanently', 'Archive']); + const off = reviewOf(preview({delete: [1], backup: 'off'})); + assert.equal(reviewGrid(off).at(-1)[1], 'Off: no safety backup (System › Backup & export › Recovery policy)'); +}); + +test('the sentences explain archive, irreversibility per backup policy, re-creation and protection', async () => { + const {reviewOf, reviewSentences} = await source; + const [archive, remove, guarded] = reviewSentences(reviewOf(preview({delete: [1], archive: [2], protected: [3]}))); + assert.match(archive, /^Prompts with negative variants, or used as one, are archived instead of deleted/); + assert.match(archive, /Archived prompts cannot be deleted\. To bring them back, press Show archived prompts/); + 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.'); + const [off] = reviewSentences(reviewOf(preview({delete: [1], backup: 'off'}))); + assert.match(off, /Deleted prompts cannot be brought back\. Image files/); + assert.doesNotMatch(off, /safety backup/); +}); + +test('a review that cannot start says why: nothing to change, or no room for the backup', async () => { + const {reviewOf, blockedReason, COPY} = await source; + assert.equal(blockedReason(reviewOf(preview({protected: [1]}))), COPY.none); + assert.equal(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: false, message: 'Not enough. Nothing was changed.'}}))), + 'Not enough. Nothing was changed.'); + assert.match(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: false}}))), /not enough free space/); + assert.equal(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: true}}))), null); + assert.equal(blockedReason(reviewOf(preview({archive: [1]}))), null); +}); + +test('a delete outcome counts each part, singular and plural, and names the backup', async () => { + const {reviewOf, deleteOutcome} = await source; + const review = reviewOf(preview({delete: [1, 2], archive: [3], protected: [9]})); + assert.deepEqual(deleteOutcome({success: true, deleted: 2, archived: 3, already_archived: 0, not_found: 0, changed: 0, + backup: 'prompts.db.backup_x'}, null, review), {committed: true, reload: true, + 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.'); + 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.'); + assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0}, null, reviewOf(preview())).committed, false); +}); + +test('a failed delete says nothing changed, or that it may have, and keeps or reloads the selection', async () => { + const {deleteOutcome, COPY} = await source; + assert.deepEqual(deleteOutcome(null, {status: 409, code: 'storage-changed'}), {toast: COPY.storage, committed: false, reload: true}); + assert.equal(COPY.storage, 'The library was restored or moved since this page loaded. Nothing was deleted or archived.'); + assert.deepEqual(deleteOutcome(null, {status: 409, code: 'busy', running: {title: 'Scan'}}), + {toast: 'Scan is running. Nothing was changed; try again when it finishes.', committed: false, reload: false}); + assert.deepEqual(deleteOutcome(null, {status: 500, code: 'backup-failed', message: 'The safety backup could not be taken and verified; nothing was changed.'}), + {toast: 'The safety backup failed. The safety backup could not be taken and verified; nothing was changed.', committed: false, reload: false}); + assert.deepEqual(deleteOutcome(null, {status: 507, code: 'insufficient-space', message: 'Not enough free disk space. Nothing was changed.'}), + {toast: 'Not enough free disk space. Nothing was changed.', committed: false, reload: false}); + for (const failure of [new TypeError('Failed to fetch'), {status: 502}, {status: 503}, {status: 504}]) { + assert.deepEqual(deleteOutcome(null, failure), {toast: COPY.unconfirmed, committed: true, reload: true}); + } + assert.deepEqual(deleteOutcome(null, {status: 400}), {toast: COPY.failed, committed: false, reload: false}); +}); + +test('a failed preview reloads only after a restore; Restore outcomes have their own words', async () => { + const {previewOutcome, restoreOutcome, COPY} = await source; + assert.deepEqual(previewOutcome({status: 409, code: 'storage-changed'}), {toast: COPY.storage, committed: false, reload: true}); + assert.deepEqual(previewOutcome({status: 500}), {toast: COPY.reviewFailed, committed: false, reload: false}); + assert.equal(previewOutcome({status: 409, code: 'busy', running: {title: 'Optimize'}}).toast, + 'Optimize is running. Nothing was changed; try again when it finishes.'); + assert.deepEqual(restoreOutcome({success: true, restored: 2, unchanged: 1, not_found: 1}, null), + {toast: 'Restored 2 prompts to the Prompt library. 1 was not archived. 1 no longer exists.', committed: true, reload: true}); + assert.deepEqual(restoreOutcome({success: true, restored: 0, unchanged: 2, not_found: 0}, null), + {toast: 'No prompt was restored. 2 were not archived.', committed: false, reload: true}); + assert.deepEqual(restoreOutcome(null, {status: 409, code: 'storage-changed'}), {toast: COPY.restoreStorage, committed: false, reload: true}); + assert.deepEqual(restoreOutcome(null, {status: 503}), {toast: COPY.restoreUnconfirmed, committed: true, reload: true}); + assert.deepEqual(restoreOutcome(null, {status: 400}), {toast: COPY.restoreFailed, committed: false, reload: false}); +}); + +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, + imageLinks: 2, backup: 'on', disk: null}); +}); diff --git a/web/js/workspace/library-delete.mjs b/web/js/workspace/library-delete.mjs new file mode 100644 index 00000000..f4805c66 --- /dev/null +++ b/web/js/workspace/library-delete.mjs @@ -0,0 +1,199 @@ +import { countOf } from './plural.mjs'; +import { openCloseOnlyReview, openReviewDialog } from './workspace-dialogs.mjs'; + +/** + * The Prompt library's Delete… review and outcomes (#443, D152, beyond the concept). The concept maps the legacy prompt + * delete to a "reference-safe archive" reviewed in its `maintenancePlan` dialog; the workspace offers Delete… on the + * D148 bar, whose review (the shared `openReviewDialog`) says what happens to each selected prompt: deleted (in no saved + * pair), archived (paired: hidden, restorable) or kept (tagged `__protected__`). The server executes the reviewed + * lists (`POST /prompt_manager/library/delete`), taking a verified safety backup before any delete when the policy is + * on. Restore (`/library/restore`) puts archived prompts back on the shelf. + */ +export const PREVIEW_URL = '/prompt_manager/library/delete/preview'; +export const DELETE_URL = '/prompt_manager/library/delete'; +export const RESTORE_URL = '/prompt_manager/library/restore'; +export const REVIEWING = 'Reviewing…'; +export const DELETE_LABEL = 'Delete…'; +export const RESTORE_LABEL = 'Restore'; +// Answers that may follow a committed write: no status, or a gateway's. +const UNCONFIRMED_STATUSES = new Set([502, 503, 504]); +const NOTHING = 'Nothing was deleted or archived.'; +export const COPY = Object.freeze({ + reviewFailed: 'The selected prompts could not be reviewed. Try again.', + storage: `The library was restored or moved since this page loaded. ${NOTHING}`, + unconfirmed: 'The server did not confirm the change. The Library was reloaded: check the shelf and Show archived prompts.', + 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.', + 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.', + archivedNotice: 'This prompt and its negative variants are hidden from the Prompt library; nothing was removed.', + archivedText: 'This text is an archived prompt. Restore it to show it in the Prompt library again.', +}); + +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}`; + +/** The preview's classes as counts and id lists, whatever else the answer holds. */ +export function reviewOf(preview) { + return { + 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, + }; +} + +export function reviewTitle(review) { + if (!review.archive.length) return 'Delete selected prompts'; + if (!review.delete.length) return 'Archive selected prompts'; + return 'Delete or archive selected prompts'; +} + +/** "Title A, Title B, Title C and 2 more": the protected prompts the page has loaded, in selection order. */ +export function protectedNames(review, titleOf) { + const names = review.protected.map(titleOf).filter(name => typeof name === 'string' && name); + const shown = names.slice(0, 3).join(', '); + const more = review.protected.length - Math.min(3, names.length); + return more > 0 ? `${shown}${shown ? ' and ' : ''}${more.toLocaleString('en-US')} more` : shown; +} + +/** The review's `[term, value]` rows (design §7.2); rows that do not apply are left out. */ +export function reviewGrid(review, titleOf = () => null) { + const rows = [ + ['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`], + ['Archive', `${countOf(review.archive.length, 'prompt')} with negative variants: hidden, restorable`], + ]; + 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)']); + } + return rows; +} + +/** The review's sentences, in order, each when it applies. */ +export function reviewSentences(review) { + const sentences = []; + if (review.archive.length) { + sentences.push('Prompts with negative variants, or used as one, are archived instead of deleted, so their variants ' + + 'and 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.'); + return sentences; +} + +export function primaryLabel(review) { + const deletes = review.delete.length, archives = review.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')}`; +} + +/** 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) { + 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.'; + } + return null; +} + +/** + * Open the review for `review` (`reviewOf`). `titleOf(id)` names a loaded prompt. A review that cannot start is the + * close-only review (`onDismiss` runs at once, as nothing is pending); otherwise Start calls `onStart()` and any other + * 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); + if (blocked) { + onDismiss(); + openCloseOnlyReview({ scope, trigger, title, grid, options, sentence: blocked }); + return; + } + openReviewDialog({ scope, trigger, title, grid, options, cancelLabel: 'Cancel', + primary: { label: primaryLabel(review), danger: true }, onStart, onDismiss }); +} + +/** 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 }; +} + +function failureOutcome(failure, copy) { + if (failure?.code === 'storage-changed') return { toast: copy.storage, committed: false, reload: true }; + if (failure?.code === 'busy') { + const name = failure.running?.title || 'Another job'; + return { toast: `${name} is running. Nothing was changed; try again when it finishes.`, committed: false, reload: false }; + } + const status = failure?.status; + if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: copy.unconfirmed, committed: true, reload: true }; + // The server's own refusals that name their reason (backup-failed, insufficient-space) say "Nothing was changed". + if (['backup-failed', 'insufficient-space'].includes(failure?.code) && typeof failure.message === 'string' && failure.message) { + const lead = failure.code === 'backup-failed' ? 'The safety backup failed. ' : ''; + return { toast: `${lead}${failure.message}`, committed: false, reload: false }; + } + return { toast: copy.failed, committed: false, reload: false }; +} + +/** What a settled delete means: the toast, whether it may have `committed` and whether the shelf must `reload`. */ +export function deleteOutcome(result, failure, review) { + if (failure !== null || !result?.success) { + return failureOutcome(failure, { storage: COPY.storage, unconfirmed: COPY.unconfirmed, failed: COPY.failed }); + } + const deleted = count(result.deleted), archived = count(result.archived); + const parts = []; + if (deleted) parts.push(`Deleted ${countOf(deleted, 'prompt')}.`); + 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.`); + 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).`); + if (!deleted && !archived) parts.push('The Library was reloaded.'); + return { toast: parts.join(' '), committed: deleted + archived > 0, reload: true }; +} + +/** What a settled restore means (the same shape as `deleteOutcome`). */ +export function restoreOutcome(result, failure) { + if (failure !== null || !result?.success) { + return failureOutcome(failure, { storage: COPY.restoreStorage, unconfirmed: COPY.restoreUnconfirmed, failed: COPY.restoreFailed }); + } + const restored = count(result.restored), unchanged = count(result.unchanged), missing = count(result.not_found); + const parts = [restored ? `Restored ${countOf(restored, 'prompt')} to the Prompt library.` : 'No prompt was restored.']; + if (unchanged) parts.push(`${unchanged.toLocaleString('en-US')} ${unchanged === 1 ? 'was' : 'were'} not archived.`); + if (missing) parts.push(`${missing.toLocaleString('en-US')} no longer ${missing === 1 ? 'exists' : 'exist'}.`); + return { toast: parts.join(' '), committed: restored > 0, reload: true }; +} + +/** A failed preview: a restored storage reloads the shelf; anything else keeps the selection. */ +export function previewOutcome(failure) { + if (failure?.code === 'storage-changed') return { toast: COPY.storage, committed: false, reload: true }; + if (failure?.code === 'busy') return failureOutcome(failure, {}); + return { toast: COPY.reviewFailed, committed: false, reload: false }; +} diff --git a/web/js/workspace/library-selection.mjs b/web/js/workspace/library-selection.mjs index 27f2b883..9a7af623 100644 --- a/web/js/workspace/library-selection.mjs +++ b/web/js/workspace/library-selection.mjs @@ -6,6 +6,8 @@ import { NO_TAGS, parseBulkTags } from './images-tagging-bulk.mjs'; import { openInputDialog } from './workspace-dialogs.mjs'; import { countOf } from './plural.mjs'; import { showWorkspaceToast } from './toast.mjs'; +import { COPY as DELETE_COPY, DELETE_LABEL, DELETE_URL, PREVIEW_URL, REVIEWING, RESTORE_LABEL, RESTORE_URL, deleteBody, deleteOutcome, + openDeleteReview, previewOutcome, restoreOutcome, reviewOf } from './library-delete.mjs'; /** * The Prompt library's selection and its bulk actions (D148, D151, beyond the concept): "Add tags" (#442, D151) and "Set @@ -19,6 +21,10 @@ import { showWorkspaceToast } from './toast.mjs'; * Apply posts `{prompt_ids, category|tags, expected_epoch}` to `POST /prompt_manager/bulk/category` or `/bulk/tags`: * the epoch the shelf was read under, so a restored storage answers 409 `storage-changed` and nothing is written. * Add tags writes prompt tags only, never image tags or auto-tags. + * + * 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. */ export const CATEGORY_URL = '/prompt_manager/bulk/category'; export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' }); @@ -123,29 +129,34 @@ export function categoryProblem(value) { * to `focusFallback` when it was on the bar the reload hides. */ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted, - onReload }) { + onReload, getShelf = () => 'active', nameOf = () => null }) { let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false; const element = el('div'); element.id = 'library-bulkbar'; const bulk = el('div', undefined, 'bulkbar'), count = el('strong'); const addTags = createButton('Add tags'); const setCategory = createButton('Set category'); + const deleteButton = createButton(DELETE_LABEL, 'danger'); + const restoreButton = createButton(RESTORE_LABEL); const clearButton = createButton('Clear'); const selectLoaded = createButton(''); - const actions = [addTags, setCategory, selectLoaded]; - bulk.append(count, addTags, setCategory, clearButton); + // Every bar action is aria-disabled while a review or a write runs (D148, D151, D152), Clear too. + const actions = [addTags, setCategory, deleteButton, restoreButton, selectLoaded, clearButton]; + let shownShelf = null; scope.listen(selectLoaded, 'click', () => { if (writing) return; selectAll(); // The pressed offer left the bar: focus moves to its first action, as after D135's offer. - addTags.focus({ preventScroll: true }); + (shownShelf === 'archived' ? restoreButton : addTags).focus({ preventScroll: true }); }); scope.listen(addTags, 'click', () => openTags()); scope.listen(setCategory, 'click', () => openCategory()); - scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); }); + scope.listen(deleteButton, 'click', () => openDelete()); + scope.listen(restoreButton, 'click', () => restoreSelected()); + scope.listen(clearButton, 'click', () => { if (writing) return; clear(); focusFallback.focus({ preventScroll: true }); }); // Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear. scope.listen(element, 'keydown', event => { - if (event.key !== 'Escape' || !checked.size) return; + if (event.key !== 'Escape' || !checked.size || writing) return; event.preventDefault(); clear(); focusFallback.focus({ preventScroll: true }); @@ -154,6 +165,11 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge const ids = () => getItems().map(item => item.id); function render() { if (!checked.size) { bulk.remove(); return; } + const shelf = getShelf() === 'archived' ? 'archived' : 'active'; + if (shelf !== shownShelf) { + shownShelf = shelf; + bulk.replaceChildren(count, ...(shelf === 'archived' ? [restoreButton] : [addTags, setCategory, deleteButton]), clearButton); + } const label = selectionLabel(checked.size); if (count.textContent !== label) count.textContent = label; const keys = ids(); @@ -240,20 +256,62 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge openInputDialog({ scope, trigger: addTags, ...PROMPT_TAGS_DIALOG, suggestions, validate: tagsProblem, onApply: text => write(TAGS_URL, { ...chosen, tags: parseBulkTags(text) }, 'tags') }); } - /** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */ - async function write(url, body, kind) { + /** Hold every bar action while a review or write runs; `label` is what Delete… reads meanwhile. */ + function lock(label = null) { writing = true; for (const button of actions) button.setAttribute('aria-disabled', 'true'); + if (label !== null) deleteButton.textContent = label; + } + function unlock() { + writing = false; + for (const button of actions) button.removeAttribute('aria-disabled'); + deleteButton.textContent = DELETE_LABEL; + } + /** Delete… (#443): preview the selection, then review it; Start posts what the review showed. */ + async function openDelete() { + const chosen = target(DELETE_COPY.reviewFailed); + if (!chosen) return; + lock(REVIEWING); + let preview = null, failure = null; + try { + preview = await api.post(PREVIEW_URL, chosen, { signal: scope.signal }); + } catch (error) { + failure = error; + } + if (scope.signal.aborted) return; + if (failure !== null || !preview?.success) { + unlock(); + const outcome = previewOutcome(failure); + showWorkspaceToast(outcome.toast); + if (outcome.reload) onReload(); + return; + } + const review = { ...reviewOf(preview), 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, + onDismiss: unlock, + onStart: () => write(DELETE_URL, deleteBody(review), 'delete', (result, error) => deleteOutcome(result, error, review), + review.delete.length ? 'Deleting…' : 'Archiving…') }); + } + /** Restore (#443): the selected archived prompts go back on the shelf. */ + function restoreSelected() { + const chosen = target(DELETE_COPY.restoreFailed); + if (!chosen) return; + write(RESTORE_URL, chosen, 'restore', restoreOutcome); + } + /** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */ + async function write(url, body, kind, outcomeOf = (result, failure) => writeOutcome(result, failure, kind), label = null) { + lock(label); let result = null, failure = null; try { result = await api.post(url, body); } catch (error) { failure = error; } finally { - writing = false; - for (const button of actions) button.removeAttribute('aria-disabled'); + unlock(); } - const outcome = writeOutcome(result, failure, kind); + const outcome = outcomeOf(result, failure); // A left page still invalidates what it cached, so a return never shows the old categories. if (outcome.committed) onCommitted(kind, result?.success ? result : { phase: 'unconfirmed' }); if (scope.signal.aborted) return; @@ -274,7 +332,9 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge selectAll, /** A loaded batch: the count and the offer follow it. */ listingChanged: () => { if (checked.size) render(); }, - /** Whether a Set category write is still in flight. */ + /** Whether a review or a write is still in flight. */ writing: () => writing, + /** The shelf changed (Show archived prompts): the bar's actions follow it. */ + shelfChanged: () => { shownShelf = null; render(); }, }; } diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index 900c944f..88386f8c 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -12,14 +12,19 @@ function parameters(filters,limit) { for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim()); const sort=filters.sort || 'newest'; if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort'); - query.set('sort',sort);return query; + query.set('sort',sort); + // #443 D152: the archived shelf; the default shelf sends nothing, as before. + const shelf=filters.shelf || 'active'; + if(!['active','archived'].includes(shelf)) throw new TypeError('Invalid library shelf'); + if(shelf==='archived') query.set('shelf','archived'); + return query; } // `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants"). function validatePage(page,cursor) { if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page'); } export function createLibraryStream({api,onChange=()=>{},restore}) { - let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null}); + let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,archivedTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null}); let query=parameters({},40),request,disposed=false,seen=new Set(); if(restore) { query=parameters(restore.filters,40);const saved=restore.state; @@ -45,11 +50,13 @@ export function createLibraryStream({api,onChange=()=>{},restore}) { const incoming=[];const added=new Set(); for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));} for(const value of added) seen.add(value); - request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length; + // #443: the archived parents matching the filters (Show archived prompts); null from an older server. + const archivedTotal=Number.isSafeInteger(page.archived_total) && page.archived_total>=0?page.archived_total:null; + request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,archivedTotal,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length; } catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;} } return {get state(){return state;},loadMore,async reset(filters={}) { - if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore(); + if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,archivedTotal:state.archivedTotal ?? null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore(); },dispose(){disposed=true;request?.abort();request=null;}}; } /** diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 3baa22bc..53c63a64 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -17,6 +17,7 @@ import {libraryFilters,libraryViewState} from './saved-views.mjs'; import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs'; import {mountLibrarySelection} from './library-selection.mjs'; import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs'; +import {COPY as DELETE_COPY,RESTORE_URL,restoreOutcome} from './library-delete.mjs'; let shelfVisit=null; // Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream. @@ -107,6 +108,8 @@ export function rememberLibraryResult(result) { function outcomeMessage(result,outcome) { const {kind,receipt}=result.commit; if(kind==='metadata') return 'Saved.'; + // #443: a save that reused an archived prompt leaves it archived; say how to bring it back. + if((kind==='create' || kind==='add-negative') && result.detail?.positive?.archived===true) return DELETE_COPY.archivedText; if(kind==='archive-pair') return ARCHIVED_MESSAGE; if(kind!=='create' && kind!=='add-negative') return null; if(receipt.positive_created) return 'Saved.'; @@ -158,7 +161,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration; // An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again. const restorable=visit?.state?visit:null; - const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved}; + // #443 D152: `shelf` ('active' or 'archived', Show archived prompts) lives in the visit's filters, never in a saved view. + const filters={text:'',category:'',tags:[],sort:'newest',shelf:'active',...visit?.filters,...saved}; const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library'); // The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15). // Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY. @@ -177,14 +181,22 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on // #437 D148: select prompts and set their category (the legacy admin's Set Category); a write reloads the shelf. let categoryNames=[]; const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null, - getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)}, + getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},getShelf:()=>filters.shelf, + nameOf:id=>{const item=state?.items.find(entry=>entry.id===id);return item?displayTitle(item):null;}, onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();}}); const message=el('p','','library-status');message.setAttribute('role','status'); const continuation=el('div',undefined,'library-continuation'); 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'); - const newPrompt=createButton('+ New prompt','ca-primary');actions.append(exportButton,newPrompt); + // #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')); + 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')); + reset();selection.shelfChanged(); + }); const exportStatus=el('p','','library-options-status');exportStatus.hidden=true;exportStatus.setAttribute('role','alert'); const editor=createNewPromptEditor(api,scope,main); scope.listen(newPrompt,'click',()=>editor.open({mode:'new',trigger:newPrompt})); @@ -239,6 +251,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on const content=el('div',undefined,'card-content'); const heading=el('div',undefined,'card-title');const open=createButton(name);open.dataset.history=''; heading.append(open,el('span',item.category || 'Uncategorized','chip')); + if(item.archived===true) heading.append(el('span','Archived','chip')); const prompts=el('div'); const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive'],title:name}); const meta=el('div',undefined,'card-meta'); @@ -388,11 +401,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on // A loaded batch: the selection bar's count and Select all N loaded follow it (D151). if(batch) selection.listingChanged(); if(next.variantTotal!==null) { - const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`; + const count=filters.shelf==='archived'?`${countOf(next.total,'archived prompt')} · ${countOf(next.variantTotal,'negative variant')} hidden with them` + :`${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')})`; + if(showArchived.textContent!==archivedLabel) showArchived.textContent=archivedLabel; + const filtered=Boolean(filters.text || filters.category || filters.tags.length); + const empty=filters.shelf==='archived'?(filtered?'No archived prompts match.':'No archived prompts.'):'No matching prompts. Try changing the filters.'; const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':''; - message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden; + message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?empty:!next.hasMore?'You’re all caught up.':'')+hidden; message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading)); schedule();scheduleLoad(); } @@ -473,6 +491,26 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on if(current) current.apply(result);else loader.load(positiveId); focusHeading(main); } + /** The Archived notice: Restore takes the prompt off the archive, then the page reads it again. */ + function archivedNotice(content,shown) { + const notice=el('div',undefined,'notice library-archived-notice'),copy=el('div'),row=el('div',undefined,'row-actions'); + const status=el('p','','small');status.setAttribute('role','status');status.hidden=true; + const restore=createButton('Restore');restore.setAttribute('aria-label','Restore this prompt to the Prompt library'); + copy.append(el('h2','Archived'),el('p',DELETE_COPY.archivedNotice),status);row.append(restore);notice.append(copy,row); + content.listen(restore,'click',async()=>{ + if(restore.getAttribute('aria-disabled')==='true') return; + restore.setAttribute('aria-disabled','true');status.hidden=true; + let result=null,failure=null; + try {result=await api.post(RESTORE_URL,{prompt_ids:[shown.positive.id],expected_epoch:shown.library_epoch});} catch(error) {failure=error;} + const outcome=restoreOutcome(result,failure); + if(outcome.committed) invalidateLibraryAfterWrite({kind:'bulk-restore',receipt:result?.success?result:{phase:'unconfirmed'}}); + if(content.signal.aborted) return; + restore.removeAttribute('aria-disabled'); + if(outcome.reload) {showWorkspaceToast(outcome.toast);detail=null;loader.load(positiveId);focusHeading(main);return;} + status.textContent=outcome.toast;status.hidden=false; + }); + return notice; + } /** Explicit exit after restored storage: dispose the recorded view, then remount with a fresh visit. */ function reloadView() { writeGeneration++;shelfVisit=shelfVisit && {filters:shelfVisit.filters,anchor:null}; @@ -650,6 +688,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on const gallery=el('section',undefined,'history-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(galleryHeading); const images=el('div',undefined,'library-linked-images');gallery.append(images); const recorded=el('div',undefined,'library-recorded-container'); + // #443 D152: an archived prompt's page says so first, with Restore (the concept's `.notice`). + if(detail.positive.archived===true) body.append(archivedNotice(content,detail)); body.append(summary,variants,gallery,recorded); const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null); const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true); diff --git a/web/js/workspace/system-backups.mjs b/web/js/workspace/system-backups.mjs index a2893624..015f318d 100644 --- a/web/js/workspace/system-backups.mjs +++ b/web/js/workspace/system-backups.mjs @@ -56,8 +56,8 @@ export const HOST_UNAVAILABLE = 'Server settings could not be read.'; export const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.'; export const WARNING_NOTE = 'Restores are staged, migrated and validated before replacing live data.'; // D104 (M2): what the safety-backup policy does (the concept's "Preview a verified backup…" line misstates it). -export const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA ' + - 'imports change records. A restore always keeps one.'; +export const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA ' + + 'imports and prompt deletes change records. A restore always keeps one.'; // I1: a third review while two are held (another window's): what the user can do about it. export const REVIEWS_FULL = 'Two restores are already being reviewed in another window. Discard one there, or try again ' + 'when it expires (15 minutes after it was reviewed).';