diff --git a/TESTING.md b/TESTING.md
index 12d9dc87..f81916ab 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -2594,7 +2594,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
@@ -2675,6 +2676,97 @@ 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 ("N prompts not in a saved pair") and Archive ("N prompts in a saved
+ pair: hidden, restorable", never negative variants: a pair's prompt with none is archived too) (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 is archived
+ because it is in a saved pair. It and its N negative variants are hidden from the Prompt library; nothing was
+ removed.", or "It is hidden from…" when it has none) 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.
+
### No 1–5 prompt rating in the workspace (#447)
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
@@ -3899,8 +3991,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/database/library.py b/database/library.py
index 49f18ac9..25ba6247 100644
--- a/database/library.py
+++ b/database/library.py
@@ -7,6 +7,7 @@ from contextlib import contextmanager
from .library_folders import folder_condition, list_library_folders
from .library_tokens import library_edit_token
+from .prompt_archive import ARCHIVED, SHELVED
_ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id)
OR EXISTS(SELECT 1 FROM generated_images eg WHERE eg.prompt_id=p.id)
@@ -106,6 +107,12 @@ ARCHIVE_SCOPES = {
}
+# Shelves (#443): the parents on the shelf, or the archived ones.
+PARENT_SCOPES = {"active": SHELVED, "archived": ARCHIVED}
+# A positive pair owner that is on the shelf, as a condition on alias pp.
+SHELVED_PAIR = SHELVED.replace("p.id", "pp.positive_prompt_id")
+
+
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@@ -270,6 +277,8 @@ class LibraryOperations:
rows = conn.execute(
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,p.created_at,"
"p.run_count,p.last_used_at,"
+ + ARCHIVED
+ + " AS archived,"
+ (f"{sort_expression} AS _sort," if sort_expression else "")
+ "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
"AND v.archived=0) AS variant_count,"
@@ -293,6 +302,7 @@ class LibraryOperations:
# Usage is shown, never compared: the token hashes owner fields only.
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
row["last_used_at"] = utc_iso(row["last_used_at"])
+ row["archived"] = bool(row["archived"])
row["edit_token"] = library_edit_token(epoch, row)
return result
@@ -310,10 +320,13 @@ class LibraryOperations:
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
+ shelf: str = "active",
) -> dict:
_limit(limit)
if sort not in _ORDERS:
raise ValueError("Unknown library sort order")
+ if not isinstance(shelf, str) or shelf not in PARENT_SCOPES:
+ raise ValueError("shelf must be active or archived")
where, params = _filter(
text, category, [] if tags is None else tags, tag_mode, folder_prefixes
)
@@ -328,15 +341,18 @@ class LibraryOperations:
ceiling = after["ceiling"]
where += " AND p.id<=?"
params.append(ceiling)
- # One SELECT counts the filtered parents and their active variants.
- total, variant_total = conn.execute(
- "SELECT COUNT(*),COALESCE(SUM((SELECT COUNT(*) FROM prompt_pairs v "
- "WHERE v.positive_prompt_id=p.id AND v.archived=0)),0) FROM "
- + _FROM
- + " WHERE "
- + where,
+ # One SELECT counts the filtered parents of this scope, their active
+ # variants, and the filtered archived parents (Show archived, #443).
+ scope = PARENT_SCOPES[shelf]
+ total, variant_total, archived_total = conn.execute(
+ f"SELECT COALESCE(SUM({scope}),0),"
+ f"COALESCE(SUM(CASE WHEN {scope} THEN (SELECT COUNT(*) "
+ "FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
+ "AND v.archived=0) ELSE 0 END),0),"
+ f"COALESCE(SUM({ARCHIVED}),0) FROM " + _FROM + " WHERE " + where,
params,
).fetchone()
+ where += f" AND {scope}"
expression, direction = _ORDERS[sort]
if after is not None:
operator = ">" if direction == "ASC" else "<"
@@ -369,6 +385,7 @@ class LibraryOperations:
"items": rows,
"total": total,
"variant_total": variant_total,
+ "archived_total": archived_total,
# Echoed so a client can tell this read applied its tag match.
"tag_mode": tag_mode,
"has_more": has_more,
@@ -386,6 +403,7 @@ class LibraryOperations:
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
+ shelf: str = "active",
) -> dict:
with self.library_snapshot(include_ceiling=True) as (conn, epoch, ceiling):
return self._list_library(
@@ -400,6 +418,7 @@ class LibraryOperations:
sort=sort,
limit=limit,
after=after,
+ shelf=shelf,
)
def _is_library_positive(self, conn: sqlite3.Connection, positive_id: int) -> bool:
@@ -644,7 +663,9 @@ class LibraryOperations:
owners = {}
order = []
for row in conn.execute(
- "SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes FROM "
+ "SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,"
+ + ARCHIVED
+ + " AS archived FROM "
+ _FROM
+ " WHERE "
+ _ELIGIBLE
@@ -657,6 +678,7 @@ class LibraryOperations:
"tags": [],
"rating": row["rating"],
"notes": row["notes"],
+ "archived": bool(row["archived"]),
"variants": [],
}
order.append(row["id"])
@@ -701,8 +723,9 @@ class LibraryOperations:
"""System › Overview counts from one snapshot, writing nothing.
``positive_prompts`` and ``negative_variants`` are the unfiltered
- Library's ``total`` and ``variant_total``: eligible parents and active
- pairs (every pair's positive is eligible). ``images`` counts indexed
+ Library's ``total`` and ``variant_total``: eligible parents on the
+ shelf (not archived, #443) and their active pairs (every pair's
+ positive is eligible). ``images`` counts indexed
``generated_images`` rows, not files on disk. An image has no rating
of its own (it shows its linked prompt's), so ``rated_images`` counts
the indexed images whose linked prompt is rated. ``review_pending`` is
@@ -710,8 +733,14 @@ class LibraryOperations:
"""
with self.library_snapshot() as (conn, _epoch, _ceiling):
row = conn.execute(
- "SELECT (SELECT COUNT(*) FROM prompts p WHERE " + _ELIGIBLE + "),"
- "(SELECT COUNT(*) FROM prompt_pairs WHERE archived=0),"
+ "SELECT (SELECT COUNT(*) FROM prompts p WHERE "
+ + _ELIGIBLE
+ + " AND "
+ + SHELVED
+ + "),"
+ "(SELECT COUNT(*) FROM prompt_pairs pp WHERE pp.archived=0 AND "
+ + SHELVED_PAIR
+ + "),"
"(SELECT COUNT(*) FROM generated_images),"
"(SELECT COUNT(*) FROM generated_images gi JOIN prompts r "
"ON r.id=gi.prompt_id WHERE r.rating IS NOT NULL),"
diff --git a/database/library_archive.py b/database/library_archive.py
new file mode 100644
index 00000000..c44aa5e0
--- /dev/null
+++ b/database/library_archive.py
@@ -0,0 +1,289 @@
+"""Delete selected Library prompts; archive the paired ones (#443, D152).
+
+A selected Library parent is classified, in this order, as ``not_found``
+(missing, or not a Library parent), ``protected`` (tagged ``__protected__``;
+never changed), ``already_archived``, ``archive`` (the positive or negative of
+any saved pair, archived pair or not: archiving hides it from the shelf and
+removes nothing) or ``delete`` (in no pair: deleted exactly as the legacy
+delete does, through ``delete_prompt_rows``). Every command checks the
+storage epoch first and runs in one ``BEGIN IMMEDIATE`` transaction.
+
+The write executes what the user reviewed: it is given the reviewed archive
+and delete lists, and deletes only reviewed deletes the route backed up
+(``candidates``) whose fingerprint is unchanged since that backup.
+"""
+
+import sqlite3
+from typing import Iterable
+
+from .library import _ELIGIBLE
+from .library_commands import _validate_bulk_ids, _validate_epoch
+from .models import utc_now_iso
+from .prompt_archive import ARCHIVED as _ARCHIVED
+from .prompt_archive import TABLE as ARCHIVE
+from .workflow_documents import documents_of_rows, release_documents
+
+try:
+ from ..utils.hashing import generate_prompt_hash
+except ImportError:
+ from utils.hashing import generate_prompt_hash
+
+# Ids bound per statement: the pair check binds each id twice, which stays
+# under SQLite's historical 999-variable limit (maintenance.py ID_BATCH).
+ID_BATCH = 400
+_PROTECTED = (
+ "EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
+ "WHERE pt.prompt_id=p.id AND t.name='__protected__')"
+)
+_PAIRED = (
+ "EXISTS(SELECT 1 FROM prompt_pairs pp "
+ "WHERE pp.positive_prompt_id=p.id OR pp.negative_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")
+
+
+def _batches(ids: list) -> Iterable[list]:
+ for start in range(0, len(ids), ID_BATCH):
+ yield ids[start : start + ID_BATCH]
+
+
+def delete_prompt_rows(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
+ """Delete prompts and their image link rows; return the prompts deleted.
+
+ The legacy delete's statements (``delete_prompt``, ``bulk_delete_prompts``)
+ in the caller's transaction: image link rows first (``image_prompt_pairs``
+ cascades, review decisions keep a NULL image), then the prompts (tags and
+ Library state cascade), then the workflow documents no row references.
+ Image files are never touched. The caller checks pairs first.
+ """
+ documents = documents_of_rows(conn, "prompt_id", prompt_ids)
+ deleted = 0
+ for chunk in _batches(list(prompt_ids)):
+ marks = ",".join("?" for _ in chunk)
+ conn.execute(
+ f"DELETE FROM generated_images WHERE prompt_id IN ({marks})", # nosec
+ chunk,
+ )
+ deleted += conn.execute(
+ f"DELETE FROM prompts WHERE id IN ({marks})", chunk # nosec B608
+ ).rowcount
+ release_documents(conn, documents)
+ return deleted
+
+
+class LibraryArchiveOperations:
+ """PromptDatabase mixin: classify, archive or delete, and restore parents."""
+
+ def _classify_library_delete(
+ self, conn: sqlite3.Connection, prompt_ids: list[int]
+ ) -> dict:
+ found = {}
+ 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"FROM prompts p WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
+ chunk,
+ ):
+ protected, archived, paired = row[1], row[2], row[3]
+ if protected:
+ found[row[0]] = "protected"
+ elif archived:
+ found[row[0]] = "already_archived"
+ else:
+ found[row[0]] = "archive" if paired else "delete"
+ classes = {name: [] for name in CLASSES}
+ for pid in prompt_ids:
+ classes[found.get(pid, "not_found")].append(pid)
+ return classes
+
+ @staticmethod
+ def _image_links(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
+ total = 0
+ for chunk in _batches(prompt_ids):
+ marks = ",".join("?" for _ in chunk)
+ total += conn.execute(
+ "SELECT COUNT(*) FROM generated_images " # nosec B608
+ f"WHERE prompt_id IN ({marks})",
+ chunk,
+ ).fetchone()[0]
+ return total
+
+ @staticmethod
+ def _fingerprints(conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
+ """What a safety backup holds of each prompt a delete would remove.
+
+ The prompt row (usage included), its tag ids, its Library title and
+ its image link rows (count and newest id), as a JSON-safe list.
+ """
+ prints = {}
+ for chunk in _batches(prompt_ids):
+ marks = ",".join("?" for _ in chunk)
+ for row in conn.execute(
+ "SELECT p.id,p.text,p.hash,p.category,p.rating,p.notes," # nosec
+ "p.updated_at,p.run_count,p.last_used_at,"
+ "(SELECT group_concat(tag_id) FROM (SELECT tag_id FROM prompt_tags "
+ "WHERE prompt_id=p.id ORDER BY tag_id)),"
+ "(SELECT title FROM prompt_library_state WHERE prompt_id=p.id),"
+ "(SELECT COUNT(*) FROM generated_images WHERE prompt_id=p.id),"
+ "(SELECT MAX(id) FROM generated_images WHERE prompt_id=p.id) "
+ f"FROM prompts p WHERE p.id IN ({marks})",
+ chunk,
+ ):
+ prints[row[0]] = [
+ value if isinstance(value, (int, str, type(None))) else repr(value)
+ for value in tuple(row)[1:]
+ ]
+ return prints
+
+ @staticmethod
+ def _checked_ids(prompt_ids: list[int], expected_epoch: str) -> list[int]:
+ _validate_epoch(expected_epoch)
+ _validate_bulk_ids(prompt_ids)
+ return list(dict.fromkeys(prompt_ids))
+
+ def classify_library_delete(
+ self, prompt_ids: list[int], *, expected_epoch: str
+ ) -> dict:
+ """What a delete of ``prompt_ids`` would do now (reads only).
+
+ Returns the id lists per class, in request order, ``image_links``
+ (the image link rows the ``delete`` class would remove) and
+ ``fingerprints`` of the ``delete`` class (``_fingerprints``).
+ """
+ prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
+ with self.library_snapshot() as (conn, _epoch, _ceiling):
+ epoch = self._library_epoch(conn, expected_epoch)
+ classes = self._classify_library_delete(conn, prompt_ids)
+ return {
+ "library_epoch": epoch,
+ **classes,
+ "image_links": self._image_links(conn, classes["delete"]),
+ "fingerprints": self._fingerprints(conn, classes["delete"]),
+ }
+
+ @staticmethod
+ def _repair_hash(conn: sqlite3.Connection, prompt_id: int) -> None:
+ """Give an archived prompt its canonical hash, so ingestion finds it.
+
+ Scans, the monitor and node saves resolve prompts by hash; a stale or
+ missing one would make them insert a visible copy (Q3). When another
+ row already holds the canonical hash, lookups find that row and no new
+ copy is made, so nothing is written.
+ """
+ text, stored = conn.execute(
+ "SELECT text,hash FROM prompts WHERE id=?", (prompt_id,)
+ ).fetchone()
+ canonical = generate_prompt_hash(text) if isinstance(text, str) else None
+ if canonical is None or stored == canonical:
+ return
+ if conn.execute(
+ "SELECT 1 FROM prompts WHERE hash=? AND id!=?", (canonical, prompt_id)
+ ).fetchone():
+ return
+ conn.execute("UPDATE prompts SET hash=? WHERE id=?", (canonical, prompt_id))
+
+ def archive_or_delete_library_prompts(
+ self,
+ archive_ids: list[int],
+ delete_ids: list[int],
+ *,
+ expected_epoch: str,
+ candidates: dict | None,
+ ) -> dict:
+ """Archive and delete what the user reviewed, all or nothing.
+
+ ``archive_ids`` and ``delete_ids`` are the reviewed classes.
+ ``candidates`` maps each reviewed delete the route backed up to its
+ fingerprint then (None: every reviewed delete, unchecked; tests and
+ callers without a backup). A reviewed delete is deleted only when it
+ 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.
+ """
+ _validate_epoch(expected_epoch)
+ for ids in (archive_ids, delete_ids):
+ if not isinstance(ids, list) or any(
+ type(pid) is not int or not 1 <= pid <= 2**63 - 1 for pid in ids
+ ):
+ raise ValueError("prompt ids must be lists of positive integers")
+ reviewed_delete = set(delete_ids)
+ if not archive_ids and not delete_ids:
+ raise ValueError("Nothing to delete or archive")
+ if reviewed_delete & set(archive_ids):
+ raise ValueError("A prompt cannot be both archived and deleted")
+ prompt_ids = list(dict.fromkeys([*archive_ids, *delete_ids]))
+ with self._pair_transaction() as conn:
+ epoch = self._library_epoch(conn, expected_epoch)
+ classes = self._classify_library_delete(conn, prompt_ids)
+ now_prints = self._fingerprints(conn, classes["delete"])
+ deletes, archives, changed = [], list(classes["archive"]), 0
+ for pid in classes["delete"]:
+ if pid not in reviewed_delete:
+ archives.append(pid) # reviewed as archive, now unpaired
+ 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)
+ now = utc_now_iso()
+ for pid in archives:
+ self._repair_hash(conn, pid)
+ conn.executemany(
+ f"INSERT INTO {ARCHIVE}(prompt_id, archived_at) VALUES (?, ?)",
+ [(pid, now) for pid in archives],
+ )
+ # A changed row is the newest edit the restore fingerprint reads.
+ conn.executemany(
+ "UPDATE prompts SET updated_at=? WHERE id=?",
+ [(now, pid) for pid in archives],
+ )
+ deleted = delete_prompt_rows(conn, deletes)
+ return {
+ "library_epoch": epoch,
+ "deleted": deleted,
+ "archived": len(archives),
+ "already_archived": len(classes["already_archived"]),
+ "not_found": len(classes["not_found"]),
+ "changed": changed,
+ "image_links": image_links,
+ }
+
+ def restore_library_prompts(
+ self, prompt_ids: list[int], *, expected_epoch: str
+ ) -> dict:
+ """Put archived prompts back on the shelf, all or nothing.
+
+ Every requested id with an archive row is restored, whatever its
+ protection or Library parent state: restoring only un-hides it.
+ """
+ prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
+ with self._pair_transaction() as conn:
+ epoch = self._library_epoch(conn, expected_epoch)
+ restored, existing = [], set()
+ for chunk in _batches(prompt_ids):
+ marks = ",".join("?" for _ in chunk)
+ for pid, archived in conn.execute(
+ f"SELECT p.id,{_ARCHIVED} FROM prompts p " # nosec B608
+ f"WHERE p.id IN ({marks})",
+ chunk,
+ ):
+ existing.add(pid)
+ if archived:
+ restored.append(pid)
+ now = utc_now_iso()
+ conn.executemany(
+ f"DELETE FROM {ARCHIVE} WHERE prompt_id=?", [(pid,) for pid in restored]
+ )
+ conn.executemany(
+ "UPDATE prompts SET updated_at=? WHERE id=?",
+ [(now, pid) for pid in restored],
+ )
+ return {
+ "library_epoch": epoch,
+ "restored": len(restored),
+ "unchanged": len(existing) - len(restored),
+ "not_found": len(prompt_ids) - len(existing),
+ }
diff --git a/database/maintenance.py b/database/maintenance.py
index a76d2d9c..c2a5cdd8 100644
--- a/database/maintenance.py
+++ b/database/maintenance.py
@@ -85,6 +85,12 @@ PROTECTIONS = (
"with Library state (a title or an explicit parent)",
"with Library state (a title or an explicit parent)",
),
+ (
+ # #443: an archived prompt is restorable, never pruned.
+ "EXISTS (SELECT 1 FROM prompt_archive pa WHERE pa.prompt_id = p.id)",
+ "archived in the Library",
+ "archived in the Library",
+ ),
(
"p.id IN (SELECT prompt_id FROM live_hint_prompts)",
"used by recorded runs",
@@ -93,7 +99,8 @@ PROTECTIONS = (
)
# Exempt references: images, pairs, the __protected__ tag, Library parent
-# state, and live recorded-run hints (computed once, see LIVE_HINT_CTES).
+# state, the Library archive, and live recorded-run hints (computed once,
+# see LIVE_HINT_CTES).
ORPHANED_PROMPTS_SQL = (
"WITH "
+ LIVE_HINT_CTES
diff --git a/database/models.py b/database/models.py
index d64cbcba..4c506962 100644
--- a/database/models.py
+++ b/database/models.py
@@ -45,6 +45,7 @@ except ImportError:
from utils.hashing import register_prompt_functions
from .image_categories import image_categories_schema_error, migrate_image_categories
from .image_removal_schema import image_removals_schema_error, migrate_image_removals
+from .prompt_archive import migrate_prompt_archive, prompt_archive_schema_error
from .workflow_documents import (
DOCUMENT_ID_COLUMNS,
migrate_workflow_documents,
@@ -367,6 +368,8 @@ class PromptModel:
migrate_autotag_state(conn)
# Image categories (#332): independent of every table above.
migrate_image_categories(conn)
+ # The Library parent archive (#443): independent of every table above.
+ migrate_prompt_archive(conn)
# Removal marks (#480): independent of every table above.
migrate_image_removals(conn)
# Last: the older generated_images rebuilds run only before file_path.
@@ -1245,6 +1248,11 @@ class PromptModel:
)
if categories_error:
return False, categories_error
+ archive_error = prompt_archive_schema_error(
+ conn, allow_missing=not require_library_state
+ )
+ if archive_error:
+ return False, archive_error
removals_error = image_removals_schema_error(
conn, allow_missing=not require_library_state
)
diff --git a/database/operations.py b/database/operations.py
index da5d0ae8..51607876 100644
--- a/database/operations.py
+++ b/database/operations.py
@@ -25,6 +25,7 @@ from .review import ReviewOperations
from .review_decisions import ReviewDecisionOperations
from .saved_views import SavedViewOperations
from .library_commands import LibraryCommandOperations
+from .library_archive import LibraryArchiveOperations, delete_prompt_rows
from .vocabulary import VocabularyOperations
from .execution_history import ExecutionHistoryOperations
from .execution_contexts import ExecutionContextOperations
@@ -449,6 +450,7 @@ class PromptDatabase(
PairOperations,
LibraryOperations,
LibraryCommandOperations,
+ LibraryArchiveOperations,
VocabularyOperations,
ExecutionHistoryOperations,
ExecutionContextOperations,
@@ -2351,16 +2353,10 @@ class PromptDatabase(
Returns:
Number of prompts actually deleted.
"""
- count = 0
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, prompt_ids)
- documents = documents_of_rows(conn, "prompt_id", prompt_ids)
- for pid in prompt_ids:
- conn.execute("DELETE FROM generated_images WHERE prompt_id = ?", (pid,))
- cursor = conn.execute("DELETE FROM prompts WHERE id = ?", (pid,))
- if cursor.rowcount > 0:
- count += 1
- release_documents(conn, documents)
+ # The workspace Library's delete runs the same statements (#443).
+ count = delete_prompt_rows(conn, list(dict.fromkeys(prompt_ids)))
conn.commit()
return count
diff --git a/database/prompt_archive.py b/database/prompt_archive.py
new file mode 100644
index 00000000..9f824361
--- /dev/null
+++ b/database/prompt_archive.py
@@ -0,0 +1,107 @@
+"""Library parent archive (#443, D152): prompts hidden from the shelf, restorable.
+
+A row means the Library parent is archived; no row means it is on the shelf.
+Nothing else about the prompt changes: its text, metadata, tags, run count,
+image links, pairs and recorded runs stay. Restore deletes the row. Deleting
+the prompt (any path) drops the row by cascade.
+
+A table of its own, not a column: ``prompt_library_state`` is validated by its
+exact definition, so a new column there would make older builds refuse the
+upgraded database, while older builds ignore an unknown table (they show the
+archived prompts again and keep the rows). Optional in old backups, required
+once a database is current.
+"""
+
+import re
+import sqlite3
+
+TABLE = "prompt_archive"
+MAX_STAMP_BYTES = 64
+# What ``utc_now_iso`` writes starts with a date; anything else was not written here.
+_STAMP_GLOB = "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T*"
+_DDL = (
+ f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
+ "archived_at TEXT NOT NULL, "
+ "FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE)"
+)
+_COLUMNS = (("prompt_id", "INTEGER", 0, 1), ("archived_at", "TEXT", 1, 0))
+_FOREIGN_KEYS = {("prompt_id", "prompts", "id", "CASCADE")}
+# A predicate on ``p`` (the prompts alias): the parent is not archived.
+SHELVED = f"NOT EXISTS(SELECT 1 FROM {TABLE} pa WHERE pa.prompt_id=p.id)"
+ARCHIVED = f"EXISTS(SELECT 1 FROM {TABLE} pa WHERE pa.prompt_id=p.id)"
+
+
+def _compact(sql: str) -> str:
+ return re.sub(r"\s+", "", sql or "").lower()
+
+
+def _structure_error(conn: sqlite3.Connection, allow_missing: bool) -> str | None:
+ objects = conn.execute(
+ "SELECT type,sql FROM sqlite_master WHERE name=? "
+ "OR (type IN ('index','trigger') AND tbl_name=?)",
+ (TABLE, TABLE),
+ ).fetchall()
+ if not objects:
+ return None if allow_missing else "Missing prompt archive schema"
+ tables = [sql for kind, sql in objects if kind == "table"]
+ if len(tables) != 1 or len(objects) != 1:
+ return "Prompt archive name is reserved by an unsupported schema object"
+ if _compact(tables[0]) != _compact(_DDL):
+ return "Prompt archive has an invalid table definition"
+ columns = tuple(
+ (row[1], row[2].upper(), row[3], row[5])
+ for row in conn.execute(f"PRAGMA table_info({TABLE})")
+ )
+ if columns != _COLUMNS:
+ return "Prompt archive has invalid columns"
+ foreign_keys = {
+ (row[3], row[2], row[4], row[6])
+ for row in conn.execute(f"PRAGMA foreign_key_list({TABLE})")
+ }
+ if foreign_keys != _FOREIGN_KEYS:
+ return "Prompt archive has invalid foreign key definitions"
+ return None
+
+
+def prompt_archive_schema_error(
+ conn: sqlite3.Connection, *, allow_missing: bool = True
+) -> str | None:
+ """Reject an unsupported archive schema or values; reads only.
+
+ A missing table is acceptable for backups that predate it unless
+ ``allow_missing`` is false.
+ """
+ error = _structure_error(conn, allow_missing)
+ if error is not None:
+ return error
+ if not conn.execute(
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (TABLE,)
+ ).fetchone():
+ return None
+ if conn.execute(f"PRAGMA foreign_key_check({TABLE})").fetchone():
+ return "Prompt archive contains foreign key violations"
+ if conn.execute(
+ f"SELECT 1 FROM {TABLE} WHERE typeof(archived_at)!='text' " # nosec B608
+ f"OR length(CAST(archived_at AS BLOB))>{MAX_STAMP_BYTES} "
+ f"OR archived_at NOT GLOB '{_STAMP_GLOB}' LIMIT 1"
+ ).fetchone():
+ return "Prompt archive contains invalid values"
+ return None
+
+
+def migrate_prompt_archive(conn: sqlite3.Connection) -> None:
+ """Install the table atomically; validate before and after (no backfill)."""
+ conn.execute("SAVEPOINT prompt_archive_schema")
+ try:
+ error = _structure_error(conn, True)
+ if error:
+ raise sqlite3.DatabaseError(error)
+ conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
+ error = _structure_error(conn, False)
+ if error:
+ raise sqlite3.DatabaseError(error)
+ conn.execute("RELEASE prompt_archive_schema")
+ except BaseException:
+ conn.execute("ROLLBACK TO prompt_archive_schema")
+ conn.execute("RELEASE prompt_archive_schema")
+ raise
diff --git a/database/restore_stage.py b/database/restore_stage.py
index 704435bf..1a3a3762 100644
--- a/database/restore_stage.py
+++ b/database/restore_stage.py
@@ -149,6 +149,7 @@ def library_summary(conn: sqlite3.Connection) -> Dict[str, int]:
collections (#331).
"""
from .library import _ELIGIBLE
+ from .prompt_archive import SHELVED, TABLE as ARCHIVE
has_state = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND "
@@ -157,9 +158,15 @@ def library_summary(conn: sqlite3.Connection) -> Dict[str, int]:
has_pairs = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='prompt_pairs'"
).fetchone()
+ has_archive = conn.execute(
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (ARCHIVE,)
+ ).fetchone()
if has_state and has_pairs:
+ # Archived parents (#443) are off the shelf, as in library_counts.
positives = conn.execute(
- "SELECT COUNT(*) FROM prompts p WHERE " + _ELIGIBLE
+ "SELECT COUNT(*) FROM prompts p WHERE "
+ + _ELIGIBLE
+ + (" AND " + SHELVED if has_archive else "")
).fetchone()[0]
else:
positives = _count(conn, "prompts")
diff --git a/database/vocabulary.py b/database/vocabulary.py
index 5eb22c34..971112bd 100644
--- a/database/vocabulary.py
+++ b/database/vocabulary.py
@@ -20,7 +20,8 @@ counted, hashed into the token and renamed with the prompts and pairs.
import sqlite3
-from .library import _ELIGIBLE, UNTAGGED, _escape, _limit
+from .library import _ELIGIBLE, SHELVED_PAIR, UNTAGGED, _escape, _limit
+from .prompt_archive import SHELVED
from .library_commands import _TOKEN, LibraryCommandError, _validate_epoch
from .library_tokens import canonical_token
from .models import utc_now_iso
@@ -40,17 +41,21 @@ _IMAGE_FILES = (
def _tag_rows(page: str, order: str = "page.id") -> str:
"""Count rows for the registry rows selected by ``page`` (id, name)."""
- # Eligible parent components carrying a tag in the page, plus the
- # positive parent of every active pair carrying it (always eligible).
+ # Eligible parent components on the shelf carrying a tag in the page,
+ # plus the positive parent of every active pair carrying it (always
+ # eligible) when that parent is on the shelf: archived parents (#443)
+ # are hidden with their pairs, so Browse lists what was counted.
return (
"WITH page AS (" + page + "),"
"owners(tag_id,pid) AS ("
"SELECT pt.tag_id,p.id FROM prompts p "
"JOIN prompt_tags pt ON pt.prompt_id=p.id "
"WHERE pt.tag_id IN (SELECT id FROM page) AND " + _ELIGIBLE + " "
+ "AND " + SHELVED + " "
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id "
- "WHERE pp.archived=0 AND ppt.tag_id IN (SELECT id FROM page)),"
+ "WHERE pp.archived=0 AND " + SHELVED_PAIR + " "
+ "AND ppt.tag_id IN (SELECT id FROM page)),"
"images(tag_id,n) AS (" + _IMAGE_FILES + " "
"WHERE mit.tag_id IN (SELECT id FROM page) GROUP BY mit.tag_id) "
"SELECT page.id,page.name,COUNT(DISTINCT owners.pid) AS prompt_count,"
@@ -79,8 +84,10 @@ _RANKED_TAG_PAGE = (
"WITH owners(tag_id,pid) AS ("
"SELECT pt.tag_id,p.id FROM prompts p "
"JOIN prompt_tags pt ON pt.prompt_id=p.id WHERE " + _ELIGIBLE + " "
+ "AND " + SHELVED + " "
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
- "JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id WHERE pp.archived=0),"
+ "JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id WHERE pp.archived=0 "
+ "AND " + SHELVED_PAIR + "),"
"counted AS (SELECT t.id,t.name,COUNT(DISTINCT o.pid) AS prompt_count "
"FROM tags t LEFT JOIN owners o ON o.tag_id=t.id WHERE {search} "
"GROUP BY t.id),"
@@ -94,7 +101,13 @@ _RANKED_TAG_PAGE = (
"ORDER BY page.prompt_count {direction},page.id"
)
_UNTAGGED_COUNT = (
- "SELECT COUNT(*) FROM prompts p WHERE (" + _ELIGIBLE + ") AND " + UNTAGGED
+ "SELECT COUNT(*) FROM prompts p WHERE ("
+ + _ELIGIBLE
+ + ") AND "
+ + UNTAGGED
+ # Untagged prompts opens the shelf, which hides archived parents (#443).
+ + " AND "
+ + SHELVED
)
_TAG_ONE = _tag_rows("SELECT id,name FROM tags WHERE id=?")
# Every owner kind a tag can sit on, for the delete confirmation.
@@ -134,7 +147,9 @@ def _validate_tag_id(tag_id: object) -> None:
# Category values in Tags & categories (#332 §7): the prompt-backed values
-# (nonblank values on eligible parents or on active pairs, grouped exactly, so
+# (nonblank values on eligible parents or on active pairs; ``prompt_count``
+# counts only parents on the shelf, so a value only archived parents (#443)
+# hold stays listed with 0, as a tag does; grouped exactly, so
# whitespace and case variants stay distinct; /library/options lists the same
# prompt-backed set with its own query) plus the values only image rows hold.
# A prompt-backed value is ordered by its oldest owning positive; an image-only
@@ -143,15 +158,17 @@ def _validate_tag_id(tag_id: object) -> None:
# files missing on disk. No FULL OUTER JOIN, so it runs on SQLite 3.31+.
_IMAGE_ONLY_FIRST_PID = 2**63 - 1
_CATEGORY_ROWS = (
- "WITH owners(value,pid) AS ("
- "SELECT p.category,p.id FROM prompts p WHERE p.category IS NOT NULL "
- "AND TRIM(p.category)!='' AND " + _ELIGIBLE + " "
- "UNION SELECT pp.category,pp.positive_prompt_id FROM prompt_pairs pp "
+ "WITH owners(value,pid,shelved) AS ("
+ "SELECT p.category,p.id," + SHELVED + " FROM prompts p "
+ "WHERE p.category IS NOT NULL AND TRIM(p.category)!='' AND " + _ELIGIBLE + " "
+ "UNION SELECT pp.category,pp.positive_prompt_id," + SHELVED_PAIR + " "
+ "FROM prompt_pairs pp "
"WHERE pp.archived=0 AND pp.category IS NOT NULL AND TRIM(pp.category)!=''),"
"image_values(value,image_count) AS ("
"SELECT category,COUNT(*) FROM image_categories GROUP BY category),"
"grouped(value,first_pid,prompt_count) AS ("
- "SELECT value,MIN(pid),COUNT(DISTINCT pid) FROM owners GROUP BY value "
+ "SELECT value,MIN(pid),COUNT(DISTINCT CASE WHEN shelved THEN pid END) "
+ "FROM owners GROUP BY value "
"UNION ALL SELECT value," + str(_IMAGE_ONLY_FIRST_PID) + ",0 FROM image_values "
"WHERE value NOT IN (SELECT value FROM owners)),"
"counted AS (SELECT g.value,g.first_pid,g.prompt_count,"
diff --git a/py/api/__init__.py b/py/api/__init__.py
index f15cce0d..0955bb0c 100644
--- a/py/api/__init__.py
+++ b/py/api/__init__.py
@@ -19,6 +19,7 @@ Split into domain-specific mixins for maintainability:
- SystemRoutesMixin: System health check, Overview and usage statistics
- LibraryLocationRoutesMixin: where the library lives, Move library, Keep it here
- MaintenanceRoutesMixin: reviewed maintenance plans (preview, apply)
+- LibraryDeleteRoutesMixin: the Library's Delete… (archive paired) and Restore
- WorkflowStorageRoutesMixin: store each workflow once, then Reclaim space
- DuplicateFilesMixin: duplicate image files as find and removal jobs
- LoraImportRoutesMixin: the reviewed, staged LoRA reference import job
@@ -43,6 +44,7 @@ from .prompts import PromptRoutesMixin
from .revalidation import revalidated_response
from .pairs import PairRoutesMixin
from .library import LibraryRoutesMixin
+from .library_delete import LibraryDeleteRoutesMixin
from .vocabulary import VocabularyRoutesMixin
from .annotations import AnnotationRoutesMixin
from .review import ReviewRoutesMixin
@@ -315,6 +317,7 @@ async def _gzip_middleware(request, handler):
class PromptManagerAPI(
PairRoutesMixin,
LibraryRoutesMixin,
+ LibraryDeleteRoutesMixin,
VocabularyRoutesMixin,
AnnotationRoutesMixin,
ReviewRoutesMixin,
@@ -700,6 +703,7 @@ class PromptManagerAPI(
self._register_pair_routes(routes)
self._register_library_routes(routes)
+ self._register_library_delete_routes(routes)
self._register_vocabulary_routes(routes)
self._register_annotation_routes(routes)
self._register_review_routes(routes)
diff --git a/py/api/jobs.py b/py/api/jobs.py
index 52f016be..7196d43b 100644
--- a/py/api/jobs.py
+++ b/py/api/jobs.py
@@ -123,6 +123,9 @@ MODEL_DOWNLOAD_JOB_KIND = "model_download"
DUPLICATES_JOB_KIND = "duplicates"
DUPLICATE_GROUP_NAMES = ("groups_found",)
DUPLICATE_CLEANUP_GROUP_NAMES = ("removed", "kept", "errors")
+# The workspace Library's Delete… (#443, py/api/library_delete.py): a
+# ``records`` hold around its safety backup and write.
+PROMPT_DELETE_JOB_KIND = "prompt_delete"
IMAGE_DELETE_JOB_KIND = "image_delete" # #480
IMAGE_DELETE_GROUP_NAMES = ("trashed", "removed", "not_changed", "errors")
# The System health check (py/api/system.py): a read-only ``read`` job with
diff --git a/py/api/library.py b/py/api/library.py
index be699e70..02ca0088 100644
--- a/py/api/library.py
+++ b/py/api/library.py
@@ -157,6 +157,13 @@ def _archive_scope(request: web.Request) -> str:
return value
+def _shelf(request: web.Request) -> str:
+ value = request.query.get("shelf", "active")
+ if value not in ("active", "archived"):
+ raise ValueError("shelf must be active or archived")
+ return value
+
+
def _missing() -> web.Response:
return web.json_response(
{"success": False, "error": "Positive prompt not found"}, status=404
@@ -332,6 +339,9 @@ class LibraryRoutesMixin:
"tags": sorted(set(request.query.getall("tag", []))),
"tag_mode": request.query.get("tag_mode", "all"),
"sort": request.query.get("sort", "newest"),
+ # #443: which shelf; part of the cursor scope, so a cursor never
+ # crosses shelves. Not the variant ``archive`` scope of /{id}.
+ "shelf": _shelf(request),
}
limit = _page_limit(request)
cursor = request.query.get("cursor")
diff --git a/py/api/library_delete.py b/py/api/library_delete.py
new file mode 100644
index 00000000..34038e04
--- /dev/null
+++ b/py/api/library_delete.py
@@ -0,0 +1,263 @@
+"""Delete or archive selected Library prompts, and restore archived ones (#443).
+
+The workspace Prompt library's Delete… (D152). The legacy ``POST
+/prompt_manager/bulk/delete`` and ``DELETE /prompt_manager/delete/{id}`` are
+separate and unchanged.
+
+- ``POST /prompt_manager/library/delete/preview {prompt_ids, expected_epoch}``
+ classifies the selection (reads only, under a lease): the ids to ``delete``
+ (in no saved pair), to ``archive`` (paired), the ``protected`` ones (tagged
+ ``__protected__``, kept), counts of ``already_archived`` and ``not_found``,
+ the ``image_links`` a delete removes, the safety ``backup`` policy and,
+ when a backup would be taken, the ``disk`` it needs.
+- ``POST /prompt_manager/library/delete {archive_ids, delete_ids,
+ expected_epoch, backup}`` executes that review under a ``records`` hold
+ (refused 409 ``busy`` beside a scan, restore, maintenance, move or
+ Optimize). When a delete remains and a backup was promised (the reviewed
+ ``backup`` was "on" or the policy is on now) it checks the disk (507
+ ``insufficient-space``) and takes a verified safety backup (500
+ ``backup-failed``); then one transaction deletes the reviewed deletes whose
+ fingerprint is unchanged since the backup and archives the paired ones.
+- ``POST /prompt_manager/library/restore {prompt_ids, expected_epoch}``
+ removes the archive mark (under a lease).
+
+A changed storage epoch answers 409 ``storage-changed``; nothing is written.
+"""
+
+import sqlite3
+
+from aiohttp import web
+
+try:
+ from ...database.library_commands import LibraryCommandError
+ from ...database.safety_backup import SafetyBackupFailed
+except ImportError:
+ from database.library_commands import LibraryCommandError
+ from database.safety_backup import SafetyBackupFailed
+
+from . import maintenance as _maintenance
+from .admin import _prune_safety_backups
+from .job_routes import busy_response
+from .jobs import PROMPT_DELETE_JOB_KIND, JobBusy
+from .prompts import parse_bulk_prompt_ids
+
+PREVIEW_URL = "/prompt_manager/library/delete/preview"
+DELETE_URL = "/prompt_manager/library/delete"
+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"})
+STORAGE_CHANGED = "Prompt storage was restored; refresh the library"
+
+
+def _failure(status, code, error, **extra):
+ return web.json_response(
+ {"success": False, "code": code, "error": error, **extra}, status=status
+ )
+
+
+def _invalid(message):
+ return _failure(400, "invalid-request", message)
+
+
+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."""
+ 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:
+ raise ValueError("Request must contain exactly " + ", ".join(sorted(keys)))
+ return data
+
+
+def _reviewed_ids(value, name):
+ """A reviewed id list: possibly empty, else as ``parse_bulk_prompt_ids``."""
+ if value == []:
+ return []
+ try:
+ return parse_bulk_prompt_ids(value)
+ except ValueError as exc:
+ raise ValueError(str(exc).replace("prompt_ids", name)) from None
+
+
+class LibraryDeleteRoutesMixin:
+ """Mixin providing the workspace Library's delete, archive and restore."""
+
+ def _register_library_delete_routes(self, routes):
+ routes.post(PREVIEW_URL)(self.preview_library_delete)
+ routes.post(DELETE_URL)(self.delete_library_prompts)
+ routes.post(RESTORE_URL)(self.restore_library_prompts)
+
+ async def preview_library_delete(self, request):
+ try:
+ data = await _body(request, _SELECTION_KEYS)
+ ids = parse_bulk_prompt_ids(data["prompt_ids"])
+ except ValueError as exc:
+ return _invalid(str(exc))
+ db = self.db
+ policy = self._safety_backup_policy()
+ try:
+ async with self._jobs.lease("Library delete review"):
+ review = await self._run_in_executor(
+ db.classify_library_delete,
+ ids,
+ expected_epoch=data["expected_epoch"],
+ )
+ disk = None
+ if review["delete"] and policy == "on":
+ public, short, _size = await self._run_in_executor(
+ _maintenance.disk_plan,
+ db.model.db_path,
+ backup=True,
+ vacuum=False,
+ )
+ disk = {
+ **public,
+ "message": (
+ None
+ if short is None
+ else _maintenance.short_space_message(short)
+ ),
+ }
+ except JobBusy as busy:
+ return busy_response(busy)
+ except LibraryCommandError as exc:
+ return _storage_changed(exc)
+ except ValueError as exc:
+ return _invalid(str(exc))
+ except (OSError, sqlite3.Error):
+ self.logger.exception("Library delete review failed")
+ return _failure(500, "review-failed", "The delete could not be reviewed")
+ return web.json_response(
+ {
+ "success": True,
+ "library_epoch": review["library_epoch"],
+ "delete": review["delete"],
+ "archive": review["archive"],
+ "protected": review["protected"],
+ "already_archived": len(review["already_archived"]),
+ "not_found": len(review["not_found"]),
+ "image_links": review["image_links"],
+ "backup": policy,
+ "disk": disk,
+ }
+ )
+
+ async def delete_library_prompts(self, request):
+ try:
+ data = await _body(request, _DELETE_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:
+ raise ValueError("Nothing to delete or archive")
+ if len(archive_ids) + len(delete_ids) > 1000:
+ raise ValueError("At most 1000 prompts can be changed at once")
+ if set(archive_ids) & set(delete_ids):
+ 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")
+ except ValueError as exc:
+ return _invalid(str(exc))
+ busy = self._jobs.blocker(PROMPT_DELETE_JOB_KIND, admission="records")
+ if busy is not None:
+ return busy_response(JobBusy(busy))
+ try:
+ async with self._jobs.hold(
+ PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records"
+ ) as job:
+ response = await self._held_library_delete(
+ self.db, data, archive_ids, delete_ids
+ )
+ if response.status >= 400:
+ job.finish("failed")
+ return response
+ except JobBusy as busy:
+ return busy_response(busy)
+
+ async def _held_library_delete(self, db, data, archive_ids, delete_ids):
+ epoch = data["expected_epoch"]
+ promised = data["backup"] == "on" or self._safety_backup_policy() == "on"
+ backup = None
+ try:
+ candidates = None
+ if delete_ids:
+ review = await self._run_in_executor(
+ db.classify_library_delete, delete_ids, expected_epoch=epoch
+ )
+ candidates = review["fingerprints"]
+ if candidates and promised:
+ _disk, short, _size = await self._run_in_executor(
+ _maintenance.disk_plan,
+ db.model.db_path,
+ backup=True,
+ vacuum=False,
+ )
+ if short is not None:
+ return _failure(
+ 507,
+ "insufficient-space",
+ _maintenance.short_space_message(short),
+ )
+ backup = await self._run_in_executor(
+ _maintenance.take_safety_backup, db.model
+ )
+ result = await self._run_in_executor(
+ db.archive_or_delete_library_prompts,
+ archive_ids,
+ delete_ids,
+ expected_epoch=epoch,
+ candidates=candidates if candidates is not None else {},
+ )
+ except LibraryCommandError as exc:
+ return _storage_changed(exc)
+ except SafetyBackupFailed as error:
+ self.logger.error("Safety backup before a Library delete failed")
+ return _failure(
+ 500, "backup-failed", _maintenance.backup_failed_message(error)
+ )
+ except ValueError as exc:
+ return _invalid(str(exc))
+ except (OSError, sqlite3.Error):
+ self.logger.exception("Library delete failed")
+ return _failure(500, "delete-failed", "The prompts could not be changed")
+ finally:
+ if backup is not None:
+ await self._prune_after_delete(db)
+ return web.json_response({"success": True, **result, "backup": backup})
+
+ async def _prune_after_delete(self, db):
+ try:
+ await self._run_in_executor(_prune_safety_backups, db.model.db_path)
+ except Exception: # pruning old copies never fails the delete
+ self.logger.exception("Could not prune old safety backups")
+
+ async def restore_library_prompts(self, request):
+ try:
+ data = await _body(request, _SELECTION_KEYS)
+ ids = parse_bulk_prompt_ids(data["prompt_ids"])
+ except ValueError as exc:
+ return _invalid(str(exc))
+ try:
+ async with self._jobs.lease("Library restore"):
+ result = await self._run_in_executor(
+ self.db.restore_library_prompts,
+ ids,
+ expected_epoch=data["expected_epoch"],
+ )
+ except JobBusy as busy:
+ return busy_response(busy)
+ except LibraryCommandError as exc:
+ return _storage_changed(exc)
+ except ValueError as exc:
+ return _invalid(str(exc))
+ except (OSError, sqlite3.Error):
+ self.logger.exception("Library restore failed")
+ return _failure(500, "restore-failed", "The prompts could not be restored")
+ return web.json_response({"success": True, **result})
diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs
index be8bf28e..d7387aaf 100644
--- a/tests/browser/library-concept.spec.mjs
+++ b/tests/browser/library-concept.spec.mjs
@@ -44,7 +44,12 @@ export function mapLibraryConcept(reference) {
// "4 runs" 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}));
- 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..6f373449
--- /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 in a saved pair: 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 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.' },
+ // 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..59b72af8
--- /dev/null
+++ b/tests/browser/workspace-library-delete.spec.mjs
@@ -0,0 +1,215 @@
+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 in a saved pair: 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');
+ // The archive reason is the saved pair, for one or many prompts (live note on #470): never negative variants.
+ await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText('2 prompts in a saved pair: hidden, restorable');
+ 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');
+ // The notice says why (the saved pair) and counts the prompt's negative variants (live note on #470).
+ expect(cat.variant_count).toBeGreaterThan(1);
+ await expect(notice.locator('p').first()).toHaveText(`This prompt is archived because it is in a saved pair. It and its ${cat.variant_count} 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);
+});
+
+// #443: a starved renderer (headless WebKit under CPU load, a background tab) can hold animation frames for
+// seconds; a loaded page of prompts is shown without waiting for one, on first load and after a delete.
+async function holdAnimationFrames(page) {
+ await page.addInitScript(()=>{
+ const request=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window);
+ const held=new Map(),real=new Map();let holding=true,next=0;
+ window.requestAnimationFrame=callback=>{const id=--next;if(holding)held.set(id,callback);else real.set(id,request(callback));return id;};
+ window.cancelAnimationFrame=id=>{held.delete(id);if(real.has(id))cancel(real.get(id));real.delete(id);};
+ window.__releaseAnimationFrames=()=>{holding=false;for(const [id,callback] of held)real.set(id,request(callback));held.clear();};
+ });
+}
+test('a held animation frame does not hide the loaded prompts, on first load or after a delete',async({page,request})=>{
+ const before=await items(request);const dog=byText(before,'Dog landscape');
+ await holdAnimationFrames(page);
+ await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
+ 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(/^Deleted 1 prompt\./);
+ await expect(cards(page)).toHaveCount(before.length-1);await expect(card(page,dog.id)).toHaveCount(0);
+ await page.evaluate(()=>window.__releaseAnimationFrames());
+ await expect(cards(page)).toHaveCount(before.length-1);
+});
+
+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();
+});
+
+test('a failed review keeps the selection, and a selection nothing can change opens the close-only review',async({page,request})=>{
+ const before=await items(request);const guarded=byText(before,'Library extra 0000');
+ await expect(await request.post('/prompt_manager/bulk/tags',{data:{prompt_ids:[guarded.id],tags:['__protected__']}})).toBeOK();
+ await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
+ await box(page,guarded.id).check();
+ await page.route(`**${PREVIEW}`,item=>item.fulfill({status:500,contentType:'application/json',body:JSON.stringify({success:false,code:'review-failed',error:'x'})}),{times:1});
+ await bar(page).getByRole('button',{name:'Delete…',exact:true}).click();
+ await expect(toast(page)).toHaveText('The selected prompts could not be reviewed. Try again.');
+ await expect(box(page,guarded.id)).toBeChecked();
+ await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
+ await openReview(page);
+ await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
+ await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.');
+ await expect(review(page).locator('.dialog-actions > button')).toHaveText(['Close']);
+ await review(page).getByRole('button',{name:'Close',exact:true}).click();
+ await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
+ expect(byText(await items(request),'Library extra 0000')).toBeDefined();
+});
diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs
index 42def0d0..2a17b3e3 100644
--- a/tests/browser/workspace-library-editor.spec.mjs
+++ b/tests/browser/workspace-library-editor.spec.mjs
@@ -493,13 +493,15 @@ 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');
- expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
+ // D152 (#443): Show archived prompts (N) comes first, a plain toggle.
+ 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/);
// 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(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
- await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
- for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
- await actions.nth(1).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
+ await expect(actions.nth(1)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(1)).not.toHaveClass(/ca-primary/);
+ await expect(actions.nth(2)).toHaveClass(/\bca-primary\b/);
+ for(const index of [0,1,2])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
+ await actions.nth(2).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await field(dialog,'Title').fill('Harbor at dawn');await field(dialog,'Positive prompt').fill('A quiet harbor at dawn');await field(dialog,'Negative prompt (optional)').fill('fog, noise');
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
const created=(await(await request.get('/prompt_manager/library?text=quiet%20harbor')).json()).items[0];
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 4d3dc248..1bed157e 100644
--- a/tests/fixtures/legacy_contract_snapshot.json
+++ b/tests/fixtures/legacy_contract_snapshot.json
@@ -132,6 +132,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..b80eb68e
--- /dev/null
+++ b/tests/js/workspace-library-delete.test.js
@@ -0,0 +1,150 @@
+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 in a saved pair: 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 Archive row names the saved pair, never negative variants, for one or many prompts', async () => {
+ const {reviewOf, reviewGrid} = await source;
+ // A saved-pair prompt with 0 negative variants (live prompt 7901) is archive-only for its pair, not for variants.
+ const one = reviewGrid(reviewOf(preview({archive: [2]}))).find(([term]) => term === 'Archive');
+ assert.deepEqual(one, ['Archive', '1 prompt in a saved pair: hidden, restorable']);
+ const many = reviewGrid(reviewOf(preview({archive: [2, 3, 4]}))).find(([term]) => term === 'Archive');
+ assert.deepEqual(many, ['Archive', '3 prompts in a saved pair: hidden, restorable']);
+ for (const [, value] of [one, many]) assert.doesNotMatch(value, /variant/);
+});
+
+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]})));
+ // Live test (#470): the archive reason is the saved pair, which a prompt with 0 negative variants has too.
+ assert.match(archive, /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the pair and its runs stay intact\./);
+ assert.doesNotMatch(archive, /variant/);
+ assert.match(archive, /Archived prompts cannot be deleted\. To bring them back, press Show archived prompts/);
+ 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});
+});
+
+test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => {
+ const {archivedNoticeText} = await source;
+ assert.equal(archivedNoticeText(0), 'This prompt is archived because it is in a saved pair. It is hidden from the Prompt ' +
+ 'library; nothing was removed.');
+ assert.equal(archivedNoticeText(1), 'This prompt is archived because it is in a saved pair. It and its 1 negative variant ' +
+ 'are hidden from the Prompt library; nothing was removed.');
+ assert.equal(archivedNoticeText(3), 'This prompt is archived because it is in a saved pair. It and its 3 negative variants ' +
+ 'are hidden from the Prompt library; nothing was removed.');
+ // An unknown count (an older server) never claims variants.
+ assert.equal(archivedNoticeText(undefined), archivedNoticeText(0));
+});
diff --git a/tests/test_bulk_library_delete.py b/tests/test_bulk_library_delete.py
new file mode 100644
index 00000000..35b9abe6
--- /dev/null
+++ b/tests/test_bulk_library_delete.py
@@ -0,0 +1,525 @@
+"""#443 (D152): delete selected Library prompts; paired ones are archived.
+
+The DB commands, and what every reader does with an archived parent.
+"""
+
+import re
+import sqlite3
+import tempfile
+import unittest
+from pathlib import Path
+
+from database.execution_history_schema import rotate_history_epoch
+from database.library_commands import LibraryCommandError
+from database.library_folders import folder_prefixes
+from database.operations import PromptDatabase
+from database.prompt_archive import TABLE as ARCHIVE
+from utils.hashing import generate_prompt_hash
+
+
+def save(db, text, **metadata):
+ return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
+
+
+def rows(db, sql, params=()):
+ with db.model.get_connection() as conn:
+ return [tuple(row) for row in conn.execute(sql, params).fetchall()]
+
+
+def archived_ids(db):
+ return sorted(row[0] for row in rows(db, f"SELECT prompt_id FROM {ARCHIVE}"))
+
+
+def prompt_ids(db):
+ return sorted(row[0] for row in rows(db, "SELECT id FROM prompts"))
+
+
+_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}")
+
+
+def _stable(value):
+ return "