diff --git a/TESTING.md b/TESTING.md index eacc3c31..11a1965e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2783,14 +2783,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 @@ -2818,6 +2819,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) + +**D160** (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/database/library_archive.py b/database/library_archive.py index c44aa5e0..a1985c2f 100644 --- a/database/library_archive.py +++ b/database/library_archive.py @@ -13,6 +13,9 @@ and delete lists, and deletes only reviewed deletes the route backed up (``candidates``) whose fingerprint is unchanged since that backup. """ +import hashlib +import json +import re import sqlite3 from typing import Iterable @@ -39,8 +42,15 @@ _PAIRED = ( "EXISTS(SELECT 1 FROM prompt_pairs pp " "WHERE pp.positive_prompt_id=p.id OR pp.negative_prompt_id=p.id)" ) +# #482: used as the negative of another positive's pair (archived or not): +# never deleted, so that positive's variant stays (archive only). +_NEGATIVE_OF_OTHER = ( + "EXISTS(SELECT 1 FROM prompt_pairs op " + "WHERE op.negative_prompt_id=p.id AND op.positive_prompt_id!=p.id)" +) # Classes in the order a prompt is tested; the first that holds wins. CLASSES = ("not_found", "protected", "already_archived", "archive", "delete") +_DIGEST = re.compile(r"^[0-9a-f]{64}$") def _batches(ids: list) -> Iterable[list]: @@ -78,24 +88,31 @@ class LibraryArchiveOperations: def _classify_library_delete( self, conn: sqlite3.Connection, prompt_ids: list[int] ) -> dict: - found = {} + found, deletable = {}, set() for chunk in _batches(prompt_ids): marks = ",".join("?" for _ in chunk) for row in conn.execute( - f"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED} " # nosec B608 + f"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED}," # nosec B608 + f"{_NEGATIVE_OF_OTHER} " f"FROM prompts p WHERE p.id IN ({marks}) AND {_ELIGIBLE}", chunk, ): - protected, archived, paired = row[1], row[2], row[3] + protected, archived, paired, negative_of_other = row[1:5] if protected: found[row[0]] = "protected" elif archived: found[row[0]] = "already_archived" + elif paired: + found[row[0]] = "archive" + if not negative_of_other: + deletable.add(row[0]) else: - found[row[0]] = "archive" if paired else "delete" + found[row[0]] = "delete" classes = {name: [] for name in CLASSES} for pid in prompt_ids: classes[found.get(pid, "not_found")].append(pid) + # #482: the archive ids Delete permanently may delete with their pairs. + classes["deletable"] = [pid for pid in classes["archive"] if pid in deletable] return classes @staticmethod @@ -137,6 +154,82 @@ class LibraryArchiveOperations: ] return prints + @staticmethod + def _distinct_images(conn: sqlite3.Connection, prompt_ids: list[int]) -> int: + """Image rows of ``prompt_ids`` (each image once; #482 deletes them).""" + return LibraryArchiveOperations._image_links(conn, prompt_ids) + + @staticmethod + def _pair_prints(conn: sqlite3.Connection, prompt_ids: list[int]) -> dict: + """#482: each prompt's own saved pairs, as the review saw them. + + Per pair (by id): its negative, archived flag, metadata, usage, ordered + tag ids and its image-pair links (count and newest id). + """ + prints = {pid: [] for pid in prompt_ids} + for chunk in _batches(prompt_ids): + marks = ",".join("?" for _ in chunk) + for row in conn.execute( + "SELECT pp.positive_prompt_id,pp.id,pp.negative_prompt_id," # nosec + "pp.archived,pp.title,pp.category,pp.rating,pp.notes,pp.run_count," + "pp.last_used_at,pp.updated_at," + "(SELECT group_concat(tag_id) FROM (SELECT tag_id FROM " + "prompt_pair_tags WHERE pair_id=pp.id ORDER BY tag_id))," + "(SELECT COUNT(*) FROM image_prompt_pairs WHERE pair_id=pp.id)," + "(SELECT MAX(id) FROM image_prompt_pairs WHERE pair_id=pp.id) " + f"FROM prompt_pairs pp WHERE pp.positive_prompt_id IN ({marks}) " + "ORDER BY pp.id", + chunk, + ): + prints[row[0]].append( + [ + v if isinstance(v, (int, str, type(None))) else repr(v) + for v in tuple(row)[1:] + ] + ) + return prints + + @classmethod + def _review_digests(cls, conn: sqlite3.Connection, prompt_ids: list[int]) -> dict: + """#482: one digest per deletable prompt of its row and its pairs.""" + prompts = cls._fingerprints(conn, prompt_ids) + pairs = cls._pair_prints(conn, prompt_ids) + return { + pid: hashlib.sha256( + json.dumps( + [prompts.get(pid), pairs[pid]], separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + for pid in prompt_ids + } + + @staticmethod + def _paired_counts( + conn: sqlite3.Connection, prompt_ids: list[int], selected: list[int] + ) -> dict: + """#482: what Delete permanently would remove with ``prompt_ids``.""" + pairs = archived = variants = 0 + negatives = set() + for chunk in _batches(prompt_ids): + marks = ",".join("?" for _ in chunk) + for archived_flag, negative, positive in conn.execute( + "SELECT archived,negative_prompt_id,positive_prompt_id " # nosec + f"FROM prompt_pairs WHERE positive_prompt_id IN ({marks})", + chunk, + ): + pairs += 1 + archived += 1 if archived_flag else 0 + if negative is not None: + variants += 1 + if negative != positive: + negatives.add(negative) + return { + "pairs": pairs, + "archived_pairs": archived, + "variants": variants, + "kept_negatives": len(negatives - set(selected)), + } + @staticmethod def _checked_ids(prompt_ids: list[int], expected_epoch: str) -> list[int]: _validate_epoch(expected_epoch) @@ -156,11 +249,16 @@ class LibraryArchiveOperations: with self.library_snapshot() as (conn, _epoch, _ceiling): epoch = self._library_epoch(conn, expected_epoch) classes = self._classify_library_delete(conn, prompt_ids) + deletable = classes["deletable"] return { "library_epoch": epoch, **classes, "image_links": self._image_links(conn, classes["delete"]), "fingerprints": self._fingerprints(conn, classes["delete"]), + # #482: Delete permanently, with the deletable prompts' pairs. + "review_digests": self._review_digests(conn, deletable), + "paired_counts": self._paired_counts(conn, deletable, prompt_ids), + "paired_image_links": self._distinct_images(conn, deletable), } @staticmethod @@ -191,6 +289,7 @@ class LibraryArchiveOperations: *, expected_epoch: str, candidates: dict | None, + delete_paired: dict | None = None, ) -> dict: """Archive and delete what the user reviewed, all or nothing. @@ -201,6 +300,11 @@ class LibraryArchiveOperations: still classifies ``delete`` and its fingerprint is unchanged; one that is now paired, and a reviewed archive now unpaired, are archived (the restorable direction); anything else is kept and counted. + + ``delete_paired`` (#482) maps reviewed archive ids the user chose to + Delete permanently to their review digest. Each is deleted with its + own saved pairs only while it is still deletable and its digest is + unchanged; otherwise it is archived and counted ``changed``. """ _validate_epoch(expected_epoch) for ids in (archive_ids, delete_ids): @@ -209,6 +313,16 @@ class LibraryArchiveOperations: ): raise ValueError("prompt ids must be lists of positive integers") reviewed_delete = set(delete_ids) + delete_paired = {} if delete_paired is None else delete_paired + if not isinstance(delete_paired, dict) or any( + type(pid) is not int + or not isinstance(digest, str) + or not _DIGEST.match(digest) + for pid, digest in delete_paired.items() + ): + raise ValueError("delete_paired must map prompt ids to review digests") + if not set(delete_paired) <= set(archive_ids): + raise ValueError("A permanent delete must be a reviewed archive id") if not archive_ids and not delete_ids: raise ValueError("Nothing to delete or archive") if reviewed_delete & set(archive_ids): @@ -218,16 +332,31 @@ class LibraryArchiveOperations: epoch = self._library_epoch(conn, expected_epoch) classes = self._classify_library_delete(conn, prompt_ids) now_prints = self._fingerprints(conn, classes["delete"]) - deletes, archives, changed = [], list(classes["archive"]), 0 + deletes, archives, changed = [], [], 0 + paired_now = set(classes["deletable"]) + chosen = [pid for pid in classes["archive"] if pid in delete_paired] + digests = self._review_digests( + conn, [pid for pid in chosen if pid in paired_now] + ) + paired_deletes = [] + for pid in classes["archive"]: + if pid not in delete_paired: + archives.append(pid) + elif digests.get(pid) == delete_paired[pid]: + paired_deletes.append(pid) + else: + archives.append(pid) # changed since the review: restorable + changed += 1 for pid in classes["delete"]: if pid not in reviewed_delete: archives.append(pid) # reviewed as archive, now unpaired + changed += 1 if pid in delete_paired else 0 elif candidates is None or candidates.get(pid) == now_prints[pid]: deletes.append(pid) else: changed += 1 changed += len(classes["protected"]) - image_links = self._image_links(conn, deletes) + image_links = self._image_links(conn, [*deletes, *paired_deletes]) now = utc_now_iso() for pid in archives: self._repair_hash(conn, pid) @@ -240,7 +369,25 @@ class LibraryArchiveOperations: "UPDATE prompts SET updated_at=? WHERE id=?", [(now, pid) for pid in archives], ) - deleted = delete_prompt_rows(conn, deletes) + # #482: the image-pair links (RESTRICT) go first, then the pairs + # (pair tags CASCADE, review decisions SET NULL), then the prompts. + pairs_removed = 0 + for chunk in _batches(paired_deletes): + marks = ",".join("?" for _ in chunk) + pairs_of = ( + "SELECT id FROM prompt_pairs " # nosec B608 + f"WHERE positive_prompt_id IN ({marks})" + ) + conn.execute( + f"DELETE FROM image_prompt_pairs WHERE pair_id IN ({pairs_of})", + chunk, + ) + pairs_removed += conn.execute( + "DELETE FROM prompt_pairs " # nosec B608 + f"WHERE positive_prompt_id IN ({marks})", + chunk, + ).rowcount + deleted = delete_prompt_rows(conn, [*deletes, *paired_deletes]) return { "library_epoch": epoch, "deleted": deleted, @@ -249,6 +396,7 @@ class LibraryArchiveOperations: "not_found": len(classes["not_found"]), "changed": changed, "image_links": image_links, + "pairs_removed": pairs_removed, } def restore_library_prompts( diff --git a/py/api/library_delete.py b/py/api/library_delete.py index 34038e04..85a43405 100644 --- a/py/api/library_delete.py +++ b/py/api/library_delete.py @@ -47,6 +47,8 @@ RESTORE_URL = "/prompt_manager/library/restore" DELETE_TITLE = "Delete prompts (Library)" _SELECTION_KEYS = frozenset({"prompt_ids", "expected_epoch"}) _DELETE_KEYS = frozenset({"archive_ids", "delete_ids", "expected_epoch", "backup"}) +# #482: Delete permanently, with its digests; both or neither (a #443 body). +_PAIRED_KEYS = frozenset({"delete_paired_ids", "paired_digests"}) STORAGE_CHANGED = "Prompt storage was restored; refresh the library" @@ -64,19 +66,39 @@ def _storage_changed(exc): return _failure(409, exc.code, STORAGE_CHANGED, library_epoch=exc.epoch) -async def _body(request, keys): - """The JSON object with exactly ``keys``, or raise ValueError.""" +async def _body(request, keys, optional=frozenset()): + """The JSON object with exactly ``keys`` (plus all or none of ``optional``).""" try: data = await request.json() except ValueError: raise ValueError("Expected a JSON object") from None if not isinstance(data, dict): raise ValueError("Expected a JSON object") - if set(data) != keys: + extra = set(data) - keys + if not keys <= set(data) or (extra and extra != optional): raise ValueError("Request must contain exactly " + ", ".join(sorted(keys))) return data +def _paired_delete(data, archive_ids): + """#482: the reviewed archive ids to delete with their pairs -> digest.""" + if "delete_paired_ids" not in data: + return {} + ids = _reviewed_ids(data["delete_paired_ids"], "delete_paired_ids") + digests = data["paired_digests"] + if not isinstance(digests, dict): + raise ValueError("paired_digests must map prompt ids to digests") + if not set(ids) <= set(archive_ids): + raise ValueError("delete_paired_ids must be reviewed archive ids") + chosen = {} + for pid in ids: + digest = digests.get(str(pid)) + if not isinstance(digest, str) or len(digest) != 64: + raise ValueError("Every delete_paired_ids entry needs its review digest") + chosen[pid] = digest + return chosen + + def _reviewed_ids(value, name): """A reviewed id list: possibly empty, else as ``parse_bulk_prompt_ids``.""" if value == []: @@ -111,7 +133,7 @@ class LibraryDeleteRoutesMixin: expected_epoch=data["expected_epoch"], ) disk = None - if review["delete"] and policy == "on": + if (review["delete"] or review["deletable"]) and policy == "on": public, short, _size = await self._run_in_executor( _maintenance.disk_plan, db.model.db_path, @@ -145,6 +167,13 @@ class LibraryDeleteRoutesMixin: "already_archived": len(review["already_archived"]), "not_found": len(review["not_found"]), "image_links": review["image_links"], + # #482: the archive ids Delete permanently may delete with pairs. + "deletable": review["deletable"], + "review_digests": { + str(pid): digest for pid, digest in review["review_digests"].items() + }, + "paired_counts": review["paired_counts"], + "paired_image_links": review["paired_image_links"], "backup": policy, "disk": disk, } @@ -152,7 +181,7 @@ class LibraryDeleteRoutesMixin: async def delete_library_prompts(self, request): try: - data = await _body(request, _DELETE_KEYS) + data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS) archive_ids = _reviewed_ids(data["archive_ids"], "archive_ids") delete_ids = _reviewed_ids(data["delete_ids"], "delete_ids") if not archive_ids and not delete_ids: @@ -163,6 +192,7 @@ class LibraryDeleteRoutesMixin: raise ValueError("A prompt cannot be both archived and deleted") if data["backup"] not in ("on", "off"): raise ValueError("backup must be on or off") + paired = _paired_delete(data, archive_ids) except ValueError as exc: return _invalid(str(exc)) busy = self._jobs.blocker(PROMPT_DELETE_JOB_KIND, admission="records") @@ -173,7 +203,7 @@ class LibraryDeleteRoutesMixin: PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records" ) as job: response = await self._held_library_delete( - self.db, data, archive_ids, delete_ids + self.db, data, archive_ids, delete_ids, paired ) if response.status >= 400: job.finish("failed") @@ -181,18 +211,21 @@ class LibraryDeleteRoutesMixin: except JobBusy as busy: return busy_response(busy) - async def _held_library_delete(self, db, data, archive_ids, delete_ids): + async def _held_library_delete(self, db, data, archive_ids, delete_ids, paired): epoch = data["expected_epoch"] promised = data["backup"] == "on" or self._safety_backup_policy() == "on" backup = None try: candidates = None - if delete_ids: + if delete_ids or paired: review = await self._run_in_executor( - db.classify_library_delete, delete_ids, expected_epoch=epoch + db.classify_library_delete, + [*delete_ids, *paired], + expected_epoch=epoch, ) candidates = review["fingerprints"] - if candidates and promised: + # #482: a permanent delete of a prompt in a saved pair is backed up too. + if (candidates or review["deletable"]) and promised: _disk, short, _size = await self._run_in_executor( _maintenance.disk_plan, db.model.db_path, @@ -214,6 +247,7 @@ class LibraryDeleteRoutesMixin: delete_ids, expected_epoch=epoch, candidates=candidates if candidates is not None else {}, + delete_paired=paired, ) except LibraryCommandError as exc: return _storage_changed(exc) diff --git a/tests/browser/library-delete-concept.spec.mjs b/tests/browser/library-delete-concept.spec.mjs index 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 725b5427..d8b5f820 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..825739fc 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 (D160): 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/tests/test_bulk_library_delete.py b/tests/test_bulk_library_delete.py index 0e4bfc08..e8c6b312 100644 --- a/tests/test_bulk_library_delete.py +++ b/tests/test_bulk_library_delete.py @@ -62,6 +62,8 @@ def table_dump(db): class LibraryDeleteCase(unittest.TestCase): + maxDiff = None + def setUp(self): self.temp = tempfile.TemporaryDirectory() # Cleanups run last-in first-out and after every cleanup a test adds, @@ -138,9 +140,23 @@ class TestClassifyAndApply(LibraryDeleteCase): "not_found": [negative_only, 99999], "image_links": 1, "fingerprints": result["fingerprints"], + # #482: the archive ids Delete permanently could remove (with pairs). + "deletable": [positive, archived_pair_positive], + "review_digests": result["review_digests"], + "paired_counts": { + "pairs": 2, + "archived_pairs": 1, + "variants": 2, + # "only negative" is itself selected; only "blur" is kept. + "kept_negatives": 1, + }, + "paired_image_links": 0, }, ) self.assertEqual(list(result["fingerprints"]), [plain]) + self.assertEqual( + list(result["review_digests"]), [positive, archived_pair_positive] + ) def test_apply_archives_paired_deletes_unpaired_and_skips_protected(self): plain = save(self.db, "Plain", tags=["red"]) @@ -160,6 +176,7 @@ class TestClassifyAndApply(LibraryDeleteCase): "not_found": 0, "changed": 0, "image_links": 1, + "pairs_removed": 0, }, ) self.assertEqual(prompt_ids(self.db), sorted([protected, positive, negative])) @@ -524,3 +541,188 @@ class TestArchivedReaders(LibraryDeleteCase): if __name__ == "__main__": unittest.main() + + +class TestPairedDelete(LibraryDeleteCase): + """#482 (D160): Delete permanently also removes a prompt's own saved pairs.""" + + def pair_rows(self, positive): + return rows( + self.db, + "SELECT id FROM prompt_pairs WHERE positive_prompt_id=? ORDER BY id", + (positive,), + ) + + def pair_link(self, pair, image): + return self.db.link_image_to_pair(pair, image, {"resolver_version": 1}) + + def delete_paired(self, ids, review, archive=None, epoch=None): + """Write ``ids`` as Delete permanently, with the digests ``review`` gave.""" + archive = review["archive"] if archive is None else archive + return self.db.archive_or_delete_library_prompts( + archive, + review["delete"], + expected_epoch=epoch or self.epoch, + candidates=review["fingerprints"], + delete_paired={pid: review["review_digests"][pid] for pid in ids}, + ) + + def test_classify_marks_deletable_and_archive_only_with_pair_counts(self): + cat, cat_negative, _pair = self.paired("Cat", "blur") + self.db.save_prompt_pair("Cat", "grain") + archived_pair = self.db.save_prompt_pair("Cat", "noise") + self.db.archive_prompt_pair(archived_pair, True) + shared = save(self.db, "Shared") + self.image(shared, "shared.png") + self.db.save_prompt_pair("Dog", "Shared") # used as another positive's negative + review = self.classify([cat, shared]) + self.assertEqual(review["archive"], [cat, shared]) + self.assertEqual(review["deletable"], [cat]) + self.assertEqual(list(review["review_digests"]), [cat]) + self.assertRegex(review["review_digests"][cat], r"^[0-9a-f]{64}$") + self.assertEqual( + review["paired_counts"], + {"pairs": 3, "archived_pairs": 1, "variants": 3, "kept_negatives": 3}, + ) + self.assertEqual(review["paired_image_links"], 0) + + def test_an_empty_negative_pair_is_deleted_with_its_prompt(self): + # Live prompt 7896: an auto-created pair with an explicitly empty negative. + pair = self.db.save_prompt_pair("été", "") + prompt = self.db.get_prompt_pair(pair)["positive_prompt_id"] + review = self.classify([prompt]) + self.assertEqual(review["deletable"], [prompt]) + self.assertEqual( + review["paired_counts"], + {"pairs": 1, "archived_pairs": 0, "variants": 0, "kept_negatives": 0}, + ) + result = self.delete_paired([prompt], review) + self.assertEqual((result["deleted"], result["archived"]), (1, 0)) + self.assertEqual(result["pairs_removed"], 1) + self.assertNotIn(prompt, prompt_ids(self.db)) + self.assertEqual(self.pair_rows(prompt), []) + self.assertEqual(rows(self.db, "PRAGMA foreign_key_check"), []) + + def test_rows_removed_are_the_legacy_pair_deletes_then_the_prompt_delete(self): + twin = PromptDatabase(str(self.root / "twin.db")) + self.addCleanup(twin.close_all) + twin_epoch = twin.get_library_options()["library_epoch"] + made = {} + for db in (self.db, twin): + plain = db.save_prompt_pair("Owl", "", tags=["night"], title="Owl") + with_negative = db.save_prompt_pair("Owl", "dark", rating=4) + hidden = db.save_prompt_pair("Owl", "fog") + db.archive_prompt_pair(hidden, True) + owl = db.get_prompt_pair(plain)["positive_prompt_id"] + path = self.root / "owl.png" + path.write_bytes(b"png") + image = db.link_image_to_prompt(owl, str(path), {"workflow": {"nodes": []}}) + db.link_image_to_pair(with_negative, image, {"resolver_version": 1}) + save(db, "Bystander", tags=["night"]) + made[id(db)] = (owl, [plain, with_negative, hidden]) + owl, pairs = made[id(self.db)] + review = self.classify([owl]) + self.delete_paired([owl], review) + twin_owl, twin_pairs = made[id(twin)] + with twin.model.get_connection() as conn: + conn.execute( + "DELETE FROM image_prompt_pairs WHERE pair_id IN (SELECT id FROM " + "prompt_pairs WHERE positive_prompt_id=?)", + (twin_owl,), + ) + conn.commit() + for pair in twin_pairs: + self.assertTrue(twin.delete_prompt_pair(pair)) + twin.archive_or_delete_library_prompts( + [], [twin_owl], expected_epoch=twin_epoch, candidates=None + ) + self.assertEqual(table_dump(self.db), table_dump(twin)) + # The negatives stay, and the image file stays on disk. + self.assertTrue((self.root / "owl.png").exists()) + texts = [row[0] for row in rows(self.db, "SELECT text FROM prompts")] + self.assertTrue({"dark", "fog", "Bystander"} <= set(texts)) + + def test_a_negative_used_by_another_positive_is_archived_never_deleted(self): + shared = save(self.db, "Shared") + self.image(shared, "s.png") + self.db.save_prompt_pair("Dog", "Shared") + review = self.classify([shared]) + self.assertEqual(review["deletable"], []) + result = self.db.archive_or_delete_library_prompts( + [shared], + [], + expected_epoch=self.epoch, + candidates={}, + delete_paired={shared: "0" * 64}, + ) + self.assertEqual((result["deleted"], result["archived"]), (0, 1)) + self.assertEqual(result["changed"], 1) + self.assertEqual(archived_ids(self.db), [shared]) + + def test_a_positive_and_its_own_negative_selected_together(self): + cat, blur, _pair = self.paired("Cat", "blur") + self.image(blur, "blur.png") # the negative is a Library prompt too + review = self.classify([cat, blur]) + self.assertEqual(review["deletable"], [cat]) + result = self.delete_paired([cat], review) + self.assertEqual((result["deleted"], result["archived"]), (1, 1)) + self.assertEqual(archived_ids(self.db), [blur]) + self.assertNotIn(cat, prompt_ids(self.db)) + + def test_a_self_pair_is_deleted(self): + pair = self.db.save_prompt_pair("Same", "Same") + same = self.db.get_prompt_pair(pair)["positive_prompt_id"] + review = self.classify([same]) + self.assertEqual(review["deletable"], [same]) + self.assertEqual(review["paired_counts"]["kept_negatives"], 0) + self.assertEqual(self.delete_paired([same], review)["deleted"], 1) + self.assertNotIn(same, prompt_ids(self.db)) + + def test_drift_after_the_review_archives_and_counts_changed(self): + cases = { + "pair added": lambda p: self.db.save_prompt_pair("Fox", "new"), + "pair edited": lambda p: self.db.update_prompt_pair_metadata( + self.pair_rows(p)[0][0], title="Renamed" + ), + "pairs gone": lambda p: self.db.delete_prompt_pair(self.pair_rows(p)[0][0]), + "now another's negative": lambda p: self.db.save_prompt_pair("Hen", "Fox"), + } + for name, change in cases.items(): + with self.subTest(name): + fox, _neg, _pair = self.paired("Fox", "mist " + name) + review = self.classify([fox]) + self.assertEqual(review["deletable"], [fox]) + change(fox) + result = self.delete_paired([fox], review) + self.assertEqual( + (result["deleted"], result["archived"], result["changed"]), + (0, 1, 1), + ) + self.assertIn(fox, archived_ids(self.db)) + self.restore([fox]) + with self.db.model.get_connection() as conn: + conn.execute( + "DELETE FROM prompt_pairs WHERE positive_prompt_id IN " + "(SELECT id FROM prompts WHERE text IN ('Hen','Fox'))" + ) + conn.commit() + + def test_delete_paired_must_name_reviewed_archive_ids(self): + fox, _neg, _pair = self.paired("Fox") + review = self.classify([fox]) + with self.assertRaises(ValueError): + self.db.archive_or_delete_library_prompts( + [], + [], + expected_epoch=self.epoch, + candidates={}, + delete_paired={fox: review["review_digests"][fox]}, + ) + with self.assertRaises(ValueError): + self.db.archive_or_delete_library_prompts( + [fox], + [], + expected_epoch=self.epoch, + candidates={}, + delete_paired={fox: "not a digest"}, + ) diff --git a/tests/test_library_delete_routes.py b/tests/test_library_delete_routes.py index 6bf68d88..a3f403be 100644 --- a/tests/test_library_delete_routes.py +++ b/tests/test_library_delete_routes.py @@ -130,8 +130,13 @@ class TestLibraryDeleteRoutes(LibraryDeleteRouteCase): async def test_archive_only_takes_no_backup(self): self.policy(True) positive = self.paired("Cat") + shared = self.save("Shared") + self.api.db.save_prompt_pair("Dog", "Shared") # archive-only (#482) + self.api.db.link_image_to_prompt(shared, "/tmp/shared-443.png") + # Nothing could be deleted: no disk plan. (#482: a deletable prompt has one.) + self.assertIsNone((await self.review([shared]))["disk"]) review = await self.review([positive]) - self.assertIsNone(review["disk"]) + self.assertIsNotNone(review["disk"]) status, body = await self.confirm(review) self.assertEqual((status, body["archived"], body["backup"]), (200, 1, None)) self.assertEqual(self.backups(), []) @@ -309,6 +314,94 @@ class TestLibraryDeleteRoutes(LibraryDeleteRouteCase): self.assertEqual(response.status, 400) +class TestPairedDeleteRoutes(LibraryDeleteRouteCase): + """#482 (D160): Delete permanently with a prompt's own saved pairs.""" + + async def confirm_paired(self, review, ids, backup=None, digests=None): + return await self.post( + DELETE, + { + "archive_ids": review["archive"], + "delete_ids": review["delete"], + "expected_epoch": review["library_epoch"], + "backup": backup or review["backup"], + "delete_paired_ids": ids, + "paired_digests": digests + or {str(pid): review["review_digests"][str(pid)] for pid in ids}, + }, + ) + + async def test_preview_reports_deletable_and_a_paired_only_delete_backs_up(self): + self.policy(True) + cat = self.paired("Cat") + shared = self.save("Shared") + self.api.db.save_prompt_pair("Dog", "Shared") + self.api.db.link_image_to_prompt(shared, "/tmp/shared-482.png") + review = await self.review([cat, shared]) + self.assertEqual(review["archive"], [cat, shared]) + self.assertEqual(review["deletable"], [cat]) + self.assertEqual(list(review["review_digests"]), [str(cat)]) + self.assertEqual( + review["paired_counts"], + {"pairs": 1, "archived_pairs": 0, "variants": 1, "kept_negatives": 1}, + ) + self.assertEqual(review["paired_image_links"], 0) + # A selection with nothing in the delete class still checks the disk. + self.assertTrue(review["disk"]["sufficient"]) + status, body = await self.confirm_paired(review, [cat]) + self.assertEqual(status, 200, body) + self.assertEqual( + (body["deleted"], body["archived"], body["pairs_removed"]), (1, 1, 1) + ) + self.assertEqual(self.backups(), [body["backup"]]) + self.assertNotIn(cat, self.ids()) + self.assertEqual(self.archived(), [shared]) + + async def test_a_body_without_the_new_keys_is_the_443_delete(self): + cat = self.paired("Cat") + review = await self.review([cat]) + status, body = await self.confirm(review) + self.assertEqual(status, 200, body) + self.assertEqual((body["deleted"], body["archived"]), (0, 1)) + self.assertIn(cat, self.ids()) + + async def test_paired_bodies_are_validated(self): + cat = self.paired("Cat") + plain = self.save("Plain") + review = await self.review([cat, plain]) + digest = review["review_digests"][str(cat)] + base = { + "archive_ids": review["archive"], + "delete_ids": review["delete"], + "expected_epoch": review["library_epoch"], + "backup": "off", + } + cases = [ + {"delete_paired_ids": [cat]}, # without its digests + {"delete_paired_ids": [plain], "paired_digests": {str(plain): digest}}, + {"delete_paired_ids": [cat], "paired_digests": {}}, + {"delete_paired_ids": [cat], "paired_digests": {str(cat): "x"}}, + {"delete_paired_ids": "all", "paired_digests": {}}, + ] + for extra in cases: + with self.subTest(extra=extra): + status, body = await self.post(DELETE, {**base, **extra}) + self.assertEqual((status, body["code"]), (400, "invalid-request")) + self.assertIn(cat, self.ids()) + + async def test_a_short_disk_refuses_a_paired_only_delete(self): + self.policy(True) + cat = self.paired("Cat") + from py.api import maintenance + + with patch.object(maintenance, "_free_bytes", return_value=0): + review = await self.review([cat]) + self.assertFalse(review["disk"]["sufficient"]) + status, body = await self.confirm_paired(review, [cat]) + self.assertEqual((status, body["code"]), (507, "insufficient-space")) + self.assertIn(cat, self.ids()) + + class TestIngestionAfterArchive(LibraryDeleteRouteCase): def scan(self, name, positive, negative="blur"): meta = {"prompt": json.dumps(graph(positive, negative))} diff --git a/web/js/workspace/library-delete.mjs b/web/js/workspace/library-delete.mjs index 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))); + }, }; }