Compare commits

..
Author SHA1 Message Date
Vito Sansevero 7a9afea441 test(collections): wait for the restore job, not 5 s; no hard-coded saved-view id (#520)
The restore test waited 5 s for a whole restore. Under load (webkit,
merge queue of #514) the restore outlasted it and kept running into the
next tests on the worker, replacing their database. It now waits for
the restore job's event stream to end, as the backups spec does. The
route-change test used saved view 5, which a worker can have made.
2026-10-29 19:23:59 -07:00
17 changed files with 105 additions and 1174 deletions
+4 -64
View File
@@ -2783,15 +2783,14 @@ rulings of 2026-10-28):
504: "The server did not confirm the change. The Library was reloaded: check the shelf and
Show archived prompts."; other refusals: "The selected prompts could not be changed. Try
again.", the selection kept.
- **Show archived prompts (N)** (heading actions, before Export, a plain button; N the
- **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. On the archived
shelf the button reads the concept's own way back, "← Prompt library" (app.js 594; #482), with
no `aria-pressed` (its label names where it goes). The shelf stays with the shelf visit, never
in a saved view; Browse and a saved view open the active shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST
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
@@ -2819,65 +2818,6 @@ failed backup, busy, unconfirmed, restore fence); `tests/browser/library-delete-
`tests/test_prompt_archive_schema.py`, `tests/test_bulk_library_delete.py`,
`tests/test_library_delete_routes.py`. A Python change: ComfyUI needs a restart.
### Deleting a prompt in a saved pair permanently (#482)
**D160** (beyond legacy, maintainer ruling 2026-10-29; follow-up to D152): when the Delete… selection
holds prompts in a saved pair that no other positive uses as its negative ("deletable"), the review
asks, in a D90 radio row "Prompts in a saved pair" after the grid: **Archive (hidden, restorable)**
(checked on every open, never remembered, so the review is D152's) or **Delete permanently, with
their saved pairs**. Choosing it rewrites the review in place (`openReviewDialog` `setRows`,
`setPrimary`):
- Delete permanently "D prompts: X not in a saved pair, Y in a saved pair" (or "Y prompts in a saved
pair"), Saved pairs removed "S saved pairs (V with a negative prompt, A archived)", Negatives kept
"K negative prompts are kept", Archive "R prompts used as another prompt's negative: hidden,
restorable", then D152's protected, already archived, no longer exist, Image links removed (images
of every deleted prompt) and Backup rows.
- Sentences: the pairs go with their titles, categories, ratings, notes, tags, run counts and image
links; their negative prompts stay; recorded runs stay in History and Activity by their text; D152's
irreversibility (per backup policy) and re-creation sentences, plus "A pair node run with the same
text saves a new pair."; and, when R > 0, why those are archived.
- Primary (`danger`): "Delete D prompts" / "Delete D and archive R prompts"; a short disk keeps it
unavailable (`aria-disabled`) with the disk sentence, while Archive still starts.
The preview adds `deletable` (a subset of `archive`, which keeps every archive id for D152 clients),
`review_digests` (sha256 of each deletable prompt's row and its pairs: negative, archived flag,
metadata, usage, tags, image-pair links), `paired_counts` and `paired_image_links`, and plans the
disk whenever anything could be deleted. The write takes optional `delete_paired_ids` with their
`paired_digests` (both or neither; ids must be reviewed archive ids). In one transaction, after the
archives, each chosen prompt whose digest is unchanged loses its image-pair links, then its own saved
pairs (pair tags cascade; review decisions keep their rows with `pair_id` cleared), then itself exactly
as D152 deletes. Any drift (pair added or edited, new link, pairs gone, now another prompt's negative)
archives it and counts it `changed`. A paired delete is backed up and disk-checked as a delete. The
toast adds "Removed S saved pairs."; "N changed since the review and was not deleted." replaces "…was
kept.". Negatives are never deleted: one no remaining pair uses is listed in the Library afterwards (as
after the legacy pair delete). Legacy still refuses paired prompts (409).
Tests: `tests/test_bulk_library_delete.py` `TestPairedDelete` (classes and counts, the 7896 empty
negative, the row diff against the legacy pair deletes then prompt delete, archive-only, a positive
with its own negative, a self-pair, each drift, validation); `tests/test_library_delete_routes.py`
`TestPairedDeleteRoutes` (preview, paired-only backup, a D152 body, validation, short disk); units
`tests/js/workspace-library-delete.test.js`, `tests/js/workspace-toolbar.test.js` (`setRows`,
`setPrimary`); browser `tests/browser/workspace-library-delete.spec.mjs` (the choice, its grid and
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
change: ComfyUI needs a restart.
### Delete on a prompt's own page (#481)
**D161** (beyond the concept, coordinator ruling (A) 2026-10-29, open to the maintainer at the live test): the recipe
editor (Edit metadata, the concept's app.js 976 row "Create text revision", "Archive…", Cancel, Save) reads "Create
text revision", **"Archive variant…"** (the selected negative variant's archive, #235, which used the concept's
"Archive…" label) and **"Delete prompt…"** (danger), then Cancel and Save. "Delete prompt…" closes the editor and runs
D152/D160's Delete… review for that one prompt (`reviewPromptDelete`: the same preview, review with the saved-pair
choice, backup and write, fenced by the page's `library_epoch`). Cancel changes nothing and returns focus to Edit
metadata. A delete toasts and opens the Prompt library; an archive (or a drift) toasts and reads the page again, so an
archived prompt shows its Archived notice. The button is disabled while the editor saves or after a restore.
The concept comparison applies D161 in `mapConcept` (`tests/browser/library-editor-canonical-comparison.mjs`): the
concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete prompt…" with the same geometry and tokens.
Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the
hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no 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
+7 -155
View File
@@ -13,9 +13,6 @@ and delete lists, and deletes only reviewed deletes the route backed up
(``candidates``) whose fingerprint is unchanged since that backup.
"""
import hashlib
import json
import re
import sqlite3
from typing import Iterable
@@ -42,15 +39,8 @@ _PAIRED = (
"EXISTS(SELECT 1 FROM prompt_pairs pp "
"WHERE pp.positive_prompt_id=p.id OR pp.negative_prompt_id=p.id)"
)
# #482: used as the negative of another positive's pair (archived or not):
# never deleted, so that positive's variant stays (archive only).
_NEGATIVE_OF_OTHER = (
"EXISTS(SELECT 1 FROM prompt_pairs op "
"WHERE op.negative_prompt_id=p.id AND op.positive_prompt_id!=p.id)"
)
# Classes in the order a prompt is tested; the first that holds wins.
CLASSES = ("not_found", "protected", "already_archived", "archive", "delete")
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
def _batches(ids: list) -> Iterable[list]:
@@ -88,31 +78,24 @@ class LibraryArchiveOperations:
def _classify_library_delete(
self, conn: sqlite3.Connection, prompt_ids: list[int]
) -> dict:
found, deletable = {}, set()
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"{_NEGATIVE_OF_OTHER} "
f"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED} " # nosec B608
f"FROM prompts p WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
chunk,
):
protected, archived, paired, negative_of_other = row[1:5]
protected, archived, paired = row[1], row[2], row[3]
if protected:
found[row[0]] = "protected"
elif archived:
found[row[0]] = "already_archived"
elif paired:
found[row[0]] = "archive"
if not negative_of_other:
deletable.add(row[0])
else:
found[row[0]] = "delete"
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)
# #482: the archive ids Delete permanently may delete with their pairs.
classes["deletable"] = [pid for pid in classes["archive"] if pid in deletable]
return classes
@staticmethod
@@ -154,82 +137,6 @@ class LibraryArchiveOperations:
]
return prints
@staticmethod
def _distinct_images(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
"""Image rows of ``prompt_ids`` (each image once; #482 deletes them)."""
return LibraryArchiveOperations._image_links(conn, prompt_ids)
@staticmethod
def _pair_prints(conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
"""#482: each prompt's own saved pairs, as the review saw them.
Per pair (by id): its negative, archived flag, metadata, usage, ordered
tag ids and its image-pair links (count and newest id).
"""
prints = {pid: [] for pid in prompt_ids}
for chunk in _batches(prompt_ids):
marks = ",".join("?" for _ in chunk)
for row in conn.execute(
"SELECT pp.positive_prompt_id,pp.id,pp.negative_prompt_id," # nosec
"pp.archived,pp.title,pp.category,pp.rating,pp.notes,pp.run_count,"
"pp.last_used_at,pp.updated_at,"
"(SELECT group_concat(tag_id) FROM (SELECT tag_id FROM "
"prompt_pair_tags WHERE pair_id=pp.id ORDER BY tag_id)),"
"(SELECT COUNT(*) FROM image_prompt_pairs WHERE pair_id=pp.id),"
"(SELECT MAX(id) FROM image_prompt_pairs WHERE pair_id=pp.id) "
f"FROM prompt_pairs pp WHERE pp.positive_prompt_id IN ({marks}) "
"ORDER BY pp.id",
chunk,
):
prints[row[0]].append(
[
v if isinstance(v, (int, str, type(None))) else repr(v)
for v in tuple(row)[1:]
]
)
return prints
@classmethod
def _review_digests(cls, conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
"""#482: one digest per deletable prompt of its row and its pairs."""
prompts = cls._fingerprints(conn, prompt_ids)
pairs = cls._pair_prints(conn, prompt_ids)
return {
pid: hashlib.sha256(
json.dumps(
[prompts.get(pid), pairs[pid]], separators=(",", ":")
).encode("utf-8")
).hexdigest()
for pid in prompt_ids
}
@staticmethod
def _paired_counts(
conn: sqlite3.Connection, prompt_ids: list[int], selected: list[int]
) -> dict:
"""#482: what Delete permanently would remove with ``prompt_ids``."""
pairs = archived = variants = 0
negatives = set()
for chunk in _batches(prompt_ids):
marks = ",".join("?" for _ in chunk)
for archived_flag, negative, positive in conn.execute(
"SELECT archived,negative_prompt_id,positive_prompt_id " # nosec
f"FROM prompt_pairs WHERE positive_prompt_id IN ({marks})",
chunk,
):
pairs += 1
archived += 1 if archived_flag else 0
if negative is not None:
variants += 1
if negative != positive:
negatives.add(negative)
return {
"pairs": pairs,
"archived_pairs": archived,
"variants": variants,
"kept_negatives": len(negatives - set(selected)),
}
@staticmethod
def _checked_ids(prompt_ids: list[int], expected_epoch: str) -> list[int]:
_validate_epoch(expected_epoch)
@@ -249,16 +156,11 @@ class LibraryArchiveOperations:
with self.library_snapshot() as (conn, _epoch, _ceiling):
epoch = self._library_epoch(conn, expected_epoch)
classes = self._classify_library_delete(conn, prompt_ids)
deletable = classes["deletable"]
return {
"library_epoch": epoch,
**classes,
"image_links": self._image_links(conn, classes["delete"]),
"fingerprints": self._fingerprints(conn, classes["delete"]),
# #482: Delete permanently, with the deletable prompts' pairs.
"review_digests": self._review_digests(conn, deletable),
"paired_counts": self._paired_counts(conn, deletable, prompt_ids),
"paired_image_links": self._distinct_images(conn, deletable),
}
@staticmethod
@@ -289,7 +191,6 @@ class LibraryArchiveOperations:
*,
expected_epoch: str,
candidates: dict | None,
delete_paired: dict | None = None,
) -> dict:
"""Archive and delete what the user reviewed, all or nothing.
@@ -300,11 +201,6 @@ class LibraryArchiveOperations:
still classifies ``delete`` and its fingerprint is unchanged; one that
is now paired, and a reviewed archive now unpaired, are archived (the
restorable direction); anything else is kept and counted.
``delete_paired`` (#482) maps reviewed archive ids the user chose to
Delete permanently to their review digest. Each is deleted with its
own saved pairs only while it is still deletable and its digest is
unchanged; otherwise it is archived and counted ``changed``.
"""
_validate_epoch(expected_epoch)
for ids in (archive_ids, delete_ids):
@@ -313,16 +209,6 @@ class LibraryArchiveOperations:
):
raise ValueError("prompt ids must be lists of positive integers")
reviewed_delete = set(delete_ids)
delete_paired = {} if delete_paired is None else delete_paired
if not isinstance(delete_paired, dict) or any(
type(pid) is not int
or not isinstance(digest, str)
or not _DIGEST.match(digest)
for pid, digest in delete_paired.items()
):
raise ValueError("delete_paired must map prompt ids to review digests")
if not set(delete_paired) <= set(archive_ids):
raise ValueError("A permanent delete must be a reviewed archive id")
if not archive_ids and not delete_ids:
raise ValueError("Nothing to delete or archive")
if reviewed_delete & set(archive_ids):
@@ -332,31 +218,16 @@ class LibraryArchiveOperations:
epoch = self._library_epoch(conn, expected_epoch)
classes = self._classify_library_delete(conn, prompt_ids)
now_prints = self._fingerprints(conn, classes["delete"])
deletes, archives, changed = [], [], 0
paired_now = set(classes["deletable"])
chosen = [pid for pid in classes["archive"] if pid in delete_paired]
digests = self._review_digests(
conn, [pid for pid in chosen if pid in paired_now]
)
paired_deletes = []
for pid in classes["archive"]:
if pid not in delete_paired:
archives.append(pid)
elif digests.get(pid) == delete_paired[pid]:
paired_deletes.append(pid)
else:
archives.append(pid) # changed since the review: restorable
changed += 1
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
changed += 1 if pid in delete_paired else 0
elif candidates is None or candidates.get(pid) == now_prints[pid]:
deletes.append(pid)
else:
changed += 1
changed += len(classes["protected"])
image_links = self._image_links(conn, [*deletes, *paired_deletes])
image_links = self._image_links(conn, deletes)
now = utc_now_iso()
for pid in archives:
self._repair_hash(conn, pid)
@@ -369,25 +240,7 @@ class LibraryArchiveOperations:
"UPDATE prompts SET updated_at=? WHERE id=?",
[(now, pid) for pid in archives],
)
# #482: the image-pair links (RESTRICT) go first, then the pairs
# (pair tags CASCADE, review decisions SET NULL), then the prompts.
pairs_removed = 0
for chunk in _batches(paired_deletes):
marks = ",".join("?" for _ in chunk)
pairs_of = (
"SELECT id FROM prompt_pairs " # nosec B608
f"WHERE positive_prompt_id IN ({marks})"
)
conn.execute(
f"DELETE FROM image_prompt_pairs WHERE pair_id IN ({pairs_of})",
chunk,
)
pairs_removed += conn.execute(
"DELETE FROM prompt_pairs " # nosec B608
f"WHERE positive_prompt_id IN ({marks})",
chunk,
).rowcount
deleted = delete_prompt_rows(conn, [*deletes, *paired_deletes])
deleted = delete_prompt_rows(conn, deletes)
return {
"library_epoch": epoch,
"deleted": deleted,
@@ -396,7 +249,6 @@ class LibraryArchiveOperations:
"not_found": len(classes["not_found"]),
"changed": changed,
"image_links": image_links,
"pairs_removed": pairs_removed,
}
def restore_library_prompts(
+10 -44
View File
@@ -47,8 +47,6 @@ RESTORE_URL = "/prompt_manager/library/restore"
DELETE_TITLE = "Delete prompts (Library)"
_SELECTION_KEYS = frozenset({"prompt_ids", "expected_epoch"})
_DELETE_KEYS = frozenset({"archive_ids", "delete_ids", "expected_epoch", "backup"})
# #482: Delete permanently, with its digests; both or neither (a #443 body).
_PAIRED_KEYS = frozenset({"delete_paired_ids", "paired_digests"})
STORAGE_CHANGED = "Prompt storage was restored; refresh the library"
@@ -66,39 +64,19 @@ def _storage_changed(exc):
return _failure(409, exc.code, STORAGE_CHANGED, library_epoch=exc.epoch)
async def _body(request, keys, optional=frozenset()):
"""The JSON object with exactly ``keys`` (plus all or none of ``optional``)."""
async def _body(request, keys):
"""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")
extra = set(data) - keys
if not keys <= set(data) or (extra and extra != optional):
if set(data) != keys:
raise ValueError("Request must contain exactly " + ", ".join(sorted(keys)))
return data
def _paired_delete(data, archive_ids):
"""#482: the reviewed archive ids to delete with their pairs -> digest."""
if "delete_paired_ids" not in data:
return {}
ids = _reviewed_ids(data["delete_paired_ids"], "delete_paired_ids")
digests = data["paired_digests"]
if not isinstance(digests, dict):
raise ValueError("paired_digests must map prompt ids to digests")
if not set(ids) <= set(archive_ids):
raise ValueError("delete_paired_ids must be reviewed archive ids")
chosen = {}
for pid in ids:
digest = digests.get(str(pid))
if not isinstance(digest, str) or len(digest) != 64:
raise ValueError("Every delete_paired_ids entry needs its review digest")
chosen[pid] = digest
return chosen
def _reviewed_ids(value, name):
"""A reviewed id list: possibly empty, else as ``parse_bulk_prompt_ids``."""
if value == []:
@@ -133,7 +111,7 @@ class LibraryDeleteRoutesMixin:
expected_epoch=data["expected_epoch"],
)
disk = None
if (review["delete"] or review["deletable"]) and policy == "on":
if review["delete"] and policy == "on":
public, short, _size = await self._run_in_executor(
_maintenance.disk_plan,
db.model.db_path,
@@ -167,13 +145,6 @@ class LibraryDeleteRoutesMixin:
"already_archived": len(review["already_archived"]),
"not_found": len(review["not_found"]),
"image_links": review["image_links"],
# #482: the archive ids Delete permanently may delete with pairs.
"deletable": review["deletable"],
"review_digests": {
str(pid): digest for pid, digest in review["review_digests"].items()
},
"paired_counts": review["paired_counts"],
"paired_image_links": review["paired_image_links"],
"backup": policy,
"disk": disk,
}
@@ -181,7 +152,7 @@ class LibraryDeleteRoutesMixin:
async def delete_library_prompts(self, request):
try:
data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS)
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:
@@ -192,7 +163,6 @@ class LibraryDeleteRoutesMixin:
raise ValueError("A prompt cannot be both archived and deleted")
if data["backup"] not in ("on", "off"):
raise ValueError("backup must be on or off")
paired = _paired_delete(data, archive_ids)
except ValueError as exc:
return _invalid(str(exc))
busy = self._jobs.blocker(PROMPT_DELETE_JOB_KIND, admission="records")
@@ -203,7 +173,7 @@ class LibraryDeleteRoutesMixin:
PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records"
) as job:
response = await self._held_library_delete(
self.db, data, archive_ids, delete_ids, paired
self.db, data, archive_ids, delete_ids
)
if response.status >= 400:
job.finish("failed")
@@ -211,21 +181,18 @@ class LibraryDeleteRoutesMixin:
except JobBusy as busy:
return busy_response(busy)
async def _held_library_delete(self, db, data, archive_ids, delete_ids, paired):
async def _held_library_delete(self, db, data, archive_ids, delete_ids):
epoch = data["expected_epoch"]
promised = data["backup"] == "on" or self._safety_backup_policy() == "on"
backup = None
try:
candidates = None
if delete_ids or paired:
if delete_ids:
review = await self._run_in_executor(
db.classify_library_delete,
[*delete_ids, *paired],
expected_epoch=epoch,
db.classify_library_delete, delete_ids, expected_epoch=epoch
)
candidates = review["fingerprints"]
# #482: a permanent delete of a prompt in a saved pair is backed up too.
if (candidates or review["deletable"]) and promised:
if candidates and promised:
_disk, short, _size = await self._run_in_executor(
_maintenance.disk_plan,
db.model.db_path,
@@ -247,7 +214,6 @@ class LibraryDeleteRoutesMixin:
delete_ids,
expected_epoch=epoch,
candidates=candidates if candidates is not None else {},
delete_paired=paired,
)
except LibraryCommandError as exc:
return _storage_changed(exc)
@@ -88,8 +88,7 @@ function mapDeleteConcept({ danger, review }) {
return {
...review,
title: 'Delete or archive selected prompts',
// #482: the paired prompt is deletable, so the review asks Archive or Delete permanently (a D90 radio row).
tree: ['dl.meta-grid', 'div.field', 'p.small', 'p.small', 'div.dialog-actions'],
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)'],
@@ -26,15 +26,9 @@ export function inventoryOf(dialog) {
};
}
// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the
// concept's "Rating" select is not drawn. D161 (#481): the concept's one danger "Archive…" becomes "Archive variant…" and
// is followed by the prompt's own "Delete prompt…", drawn as the same danger button.
function mapButton(item) {
if(item.text==='Save in prototype') return [{...item,text:'Save'}];
if(item.text==='Archive…') return [{...item,text:'Archive variant…'},{...item,text:'Delete prompt…'}];
return [item];
}
// concept's "Rating" select is not drawn.
export function mapConcept(reference) {
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.flatMap(mapButton)}};
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
}
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
+1 -1
View File
@@ -24,7 +24,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
await expect(editor(page).getByRole('button',{name:'Save',exact:true})).toBeEnabled();
await editor(page).getByRole('button',{name:'Archive variant…',exact:true}).click();
await editor(page).getByRole('button',{name:'Archive…',exact:true}).click();
await expect(editor(page).getByRole('button',{name:'Archive',exact:true})).toBeEnabled();await shot('editor-archive');
let release;const held=new Promise(resolve=>{release=resolve;});
+3 -192
View File
@@ -13,8 +13,6 @@ const bar=page=>page.locator('#library-bulkbar > .bulkbar');
const toast=page=>page.locator('.toast');
const review=page=>page.getByRole('dialog');
const showArchived=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:/^Show archived prompts/});
// #482: on the archived shelf the same button reads the concept's way back (app.js 594 "← Prompt library").
const backToLibrary=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'← Prompt library',exact:true});
async function items(request,shelf='active') {
const query=shelf==='archived'?'&shelf=archived':'';
@@ -85,11 +83,9 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click();
await expect(toast(page)).toHaveText('Archived 2 prompts.');
await expect(cards(page)).toHaveCount(before.length-2);
// #482: a plain button that names where it goes (no pressed state that its changing label would contradict).
await expect(showArchived(page)).not.toHaveAttribute('aria-pressed');
await expect(showArchived(page)).toHaveAttribute('aria-pressed','false');
await showArchived(page).click();
await expect(backToLibrary(page)).toBeVisible();await expect(backToLibrary(page)).not.toHaveAttribute('aria-pressed');
await expect(showArchived(page)).toHaveCount(0);
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);
@@ -98,8 +94,7 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
await expect(cards(page)).toHaveCount(1);
await backToLibrary(page).click();
await expect(showArchived(page)).toHaveText('Show archived prompts (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]);
@@ -109,190 +104,6 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
await expect(page.locator('.library-status')).toHaveText('No archived prompts.');
});
// #482 (D154): a prompt in a saved pair can be deleted permanently, with its own saved pairs; Archive stays the default.
const choice=page=>review(page).getByRole('radiogroup',{name:'Prompts in a saved pair'});
const row=(page,term)=>review(page).locator('dl.meta-grid > div').filter({has:page.locator('dt',{hasText:new RegExp(`^${term}$`)})}).locator('dd');
test('Delete permanently removes a paired prompt with its own saved pair; its negative stays (#482)',async({page,request})=>{
const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait');
const writes=recordPosts(page,DELETE);
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
await box(page,dual.id).check();await openReview(page);
// Archive is checked: the review is #443's.
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
// The D90 radio row (the auto-tag "How to apply"): each radio keeps its own size beside its label.
expect(await choice(page).evaluate(group=>{const label=group.querySelector('label'),radio=label.querySelector('input');
return {group:group.className,display:getComputedStyle(label).display,gap:getComputedStyle(label).columnGap,
margin:getComputedStyle(radio).marginLeft,full:radio.getBoundingClientRect().width>=label.getBoundingClientRect().width/2};}))
.toEqual({group:'field',display:'flex',gap:'8px',margin:'0px',full:false});
await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible();
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
await expect(row(page,'Delete permanently')).toHaveText('1 prompt in a saved pair');
await expect(row(page,'Saved pairs removed')).toHaveText('1 saved pair (1 with a negative prompt)');
await expect(row(page,'Negatives kept')).toHaveText('1 negative prompt is kept');
await expect(row(page,'Backup')).toHaveText('Create and verify before deleting');
await expect(review(page).locator('p.small').first()).toHaveText(/^Deleting a prompt in a saved pair also removes its saved pairs/);
// Back to Archive: the review is #443's again.
await choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'}).check();
await expect(row(page,'Saved pairs removed')).toHaveCount(0);
await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible();
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /);
await expect(card(page,dual.id)).toHaveCount(0);
expect(writes).toHaveLength(1);
expect(writes[0].delete_paired_ids).toEqual([dual.id]);
expect(Object.keys(writes[0].paired_digests)).toEqual([String(dual.id)]);
const after=await items(request);
expect(byText(after,'Dual role portrait')).toBeUndefined();
// Its negative (Cat portrait) is kept, with its own variants.
expect(byText(after,'Cat portrait')?.id).toBe(cat.id);
expect((await items(request,'archived')).map(item=>item.id)).toEqual([]);
});
test('a prompt used as another prompt\'s negative is archived even when Delete permanently is chosen (#482)',async({page,request})=>{
const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait');
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
await box(page,cat.id).check();await box(page,dual.id).check();await openReview(page);
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await expect(row(page,'Archive')).toHaveText('1 prompt used as another prompt\'s negative: hidden, restorable');
await expect(review(page).locator('p.small',{hasText:'archived instead of deleted'})).toHaveCount(1);
await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Archived 1 prompt\./);
expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]);
});
test('the choice resets to Archive on every Delete…, and a selection with no paired prompt has none (#482)',async({page,request})=>{
const before=await items(request);const dual=byText(before,'Dual role portrait'),dog=byText(before,'Dog landscape');
await page.goto(route);await box(page,dual.id).check();await openReview(page);
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await openReview(page);
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
await box(page,dog.id).check();await openReview(page);
await expect(choice(page)).toHaveCount(0);
});
// #481 (D161): a prompt's own page deletes it from Edit metadata (the concept's recipe editor, app.js 976), through the
// same review: "Delete prompt…" beside "Archive variant…" (the variant's archive, renamed so the two cannot be confused).
const editMetadata=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'Edit metadata',exact:true});
const editor=page=>page.locator('dialog.library-editor[open]');
async function deleteFromPage(page,id){
await page.goto(`/prompt_manager/workspace#/library/${id}`);
await editMetadata(page).click();
await expect(editor(page).locator('.dialog-actions button')).toHaveText(['Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
await expect(editor(page).getByRole('button',{name:'Delete prompt…',exact:true})).toHaveClass(/\bdanger\b/);
await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await expect(editor(page)).toHaveCount(0);await expect(review(page)).toBeVisible();
}
test('Delete prompt… on a prompt\'s page archives it by default, and deletes it permanently when chosen (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const previews=recordPosts(page,PREVIEW);
await deleteFromPage(page,dual.id);
expect(previews.map(body=>body.prompt_ids)).toEqual([[dual.id]]);
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('Archived 1 prompt.');
// The page stays and shows its Archived notice.
await expect(page.locator('.library-archived-notice h2')).toHaveText('Archived');
await expect(page.locator('#workspace-main h1')).toBeFocused();
await page.locator('.library-archived-notice').getByRole('button',{name:'Restore this prompt to the Prompt library'}).click();
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
// Delete permanently: back to the Prompt library, the prompt gone.
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /);
await expect(page).toHaveURL(/#\/library$/);
await expect(card(page,dual.id)).toHaveCount(0);
expect(byText(await items(request),'Dual role portrait')).toBeUndefined();
});
test('Cancel on the page\'s review changes nothing and returns focus to Edit metadata (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const writes=recordPosts(page,DELETE);
await deleteFromPage(page,dual.id);
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
expect(writes).toEqual([]);
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
});
test('the page\'s delete holds one review at a time, Escape returns focus, and an archived page\'s review is close-only (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
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(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
// While the preview is held, Edit metadata is held too: no second review and no save can race the delete.
await expect(editMetadata(page)).toHaveAttribute('aria-disabled','true');
await editMetadata(page).click({force:true});await expect(editor(page)).toHaveCount(0);
release();
await expect(review(page)).toBeVisible();
expect(previews).toHaveLength(1);
expect(previews[0].expected_epoch).toMatch(/\S/);
await page.keyboard.press('Escape');
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
await page.unroute(`**${PREVIEW}`);
// Archived: its review has nothing to change and only closes.
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await expect(page.locator('.library-archived-notice')).toBeVisible();
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.');
await expect(review(page).getByRole('button',{name:/^(Delete|Archive)/})).toHaveCount(0);
});
test('a failed review on the prompt\'s page says so, changes nothing and releases Edit metadata (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const writes=recordPosts(page,DELETE);
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 page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await expect(toast(page)).toHaveText('The selected prompts could not be reviewed. Try again.');
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
expect(writes).toEqual([]);
await editMetadata(page).click();await expect(editor(page)).toBeVisible();
});
test('a delete answered after the page was left invalidates the shelf without a toast or a jump (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
let release;const held=new Promise(resolve=>{release=resolve;});
let answered;const done=new Promise(resolve=>{answered=resolve;});
await page.route(`**${DELETE}`,async item=>{await held;const response=await item.fetch();await item.fulfill({response});answered();});
await deleteFromPage(page,dual.id);
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await page.evaluate(()=>{window.location.hash='#/overview';});
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
// Every toast text shown from here on is recorded, so one shown and hidden again still counts.
await page.evaluate(()=>{window.seenToasts=[];new MutationObserver(()=>{for(const node of document.querySelectorAll('.toast'))
if(node.textContent.trim())window.seenToasts.push(node.textContent.trim());}).observe(document.body,{subtree:true,childList:true,characterData:true});});
release();await done;
// The answer lands on Overview: no navigation; the shelf reads again when opened; and no toast was ever shown.
await page.evaluate(()=>{window.location.hash='#/library';});
await expect(card(page,dual.id)).toHaveCount(0);
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
expect(await page.evaluate(()=>window.seenToasts)).toEqual([]);
});
test('a restore since the prompt\'s page loaded is refused and the page reads the Library again (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await armUnseenRestores(page);
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
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:'Archive 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.');
await expect(page.locator('#workspace-main h1')).toBeFocused();
expect((await items(request,'archived')).map(item=>item.id)).toEqual([]);
});
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;
+26 -34
View File
@@ -8,7 +8,7 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c
// Dialog-level contracts mount the editor through a page import; the user flows below use the real entry actions.
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const workspace='/prompt_manager/workspace';
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive variant…','Delete prompt…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save.
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes'];
const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.';
@@ -27,15 +27,13 @@ async function mountEditor(page,theme) {
if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.evaluate(async()=>{
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
window.editorEvents={committed:[],reconciled:[],deletePrompt:[]};
window.editorEvents={committed:[],reconciled:[]};
try {
const {createLibraryEditor,showWorkspaceToast}=await import('/prompt_manager/js/workspace/library-editor.mjs');
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const scope=createScope();
const editor=createLibraryEditor({api:window.ApiClient.createApiClient(),parentScope:scope,
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result),
// As the prompt's page wires it (#481); the review itself is covered in workspace-library-delete.spec.mjs.
onDeletePrompt:request=>window.editorEvents.deletePrompt.push(request.positiveId)});
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result)});
window.editorHarness={scope,editor,showWorkspaceToast};
} catch(error) {window.editorHarness={missing:String(error)};}
});
@@ -54,7 +52,7 @@ const pairRead=async(request,id)=>(await(await request.get(`/prompt_manager/pair
test('edit recipe metadata shows the concept fields with the selected saved negative read-only',async({page})=>{
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
await expect(dialog.getByRole('heading',{name:'Edit recipe metadata',exact:true})).toBeVisible();
expect(await dialog.evaluate(node=>node.matches(':modal'))).toBe(true);
await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog);
@@ -75,14 +73,9 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
await expect(dialog.locator('.demo-note')).toHaveCount(0);
expect(await actionNames(dialog)).toEqual(ACTIONS.metadata);
await expect(button(dialog,'Archive variant…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
await expect(button(dialog,'Archive…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
// D161 (#481): Delete prompt… closes the editor first and hands the prompt to the page's delete review.
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await expect(button(dialog,'Delete prompt…')).toHaveClass(/\bdanger\b/);await button(dialog,'Delete prompt…').click();
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(()=>window.editorEvents.deletePrompt)).toEqual([f.positive_id]);expect(page.writes).toHaveLength(0);
});
test('editor modes persist through reload with selected saved negative',async({page,request})=>{
@@ -151,13 +144,13 @@ test('editor modes persist through reload with selected saved negative',async({p
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.standalone_id,pairId:null});await ready(dialog);
const archive=button(dialog,'Archive variant…');await expect(archive).toBeDisabled();
const archive=button(dialog,'Archive…');await expect(archive).toBeDisabled();
await expect(archive).toHaveAccessibleDescription('Archive needs a selected saved negative variant.');
await expect(note(dialog)).toHaveText('Archive needs a selected saved negative variant.');
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive variant…').click();
await button(dialog,'Archive…').click();
await expect(dialog.getByRole('heading',{name:'Archive negative variant',exact:true})).toBeVisible();
await expect(button(dialog,'Close dialog')).toBeFocused();
const block=dialog.getByRole('region',{name:'Negative prompt',exact:true});
@@ -170,7 +163,7 @@ test('archive confirms the selected pair with fresh counts and writes only on co
expect((await pairRead(request,f.pair_id)).archived).toBe(false);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:true}}]);
expect((await pairRead(request,f.pair_id)).archived).toBe(true);
@@ -185,7 +178,7 @@ test('a failed archive keeps focus on the Archive confirmation inside the dialog
failed=true;return route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Cannot archive.","code":null}'});
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toContainText('Cannot archive.');expect(page.writes).toHaveLength(1);
await expect(button(dialog,'Archive')).toBeFocused();
@@ -199,7 +192,7 @@ test('an unconfirmed archive moves focus to Retry instead of dropping it out of
await route.continue();
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('Archive could not be confirmed. Retry sends the same archive once.');
await expect(button(dialog,'Archive')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
@@ -216,7 +209,7 @@ test('an archive conflict offers an explicit Reload that re-reads and never repl
conflicted=true;return route.fulfill({status:409,contentType:'application/json',body:'{"success":false,"error":"changed","code":"metadata-conflict"}'});
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('This negative variant changed since it was loaded. Reload it and review it before archiving.');
await expect(button(dialog,'Archive')).toBeDisabled();expect(page.writes).toHaveLength(1);
@@ -244,7 +237,7 @@ test('cancel and busy submit have honest write counts',async({page})=>{
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Notes').fill('busy save');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Saving… The dialog stays open until the server answers.');
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
await field(dialog,'Title').press('Enter');await page.keyboard.press('Escape');
await expect(dialog).toBeVisible();expect(page.writes).toHaveLength(1);
release();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(1);
@@ -326,15 +319,15 @@ test('edge states keep drafts, explain disabled archive and clean up on dispose'
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.archived_pair_id});await ready(dialog);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.archived_negative);
await expect(button(dialog,'Archive variant…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
await expect(button(dialog,'Archive variant…')).toHaveAccessibleDescription('This negative variant is already archived.');
await expect(button(dialog,'Archive…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
await expect(button(dialog,'Archive…')).toHaveAccessibleDescription('This negative variant is already archived.');
await button(dialog,'Cancel').click();
let failed=true;
await page.route(`**/prompt_manager/pairs/${f.empty_pair_id}`,route=>{if(!failed&&route.request().method()==='GET'){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'});}return route.continue();});
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.empty_pair_id});await ready(dialog);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue('');
failed=false;await button(dialog,'Archive variant…').click();
failed=false;await button(dialog,'Archive…').click();
await expect(note(dialog)).toHaveText('Linked images and recorded runs could not be read. Nothing was archived.');
await expect(button(dialog,'Archive')).toBeDisabled();
await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
@@ -354,12 +347,12 @@ test('the archive confirmation does not inherit a failed or invalid Edit save me
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Notes').fill('Rejected notes');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Not saved: Notes are too long. Your entries are kept.');
await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Title').fill(' ');await button(dialog,'Save').click();await expect(note(dialog)).toHaveText('Title is required');
await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
expect(page.writes).toHaveLength(1);
});
@@ -500,10 +493,9 @@ test('a restore seen finishing keeps New prompt open and reloads the Library aft
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
const actions=headingActions(page).locator('button');
// D152 (#443): Show archived prompts (N) comes first, a plain button (#482: no pressed state; on the archived shelf it
// reads "← Prompt library").
// 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)).not.toHaveAttribute('aria-pressed');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
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(1)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(1)).not.toHaveClass(/ca-primary/);
@@ -649,14 +641,14 @@ test('restored storage after a confirmed save or before an archive offers an exp
const saved=page.waitForResponse(item=>item.request().method()==='PATCH'&&new URL(item.url()).pathname===`/prompt_manager/library/${f.positive_id}`);
await button(dialog,'Save').click();await saved;
await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.');
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);
await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1);
await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await expect(await restore()).toBeOK();await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was archived.');
expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Archive']);expect(page.writes).toHaveLength(1);
@@ -673,10 +665,10 @@ test('restored storage before an Edit or Add negative save offers only the Libra
await expect(await restore()).toBeOK();
await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`);
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled();
// Revise and archive would re-read the old numeric IDs, which may now name another prompt.
for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
await expect(button(dialog,'Reload Library')).toBeFocused();
expect(page.writes).toHaveLength(1);// the refused save
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
@@ -743,7 +735,7 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
const before=await evidence();expect(before.counts).toEqual({images:1,runs:2});
await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
@@ -886,7 +878,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
await toggle().click();await expect(toggle()).toHaveAttribute('aria-pressed','false');await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0);
await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
+3 -84
View File
@@ -91,10 +91,10 @@ test('a delete outcome counts each part, singular and plural, and names the back
toast: 'Deleted 2 prompts. Archived 3 prompts. 1 protected prompt was kept. Safety backup saved: prompts.db.backup_x (System › Backup & export).'});
assert.equal(deleteOutcome({success: true, deleted: 1, archived: 0, already_archived: 1, not_found: 1, changed: 1,
backup: null}, null, reviewOf(preview())).toast,
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was not deleted.');
'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 not deleted. 2 protected prompts were kept. The Library was reloaded.');
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were kept. 2 protected prompts were kept. The Library was reloaded.');
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0}, null, reviewOf(preview())).committed, false);
});
@@ -134,8 +134,7 @@ test('reviewOf keeps only well-formed ids and counts from an answer', async () =
const review = reviewOf({library_epoch: 3, delete: [1, 'x', -2, 2], archive: null, protected: [1.5], already_archived: -1,
not_found: 'x', image_links: 2, backup: 'maybe'});
assert.deepEqual(review, {epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
imageLinks: 2, backup: 'on', disk: null, deletable: [], digests: {},
pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive'});
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 () => {
@@ -149,83 +148,3 @@ test('the archived notice says why (the saved pair) and names negative variants
// An unknown count (an older server) never claims variants.
assert.equal(archivedNoticeText(undefined), archivedNoticeText(0));
});
// #482 (D160): prompts in a saved pair can be deleted permanently, with their own saved pairs.
const D1 = 'a'.repeat(64), D2 = 'b'.repeat(64);
const paired = (patch = {}) => preview({archive: [3, 4, 5], deletable: [3, 4], review_digests: {3: D1, 4: D2},
paired_counts: {pairs: 3, archived_pairs: 1, variants: 2, kept_negatives: 2}, paired_image_links: 4, ...patch});
test('reviewOf reads the deletable ids, their digests and the pair counts; the choice starts at Archive', async () => {
const {reviewOf, hasPairedChoice} = await source;
const review = reviewOf(paired({deletable: [3, 4, 99], review_digests: {3: D1, 4: 'short', 99: D1}}));
// Only reviewed archive ids with a well-formed digest can be deleted permanently.
assert.deepEqual(review.deletable, [3]);
assert.deepEqual(review.digests, {3: D1});
assert.deepEqual(review.pairedCounts, {pairs: 3, archivedPairs: 1, variants: 2, keptNegatives: 2});
assert.equal(review.pairedImageLinks, 4);
assert.equal(review.choice, 'archive');
assert.equal(hasPairedChoice(review), true);
assert.equal(hasPairedChoice(reviewOf(preview({archive: [5]}))), false);
});
test('Archive chosen keeps the #443 review; Delete permanently names the pairs, the kept negatives and archive-only', async () => {
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel, deleteBody} = await source;
const review = reviewOf(paired({delete: [1], image_links: 2}));
assert.deepEqual(reviewGrid(review).map(([term]) => term), ['Delete permanently', 'Archive', 'Image links removed', 'Backup']);
assert.equal(primaryLabel(review), 'Delete 1 and archive 3 prompts');
assert.deepEqual(deleteBody(review), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on'});
const permanent = withChoice(review, 'delete');
assert.notEqual(permanent, review);
assert.equal(review.choice, 'archive');
assert.deepEqual(reviewGrid(permanent), [
['Delete permanently', '3 prompts: 1 not in a saved pair, 2 in a saved pair'],
['Saved pairs removed', '3 saved pairs (2 with a negative prompt, 1 archived)'],
['Negatives kept', '2 negative prompts are kept'],
['Archive', '1 prompt used as another prompt\'s negative: hidden, restorable'],
['Image links removed', '6 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
assert.equal(reviewTitle(permanent), 'Delete or archive selected prompts');
assert.equal(primaryLabel(permanent), 'Delete 3 and archive 1 prompt');
assert.deepEqual(deleteBody(permanent), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on',
delete_paired_ids: [3, 4], paired_digests: {3: D1, 4: D2}});
});
test('edge counts: one prompt, no negative, nothing archive-only', async () => {
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel} = await source;
// Live prompt 7896: one pair with an explicitly empty negative.
const one = withChoice(reviewOf(preview({archive: [7], deletable: [7], review_digests: {7: D1},
paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}, paired_image_links: 0})), 'delete');
assert.deepEqual(reviewGrid(one), [
['Delete permanently', '1 prompt in a saved pair'],
['Saved pairs removed', '1 saved pair'],
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
assert.equal(reviewTitle(one), 'Delete selected prompts');
assert.equal(primaryLabel(one), 'Delete 1 prompt');
});
test('Delete permanently sentences: what goes with the pairs, what stays, and how it comes back', async () => {
const {reviewOf, withChoice, reviewSentences} = await source;
const [pairs, remove, archive] = reviewSentences(withChoice(reviewOf(paired()), 'delete'));
assert.equal(pairs, 'Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' +
'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' +
'and Activity, listed by their text.');
assert.match(remove, /the only way back is to restore the safety backup/);
assert.match(remove, /A pair node run with the same text saves a new pair\.$/);
assert.equal(archive, 'Prompts used as another prompt\'s negative are archived instead of deleted, so that ' +
'prompt\'s variant stays.');
const [, off] = reviewSentences(withChoice(reviewOf(paired({backup: 'off'})), 'delete'));
assert.match(off, /Deleted prompts cannot be brought back\./);
});
test('a short disk blocks only the permanent delete; the outcome names removed pairs', async () => {
const {reviewOf, withChoice, blockedReason, deleteOutcome} = await source;
const short = reviewOf(paired({disk: {sufficient: false, message: 'Not enough space. Nothing was changed.'}}));
assert.equal(blockedReason(short), null);
assert.equal(blockedReason(withChoice(short, 'delete')), 'Not enough space. Nothing was changed.');
const {toast} = deleteOutcome({success: true, deleted: 2, archived: 1, pairs_removed: 3, changed: 1}, null, short);
assert.equal(toast, 'Deleted 2 prompts. Removed 3 saved pairs. Archived 1 prompt. 1 changed since the review and ' +
'was not deleted.');
});
-36
View File
@@ -859,42 +859,6 @@ test('backupNotice is the concept .notice with its copy and one Back up now acti
}
});
test('openReviewDialog replaces its rows and its primary in place (#482)', async () => {
const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
const started = [];
const review = openReviewDialog({ scope, title: 'Delete or archive', grid: [['Archive', '2 prompts']],
cancelLabel: 'Cancel', primary: { label: 'Archive 2 prompts', danger: true }, onStart: () => started.push('start') });
review.setRows([['Delete permanently', '2 prompts'], ['Saved pairs removed', '3 saved pairs']]);
const grid = byId(dom, 'modal-body').children[0];
assert.deepEqual(grid.children.map(entry => [entry.children[0].textContent, entry.children[1].textContent]),
[['Delete permanently', '2 prompts'], ['Saved pairs removed', '3 saved pairs']]);
// setValue reaches the new rows only.
review.setValue('Saved pairs removed', '4 saved pairs');
review.setValue('Archive', 'x');
assert.equal(grid.children[1].children[1].textContent, '4 saved pairs');
const start = button(byId(dom, 'modal-body'), 'Archive 2 prompts');
review.setPrimary({ label: 'Delete 2 prompts', disabled: true });
assert.equal(start.textContent, 'Delete 2 prompts');
assert.equal(start.getAttribute('aria-disabled'), 'true');
start.click();
assert.deepEqual(started, []);
assert.equal(modal(dom).open, true);
review.setPrimary({ label: 'Delete 2 prompts', disabled: false });
assert.equal(start.getAttribute('aria-disabled'), 'false');
start.click();
assert.deepEqual(started, ['start']);
assert.throws(() => review.setRows([['only a term']]), TypeError);
} finally {
scope.dispose();
closeWorkspaceDialog();
dom.restore();
}
});
test('openReviewDialog lays out heading, grid, options, sentence, backup box and actions', async () => {
const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
-202
View File
@@ -62,8 +62,6 @@ def table_dump(db):
class LibraryDeleteCase(unittest.TestCase):
maxDiff = None
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
# Cleanups run last-in first-out and after every cleanup a test adds,
@@ -140,23 +138,9 @@ class TestClassifyAndApply(LibraryDeleteCase):
"not_found": [negative_only, 99999],
"image_links": 1,
"fingerprints": result["fingerprints"],
# #482: the archive ids Delete permanently could remove (with pairs).
"deletable": [positive, archived_pair_positive],
"review_digests": result["review_digests"],
"paired_counts": {
"pairs": 2,
"archived_pairs": 1,
"variants": 2,
# "only negative" is itself selected; only "blur" is kept.
"kept_negatives": 1,
},
"paired_image_links": 0,
},
)
self.assertEqual(list(result["fingerprints"]), [plain])
self.assertEqual(
list(result["review_digests"]), [positive, archived_pair_positive]
)
def test_apply_archives_paired_deletes_unpaired_and_skips_protected(self):
plain = save(self.db, "Plain", tags=["red"])
@@ -176,7 +160,6 @@ class TestClassifyAndApply(LibraryDeleteCase):
"not_found": 0,
"changed": 0,
"image_links": 1,
"pairs_removed": 0,
},
)
self.assertEqual(prompt_ids(self.db), sorted([protected, positive, negative]))
@@ -541,188 +524,3 @@ class TestArchivedReaders(LibraryDeleteCase):
if __name__ == "__main__":
unittest.main()
class TestPairedDelete(LibraryDeleteCase):
"""#482 (D160): Delete permanently also removes a prompt's own saved pairs."""
def pair_rows(self, positive):
return rows(
self.db,
"SELECT id FROM prompt_pairs WHERE positive_prompt_id=? ORDER BY id",
(positive,),
)
def pair_link(self, pair, image):
return self.db.link_image_to_pair(pair, image, {"resolver_version": 1})
def delete_paired(self, ids, review, archive=None, epoch=None):
"""Write ``ids`` as Delete permanently, with the digests ``review`` gave."""
archive = review["archive"] if archive is None else archive
return self.db.archive_or_delete_library_prompts(
archive,
review["delete"],
expected_epoch=epoch or self.epoch,
candidates=review["fingerprints"],
delete_paired={pid: review["review_digests"][pid] for pid in ids},
)
def test_classify_marks_deletable_and_archive_only_with_pair_counts(self):
cat, cat_negative, _pair = self.paired("Cat", "blur")
self.db.save_prompt_pair("Cat", "grain")
archived_pair = self.db.save_prompt_pair("Cat", "noise")
self.db.archive_prompt_pair(archived_pair, True)
shared = save(self.db, "Shared")
self.image(shared, "shared.png")
self.db.save_prompt_pair("Dog", "Shared") # used as another positive's negative
review = self.classify([cat, shared])
self.assertEqual(review["archive"], [cat, shared])
self.assertEqual(review["deletable"], [cat])
self.assertEqual(list(review["review_digests"]), [cat])
self.assertRegex(review["review_digests"][cat], r"^[0-9a-f]{64}$")
self.assertEqual(
review["paired_counts"],
{"pairs": 3, "archived_pairs": 1, "variants": 3, "kept_negatives": 3},
)
self.assertEqual(review["paired_image_links"], 0)
def test_an_empty_negative_pair_is_deleted_with_its_prompt(self):
# Live prompt 7896: an auto-created pair with an explicitly empty negative.
pair = self.db.save_prompt_pair("été", "")
prompt = self.db.get_prompt_pair(pair)["positive_prompt_id"]
review = self.classify([prompt])
self.assertEqual(review["deletable"], [prompt])
self.assertEqual(
review["paired_counts"],
{"pairs": 1, "archived_pairs": 0, "variants": 0, "kept_negatives": 0},
)
result = self.delete_paired([prompt], review)
self.assertEqual((result["deleted"], result["archived"]), (1, 0))
self.assertEqual(result["pairs_removed"], 1)
self.assertNotIn(prompt, prompt_ids(self.db))
self.assertEqual(self.pair_rows(prompt), [])
self.assertEqual(rows(self.db, "PRAGMA foreign_key_check"), [])
def test_rows_removed_are_the_legacy_pair_deletes_then_the_prompt_delete(self):
twin = PromptDatabase(str(self.root / "twin.db"))
self.addCleanup(twin.close_all)
twin_epoch = twin.get_library_options()["library_epoch"]
made = {}
for db in (self.db, twin):
plain = db.save_prompt_pair("Owl", "", tags=["night"], title="Owl")
with_negative = db.save_prompt_pair("Owl", "dark", rating=4)
hidden = db.save_prompt_pair("Owl", "fog")
db.archive_prompt_pair(hidden, True)
owl = db.get_prompt_pair(plain)["positive_prompt_id"]
path = self.root / "owl.png"
path.write_bytes(b"png")
image = db.link_image_to_prompt(owl, str(path), {"workflow": {"nodes": []}})
db.link_image_to_pair(with_negative, image, {"resolver_version": 1})
save(db, "Bystander", tags=["night"])
made[id(db)] = (owl, [plain, with_negative, hidden])
owl, pairs = made[id(self.db)]
review = self.classify([owl])
self.delete_paired([owl], review)
twin_owl, twin_pairs = made[id(twin)]
with twin.model.get_connection() as conn:
conn.execute(
"DELETE FROM image_prompt_pairs WHERE pair_id IN (SELECT id FROM "
"prompt_pairs WHERE positive_prompt_id=?)",
(twin_owl,),
)
conn.commit()
for pair in twin_pairs:
self.assertTrue(twin.delete_prompt_pair(pair))
twin.archive_or_delete_library_prompts(
[], [twin_owl], expected_epoch=twin_epoch, candidates=None
)
self.assertEqual(table_dump(self.db), table_dump(twin))
# The negatives stay, and the image file stays on disk.
self.assertTrue((self.root / "owl.png").exists())
texts = [row[0] for row in rows(self.db, "SELECT text FROM prompts")]
self.assertTrue({"dark", "fog", "Bystander"} <= set(texts))
def test_a_negative_used_by_another_positive_is_archived_never_deleted(self):
shared = save(self.db, "Shared")
self.image(shared, "s.png")
self.db.save_prompt_pair("Dog", "Shared")
review = self.classify([shared])
self.assertEqual(review["deletable"], [])
result = self.db.archive_or_delete_library_prompts(
[shared],
[],
expected_epoch=self.epoch,
candidates={},
delete_paired={shared: "0" * 64},
)
self.assertEqual((result["deleted"], result["archived"]), (0, 1))
self.assertEqual(result["changed"], 1)
self.assertEqual(archived_ids(self.db), [shared])
def test_a_positive_and_its_own_negative_selected_together(self):
cat, blur, _pair = self.paired("Cat", "blur")
self.image(blur, "blur.png") # the negative is a Library prompt too
review = self.classify([cat, blur])
self.assertEqual(review["deletable"], [cat])
result = self.delete_paired([cat], review)
self.assertEqual((result["deleted"], result["archived"]), (1, 1))
self.assertEqual(archived_ids(self.db), [blur])
self.assertNotIn(cat, prompt_ids(self.db))
def test_a_self_pair_is_deleted(self):
pair = self.db.save_prompt_pair("Same", "Same")
same = self.db.get_prompt_pair(pair)["positive_prompt_id"]
review = self.classify([same])
self.assertEqual(review["deletable"], [same])
self.assertEqual(review["paired_counts"]["kept_negatives"], 0)
self.assertEqual(self.delete_paired([same], review)["deleted"], 1)
self.assertNotIn(same, prompt_ids(self.db))
def test_drift_after_the_review_archives_and_counts_changed(self):
cases = {
"pair added": lambda p: self.db.save_prompt_pair("Fox", "new"),
"pair edited": lambda p: self.db.update_prompt_pair_metadata(
self.pair_rows(p)[0][0], title="Renamed"
),
"pairs gone": lambda p: self.db.delete_prompt_pair(self.pair_rows(p)[0][0]),
"now another's negative": lambda p: self.db.save_prompt_pair("Hen", "Fox"),
}
for name, change in cases.items():
with self.subTest(name):
fox, _neg, _pair = self.paired("Fox", "mist " + name)
review = self.classify([fox])
self.assertEqual(review["deletable"], [fox])
change(fox)
result = self.delete_paired([fox], review)
self.assertEqual(
(result["deleted"], result["archived"], result["changed"]),
(0, 1, 1),
)
self.assertIn(fox, archived_ids(self.db))
self.restore([fox])
with self.db.model.get_connection() as conn:
conn.execute(
"DELETE FROM prompt_pairs WHERE positive_prompt_id IN "
"(SELECT id FROM prompts WHERE text IN ('Hen','Fox'))"
)
conn.commit()
def test_delete_paired_must_name_reviewed_archive_ids(self):
fox, _neg, _pair = self.paired("Fox")
review = self.classify([fox])
with self.assertRaises(ValueError):
self.db.archive_or_delete_library_prompts(
[],
[],
expected_epoch=self.epoch,
candidates={},
delete_paired={fox: review["review_digests"][fox]},
)
with self.assertRaises(ValueError):
self.db.archive_or_delete_library_prompts(
[fox],
[],
expected_epoch=self.epoch,
candidates={},
delete_paired={fox: "not a digest"},
)
+1 -94
View File
@@ -130,13 +130,8 @@ class TestLibraryDeleteRoutes(LibraryDeleteRouteCase):
async def test_archive_only_takes_no_backup(self):
self.policy(True)
positive = self.paired("Cat")
shared = self.save("Shared")
self.api.db.save_prompt_pair("Dog", "Shared") # archive-only (#482)
self.api.db.link_image_to_prompt(shared, "/tmp/shared-443.png")
# Nothing could be deleted: no disk plan. (#482: a deletable prompt has one.)
self.assertIsNone((await self.review([shared]))["disk"])
review = await self.review([positive])
self.assertIsNotNone(review["disk"])
self.assertIsNone(review["disk"])
status, body = await self.confirm(review)
self.assertEqual((status, body["archived"], body["backup"]), (200, 1, None))
self.assertEqual(self.backups(), [])
@@ -314,94 +309,6 @@ class TestLibraryDeleteRoutes(LibraryDeleteRouteCase):
self.assertEqual(response.status, 400)
class TestPairedDeleteRoutes(LibraryDeleteRouteCase):
"""#482 (D160): Delete permanently with a prompt's own saved pairs."""
async def confirm_paired(self, review, ids, backup=None, digests=None):
return await self.post(
DELETE,
{
"archive_ids": review["archive"],
"delete_ids": review["delete"],
"expected_epoch": review["library_epoch"],
"backup": backup or review["backup"],
"delete_paired_ids": ids,
"paired_digests": digests
or {str(pid): review["review_digests"][str(pid)] for pid in ids},
},
)
async def test_preview_reports_deletable_and_a_paired_only_delete_backs_up(self):
self.policy(True)
cat = self.paired("Cat")
shared = self.save("Shared")
self.api.db.save_prompt_pair("Dog", "Shared")
self.api.db.link_image_to_prompt(shared, "/tmp/shared-482.png")
review = await self.review([cat, shared])
self.assertEqual(review["archive"], [cat, shared])
self.assertEqual(review["deletable"], [cat])
self.assertEqual(list(review["review_digests"]), [str(cat)])
self.assertEqual(
review["paired_counts"],
{"pairs": 1, "archived_pairs": 0, "variants": 1, "kept_negatives": 1},
)
self.assertEqual(review["paired_image_links"], 0)
# A selection with nothing in the delete class still checks the disk.
self.assertTrue(review["disk"]["sufficient"])
status, body = await self.confirm_paired(review, [cat])
self.assertEqual(status, 200, body)
self.assertEqual(
(body["deleted"], body["archived"], body["pairs_removed"]), (1, 1, 1)
)
self.assertEqual(self.backups(), [body["backup"]])
self.assertNotIn(cat, self.ids())
self.assertEqual(self.archived(), [shared])
async def test_a_body_without_the_new_keys_is_the_443_delete(self):
cat = self.paired("Cat")
review = await self.review([cat])
status, body = await self.confirm(review)
self.assertEqual(status, 200, body)
self.assertEqual((body["deleted"], body["archived"]), (0, 1))
self.assertIn(cat, self.ids())
async def test_paired_bodies_are_validated(self):
cat = self.paired("Cat")
plain = self.save("Plain")
review = await self.review([cat, plain])
digest = review["review_digests"][str(cat)]
base = {
"archive_ids": review["archive"],
"delete_ids": review["delete"],
"expected_epoch": review["library_epoch"],
"backup": "off",
}
cases = [
{"delete_paired_ids": [cat]}, # without its digests
{"delete_paired_ids": [plain], "paired_digests": {str(plain): digest}},
{"delete_paired_ids": [cat], "paired_digests": {}},
{"delete_paired_ids": [cat], "paired_digests": {str(cat): "x"}},
{"delete_paired_ids": "all", "paired_digests": {}},
]
for extra in cases:
with self.subTest(extra=extra):
status, body = await self.post(DELETE, {**base, **extra})
self.assertEqual((status, body["code"]), (400, "invalid-request"))
self.assertIn(cat, self.ids())
async def test_a_short_disk_refuses_a_paired_only_delete(self):
self.policy(True)
cat = self.paired("Cat")
from py.api import maintenance
with patch.object(maintenance, "_free_bytes", return_value=0):
review = await self.review([cat])
self.assertFalse(review["disk"]["sufficient"])
status, body = await self.confirm_paired(review, [cat])
self.assertEqual((status, body["code"]), (507, "insufficient-space"))
self.assertIn(cat, self.ids())
class TestIngestionAfterArchive(LibraryDeleteRouteCase):
def scan(self, name, positive, negative="blur"):
meta = {"prompt": json.dumps(graph(positive, negative))}
+29 -180
View File
@@ -34,7 +34,6 @@ export const COPY = Object.freeze({
const count = value => (Number.isSafeInteger(value) && value >= 0 ? value : 0);
const ids = value => (Array.isArray(value) ? value.filter(id => Number.isSafeInteger(id) && id > 0) : []);
const were = (value, singular, plural) => `${value.toLocaleString('en-US')} ${value === 1 ? singular : plural}`;
const DIGEST = /^[0-9a-f]{64}$/;
/** The preview's classes as counts and id lists, whatever else the answer holds. */
export function reviewOf(preview) {
@@ -44,43 +43,12 @@ export function reviewOf(preview) {
alreadyArchived: count(preview?.already_archived), notFound: count(preview?.not_found),
imageLinks: count(preview?.image_links), backup: preview?.backup === 'off' ? 'off' : 'on',
disk: preview?.disk && typeof preview.disk === 'object' ? preview.disk : null,
...pairedOf(preview),
};
}
/** #482: the archive ids that may be deleted permanently (each with its review digest), and the pair counts. */
function pairedOf(preview) {
const archive = new Set(ids(preview?.archive)), given = preview?.review_digests ?? {};
const deletable = ids(preview?.deletable).filter(id => archive.has(id) && DIGEST.test(String(given?.[id] ?? '')));
const counts = preview?.paired_counts ?? {};
return {
deletable, digests: Object.fromEntries(deletable.map(id => [id, given[id]])),
pairedCounts: { pairs: count(counts.pairs), archivedPairs: count(counts.archived_pairs),
variants: count(counts.variants), keptNegatives: count(counts.kept_negatives) },
pairedImageLinks: count(preview?.paired_image_links), choice: 'archive',
};
}
/** #482: whether the review asks what to do with prompts in a saved pair. */
export const hasPairedChoice = review => review.deletable.length > 0;
/** #482: the review with its saved-pair choice (`archive`, the default, or `delete`); the original is unchanged. */
export function withChoice(review, choice) {
return { ...review, choice: choice === 'delete' && hasPairedChoice(review) ? 'delete' : 'archive' };
}
const permanent = review => review.choice === 'delete';
/** What is deleted and archived: Delete permanently moves the deletable ids from archive to delete. */
function plan(review) {
if (!permanent(review)) return { plain: review.delete, paired: [], archive: review.archive };
const chosen = new Set(review.deletable);
return { plain: review.delete, paired: review.deletable, archive: review.archive.filter(id => !chosen.has(id)) };
}
export function reviewTitle(review) {
const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length;
if (!archive.length) return 'Delete selected prompts';
if (!deletes) return 'Archive selected prompts';
if (!review.archive.length) return 'Delete selected prompts';
if (!review.delete.length) return 'Archive selected prompts';
return 'Delete or archive selected prompts';
}
@@ -102,90 +70,49 @@ export function archivedNoticeText(variantCount) {
/** The review's `[term, value]` rows (design §7.2); rows that do not apply are left out. */
export function reviewGrid(review, titleOf = () => null) {
if (permanent(review)) return permanentGrid(review, titleOf);
const rows = [
['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`],
// The archive reason is the saved pair (a pair's positive or negative), with or without negative variants.
['Archive', `${countOf(review.archive.length, 'prompt')} in a saved pair: hidden, restorable`],
];
keptRows(rows, review, titleOf);
if (review.delete.length) deleteRows(rows, review, review.imageLinks);
return rows;
}
function keptRows(rows, review, titleOf) {
if (review.protected.length) {
const names = protectedNames(review, titleOf);
rows.push(['Protected (kept)', `${review.protected.length.toLocaleString('en-US')}${names ? `: ${names}` : ''}`]);
}
if (review.alreadyArchived) rows.push(['Already archived', review.alreadyArchived.toLocaleString('en-US')]);
if (review.notFound) rows.push(['No longer exist', review.notFound.toLocaleString('en-US')]);
}
function deleteRows(rows, review, images) {
rows.push(['Image links removed', `${countOf(images, 'image')} will no longer show these prompts (files stay on disk)`]);
rows.push(['Backup', review.backup === 'on' ? 'Create and verify before deleting'
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
}
/** #482: the grid when Delete permanently is chosen. */
function permanentGrid(review, titleOf) {
const { plain, paired, archive } = plan(review), counts = review.pairedCounts;
const parts = [plain.length && `${plain.length.toLocaleString('en-US')} not in a saved pair`,
`${paired.length.toLocaleString('en-US')} in a saved pair`].filter(Boolean);
const total = plain.length + paired.length;
const deleted = plain.length ? `${countOf(total, 'prompt')}: ${parts.join(', ')}` : `${countOf(total, 'prompt')} in a saved pair`;
const details = [counts.variants && `${counts.variants.toLocaleString('en-US')} with a negative prompt`,
counts.archivedPairs && `${counts.archivedPairs.toLocaleString('en-US')} archived`].filter(Boolean);
const rows = [['Delete permanently', deleted],
['Saved pairs removed', `${countOf(counts.pairs, 'saved pair')}${details.length ? ` (${details.join(', ')})` : ''}`]];
if (counts.keptNegatives) rows.push(['Negatives kept', `${countOf(counts.keptNegatives, 'negative prompt')} ${counts.keptNegatives === 1 ? 'is' : 'are'} kept`]);
if (archive.length) rows.push(['Archive', `${countOf(archive.length, 'prompt')} used as another prompt's negative: hidden, restorable`]);
keptRows(rows, review, titleOf);
deleteRows(rows, review, review.imageLinks + review.pairedImageLinks);
if (review.delete.length) {
rows.push(['Image links removed', `${countOf(review.imageLinks, 'image')} will no longer show these prompts (files stay on disk)`]);
rows.push(['Backup', review.backup === 'on' ? 'Create and verify before deleting'
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
}
return rows;
}
/** The review's sentences, in order, each when it applies. */
export function reviewSentences(review) {
if (permanent(review)) return permanentSentences(review);
const sentences = [];
if (review.archive.length) {
sentences.push('Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the ' +
'pair and its runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, ' +
'select them and press Restore.');
}
if (review.delete.length) sentences.push(removeSentence(review));
if (review.protected.length) sentences.push(PROTECTED_SENTENCE);
return sentences;
}
const PROTECTED_SENTENCE = 'Protected prompts are tagged __protected__; remove the tag to delete them.';
function removeSentence(review, pairs = false) {
const undo = review.backup === 'on'
? ' Deleted prompts cannot be brought back one by one: the only way back is to restore the safety backup taken ' +
'first (System › Backup & export), which returns the whole library to that moment. It is kept among the last ' +
'10 safety backups.'
: ' Deleted prompts cannot be brought back.';
return "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " +
`image links.${undo} Image files, favorites, collections and recorded runs stay. A later scan, a new image or a ` +
`node run with the same text adds it again as a new prompt.${pairs ? ' A pair node run with the same text saves a new pair.' : ''}`;
}
/** #482: the sentences when Delete permanently is chosen. */
function permanentSentences(review) {
const sentences = ['Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' +
'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' +
'and Activity, listed by their text.', removeSentence(review, true)];
if (plan(review).archive.length) sentences.push("Prompts used as another prompt's negative are archived instead of deleted, " +
"so that prompt's variant stays.");
if (review.protected.length) sentences.push(PROTECTED_SENTENCE);
if (review.delete.length) {
const undo = review.backup === 'on'
? ' Deleted prompts cannot be brought back one by one: the only way back is to restore the safety backup taken ' +
'first (System › Backup & export), which returns the whole library to that moment. It is kept among the last ' +
'10 safety backups.'
: ' Deleted prompts cannot be brought back.';
sentences.push("Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " +
`image links.${undo} Image files, favorites, collections and recorded runs stay. A later scan, a new image or a ` +
'node run with the same text adds it again as a new prompt.');
}
if (review.protected.length) sentences.push('Protected prompts are tagged __protected__; remove the tag to delete them.');
return sentences;
}
export function primaryLabel(review) {
const { plain, paired, archive } = plan(review);
const deletes = plain.length + paired.length, archives = archive.length;
const deletes = review.delete.length, archives = review.archive.length;
if (deletes && archives) return `Delete ${deletes.toLocaleString('en-US')} and archive ${countOf(archives, 'prompt')}`;
return deletes ? `Delete ${countOf(deletes, 'prompt')}` : `Archive ${countOf(archives, 'prompt')}`;
}
@@ -193,8 +120,7 @@ export function primaryLabel(review) {
/** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */
export function blockedReason(review) {
if (!review.delete.length && !review.archive.length) return COPY.none;
const deletes = review.delete.length + plan(review).paired.length;
if (deletes && review.disk && review.disk.sufficient === false) {
if (review.delete.length && review.disk && review.disk.sufficient === false) {
return typeof review.disk.message === 'string' && review.disk.message
? review.disk.message : 'There is not enough free space for the safety backup. Nothing was changed.';
}
@@ -207,69 +133,21 @@ export function blockedReason(review) {
* close `onDismiss()`.
*/
export function openDeleteReview({ scope, trigger, review, titleOf, onStart, onDismiss }) {
let current = withChoice(review, 'archive');
const paragraphs = target => reviewSentences(target).map(text => paragraph(text));
const title = reviewTitle(current), grid = reviewGrid(current, titleOf);
let sentences = paragraphs(current);
const blocked = blockedReason(current);
const title = reviewTitle(review), grid = reviewGrid(review, titleOf);
const options = reviewSentences(review).map(text => Object.assign(document.createElement('p'), { className: 'small', textContent: text }));
const blocked = blockedReason(review);
if (blocked) {
onDismiss();
openCloseOnlyReview({ scope, trigger, title, grid, options: sentences, sentence: blocked });
openCloseOnlyReview({ scope, trigger, title, grid, options, sentence: blocked });
return;
}
// #482: prompts in a saved pair ask Archive (the default, as #443) or Delete permanently, with their pairs.
const choice = hasPairedChoice(review) ? pairedChoice(value => update(value)) : null;
const dialog = openReviewDialog({ scope, trigger, title, grid, options: choice ? [choice, ...sentences] : sentences,
cancelLabel: 'Cancel', primary: { label: primaryLabel(current), danger: true },
onStart: () => onStart(current), onDismiss });
function update(value) {
current = withChoice(review, value);
dialog.setRows(reviewGrid(current, titleOf));
const reason = blockedReason(current);
const next = paragraphs(current);
if (reason) next.push(paragraph(reason));
for (const node of sentences) node.remove();
choice.after(...next);
sentences = next;
const heading = choice.closest('dialog')?.querySelector('#modal-title');
if (heading) heading.textContent = reviewTitle(current);
dialog.setPrimary({ label: primaryLabel(current), disabled: Boolean(reason) });
}
}
const paragraph = text => Object.assign(document.createElement('p'), { className: 'small', textContent: text });
export const PAIRED_CHOICES = Object.freeze([
Object.freeze({ value: 'archive', label: 'Archive (hidden, restorable)' }),
Object.freeze({ value: 'delete', label: 'Delete permanently, with their saved pairs' }),
]);
/** #482: the D90 radio row ("Prompts in a saved pair"), Archive checked; it is built fresh for every review. */
function pairedChoice(onChange) {
const group = document.createElement('div');
group.className = 'field';
group.setAttribute('role', 'radiogroup');
const caption = document.createElement('span');
caption.textContent = 'Prompts in a saved pair';
caption.id = 'library-delete-choice-label';
group.setAttribute('aria-labelledby', caption.id);
group.append(caption);
for (const choice of PAIRED_CHOICES) {
const label = document.createElement('label');
const input = document.createElement('input');
Object.assign(input, { type: 'radio', name: 'library-delete-choice', value: choice.value, checked: choice.value === 'archive' });
input.addEventListener('change', () => { if (input.checked) onChange(choice.value); });
label.append(input, ` ${choice.label}`);
group.append(label);
}
return group;
openReviewDialog({ scope, trigger, title, grid, options, cancelLabel: 'Cancel',
primary: { label: primaryLabel(review), danger: true }, onStart, onDismiss });
}
/** The write's body: the reviewed lists, the epoch and the reviewed backup promise. */
export function deleteBody(review) {
const body = { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup };
// #482: the archive ids to delete permanently, each with the digest of what the review showed.
if (permanent(review)) Object.assign(body, { delete_paired_ids: review.deletable, paired_digests: review.digests });
return body;
return { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup };
}
function failureOutcome(failure, copy) {
@@ -294,15 +172,14 @@ export function deleteOutcome(result, failure, review) {
return failureOutcome(failure, { storage: COPY.storage, unconfirmed: COPY.unconfirmed, failed: COPY.failed });
}
const deleted = count(result.deleted), archived = count(result.archived);
const parts = [], pairs = count(result.pairs_removed);
const parts = [];
if (deleted) parts.push(`Deleted ${countOf(deleted, 'prompt')}.`);
if (pairs) parts.push(`Removed ${countOf(pairs, 'saved pair')}.`);
if (archived) parts.push(`Archived ${countOf(archived, 'prompt')}.`);
if (!parts.length) parts.push(NOTHING);
const already = count(result.already_archived), missing = count(result.not_found), changed = count(result.changed);
if (already) parts.push(`${were(already, 'was', 'were')} already archived.`);
if (missing) parts.push(`${missing.toLocaleString('en-US')} no longer ${missing === 1 ? 'exists' : 'exist'}.`);
if (changed) parts.push(`${changed.toLocaleString('en-US')} changed since the review and ${changed === 1 ? 'was' : 'were'} not deleted.`);
if (changed) parts.push(`${changed.toLocaleString('en-US')} changed since the review and ${changed === 1 ? 'was' : 'were'} kept.`);
const kept = review?.protected?.length || 0;
if (kept) parts.push(`${countOf(kept, 'protected prompt')} ${kept === 1 ? 'was' : 'were'} kept.`);
if (typeof result.backup === 'string' && result.backup) parts.push(`Safety backup saved: ${result.backup} (System › Backup & export).`);
@@ -328,31 +205,3 @@ export function previewOutcome(failure) {
if (failure?.code === 'busy') return failureOutcome(failure, {});
return { toast: COPY.reviewFailed, committed: false, reload: false };
}
/**
* #481: review and run a delete of `ids` outside the selection bar (a prompt's own page): the same preview, review
* (#482's choice included), backup and write. `onSettled(outcome, result, failure)` runs once the write has answered
* (or the preview failed: `result` null); a dismissed review calls `onDismiss()`. The toast is the caller's.
*/
export async function reviewPromptDelete({ api, scope, trigger, ids: promptIds, epoch, titleOf, onSettled, onDismiss = () => {} }) {
const body = { prompt_ids: promptIds, expected_epoch: epoch };
let preview = null, failure = null;
try {
preview = await api.post(PREVIEW_URL, body, { signal: scope.signal });
} catch (error) {
failure = error;
}
if (scope.signal.aborted) return;
if (failure !== null || !preview?.success) { onSettled(previewOutcome(failure), null, failure); return; }
const review = { ...reviewOf(preview), epoch };
openDeleteReview({ scope, trigger, review, titleOf, onDismiss,
onStart: async chosen => {
let result = null, error = null;
try {
result = await api.post(DELETE_URL, deleteBody(chosen));
} catch (caught) {
error = caught;
}
onSettled(deleteOutcome(result, error, chosen), result?.success ? result : null, error);
} });
}
+3 -14
View File
@@ -122,7 +122,7 @@ function datalist(id, values) {
* exit when prompt storage was restored; the dialog closes first.
*/
export function createLibraryEditor({api, parentScope, onCommitted = () => {}, onReconciled = () => {},
onRefreshRequired = () => window.location.reload(), onDeletePrompt = null}) {
onRefreshRequired = () => window.location.reload()}) {
let session = null, disposed = false;
const dialog = el('dialog', undefined, 'library-editor');
dialog.setAttribute('aria-labelledby', 'library-editor-title');
@@ -222,10 +222,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
} else form.append(el('p', NEGATIVE_HELP, 'small'));
const line = statusLine(), recovery = new Map();
const actions = el('div', undefined, 'dialog-actions');
// D161 (#481): the variant's archive is named for it, beside the prompt's own Delete prompt… (the concept's one
// "Archive…" in this row archives the prompt, app.js 976).
const revise = createButton('Create text revision'), archive = createButton('Archive variant…', 'danger');
const removePrompt = createButton('Delete prompt…', 'danger');
const revise = createButton('Create text revision'), archive = createButton('Archive…', 'danger');
const cancel = createButton('Cancel'), save = createButton('Save', 'ca-primary');
save.type = 'submit';
form.append(line.note, actions);
@@ -240,12 +237,6 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
cancel.addEventListener('click', () => finish(), {signal: views.signal});
revise.addEventListener('click', () => open({mode: 'revision', positiveId, pairId, trigger: session.trigger}), {signal: views.signal});
archive.addEventListener('click', () => archiveView(), {signal: views.signal});
// #481: the prompt's own delete review (#443/#482) replaces this dialog; focus returns to its trigger.
removePrompt.addEventListener('click', () => {
const {trigger} = session;
finish();
onDeletePrompt?.({positiveId, trigger});
}, {signal: views.signal});
if (!negativeOnly) loadVocabulary(tags, views.signal);
let categoryKey = null, refocus = null;
@@ -265,7 +256,6 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
cancel.disabled = close.disabled = saving;
// After a restore the numeric IDs may name another prompt; only the Library reload exit stays.
revise.disabled = saving || restoredStorage(state);
removePrompt.disabled = saving || restoredStorage(state);
const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null;
archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived;
const status = statusOf(state, false, mode);
@@ -273,8 +263,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
if (!status && pairProblem) archive.setAttribute('aria-describedby', line.note.id);
else archive.removeAttribute('aria-describedby');
const retry = recoveryButton(recovery, status?.action, views.signal);
const prompt = readOnly && onDeletePrompt ? [removePrompt] : [];
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive, ...prompt] : []), cancel, save]);
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]);
refocus = restoreFocus(refocus, state, [retry, save]);
if (state.phase === 'complete') finish();
}
+2 -3
View File
@@ -291,9 +291,8 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
deleteButton.textContent = DELETE_LABEL;
openDeleteReview({ scope, trigger: deleteButton, review, titleOf: nameOf,
onDismiss: unlock,
// #482: the review as chosen (Archive, or Delete permanently with the prompts' saved pairs).
onStart: chosen => write(DELETE_URL, deleteBody(chosen), 'delete', (result, error) => deleteOutcome(result, error, chosen),
chosen.delete.length || chosen.choice === 'delete' ? 'Deleting…' : 'Archiving…') });
onStart: () => write(DELETE_URL, deleteBody(review), 'delete', (result, error) => deleteOutcome(result, error, review),
review.delete.length ? 'Deleting…' : 'Archiving…') });
}
/** Restore (#443): the selected archived prompts go back on the shelf. */
function restoreSelected() {
+6 -38
View File
@@ -17,7 +17,7 @@ import {libraryFilters,libraryViewState} from './saved-views.mjs';
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
import {mountLibrarySelection} from './library-selection.mjs';
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome,reviewPromptDelete} from './library-delete.mjs';
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome} from './library-delete.mjs';
let shelfVisit=null;
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
@@ -194,15 +194,12 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
const actions=headingActions(main,section);
const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON');
// #443 D152: the shelf's counterpart of Negative variants' Show archived (#235); N follows the filters. #482: on
// the archived shelf it reads the concept's own way back ("← Prompt library", app.js 594); a plain button whose
// label names where it goes, so it carries no pressed state its changing label would contradict.
const BACK_TO_LIBRARY='← Prompt library';
const showArchived=createButton(filters.shelf==='archived'?BACK_TO_LIBRARY:'Show archived prompts');
// #443 D152: the shelf's counterpart of Negative variants' Show archived (#235); N follows the filters.
const showArchived=createButton('Show archived prompts');showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived'));
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(showArchived,exportButton,newPrompt);
scope.listen(showArchived,'click',()=>{
if(selection.writing()) return;
filters.shelf=filters.shelf==='archived'?'active':'archived';
filters.shelf=filters.shelf==='archived'?'active':'archived';showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived'));
reset();selection.shelfChanged();
});
const exportStatus=el('p','','library-options-status');exportStatus.hidden=true;exportStatus.setAttribute('role','alert');
@@ -411,8 +408,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
:`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
}
const archivedLabel=filters.shelf==='archived'?BACK_TO_LIBRARY
:next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`;
const archivedLabel=next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`;
if(showArchived.textContent!==archivedLabel) showArchived.textContent=archivedLabel;
const filtered=Boolean(filters.text || filters.category || filters.tags.length || filters.folder || filters.tagMode!=='all');
const empty=filters.shelf==='archived'?(filtered?'No archived prompts match.':'No archived prompts.'):'No matching prompts. Try changing the filters.';
@@ -496,37 +492,9 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
let pendingSelection=takeSavedSelection(positiveId);
if(selectedPairs.get(positiveId)?.id!==pendingSelection) selectedPairs.delete(positiveId);
const loader=createLibraryDetail({api,onChange:render});scope.cleanup(()=>loader.dispose());
const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView,
onDeletePrompt:()=>deletePrompt()});
/** #481: this prompt through the Library's Delete… review; a delete returns to the shelf, an archive shows its notice. */
let deleting=false;
// While the review or its write runs, Edit metadata is held (as the selection bar holds its actions), so no save
// can race the delete; aria-disabled keeps it focusable for the return.
function holdEdit(held) {
deleting=held;
if(held) edit.setAttribute('aria-disabled','true');else edit.removeAttribute('aria-disabled');
}
function deletePrompt() {
const shown=detail;if(deleting || !shown?.positive) return;
// One review at a time, as the selection bar holds itself (a second press during the preview starts nothing).
holdEdit(true);
reviewPromptDelete({api,scope,trigger:edit,ids:[shown.positive.id],epoch:shown.library_epoch,
titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(edit.isConnected) edit.focus({preventScroll:true});},
onSettled:(outcome,result,failure)=>{
holdEdit(false);
if(outcome.committed) invalidateLibraryAfterWrite({kind:'bulk-delete',receipt:result ?? {phase:'unconfirmed'}});
// A left page still invalidates what it cached, but says nothing (as the bar's write).
if(scope.signal.aborted) return;
showWorkspaceToast(outcome.toast);
// Restored storage: the whole Library view reads again, as the editor's own restore path.
if(failure?.code==='storage-changed') {reloadView();return;}
if(result && Number.isSafeInteger(result.deleted) && result.deleted>0) {focusHeadingOnMount=true;window.location.hash='#/library';return;}
if(outcome.reload) {detail=null;loader.load(positiveId);focusHeading(main);}
}});
}
const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView});
scope.listen(back,'click',()=>{window.location.hash='#/library';});
scope.listen(edit,'click',()=>{
if(deleting) return;
const draft=metadataDrafts.get(positiveId);metadataDrafts.delete(positiveId);
editor.open({mode:'metadata',positiveId,pairId:current?.activePairId() ?? null,trigger:edit,draft:draft || null});
});
+7 -23
View File
@@ -491,17 +491,13 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
if (heading) nodes.push(el('h3', heading));
const list = el('dl', undefined, 'meta-grid');
const values = new Map();
const fill = rows => {
values.clear();
list.replaceChildren(...rows.map(([term, value]) => {
const entry = el('div');
const dd = el('dd', value);
values.set(term, dd);
entry.append(el('dt', term), dd);
return entry;
}));
};
fill(grid);
for (const [term, value] of grid) {
const entry = el('div');
const dd = el('dd', value);
values.set(term, dd);
entry.append(el('dt', term), dd);
list.append(entry);
}
nodes.push(list, ...options);
if (sentence) nodes.push(el('p', sentence, 'small'));
if (backup) nodes.push(backupNotice(backup));
@@ -510,7 +506,6 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
let started = false;
cancel.addEventListener('click', () => finish());
start.addEventListener('click', () => {
if (start.getAttribute('aria-disabled') === 'true') return;
started = true;
finish();
onStart();
@@ -529,17 +524,6 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
const dd = values.get(term);
if (dd) dd.title = text;
},
/** #482: the rows a choice in the review changes (`[term, value]` pairs, as `grid`). */
setRows(rows) {
requireGrid(rows);
fill(rows);
},
/** #482: the primary's label, and whether it can start (an unavailable start stays in place, aria-disabled). */
setPrimary({ label, disabled = false }) {
if (typeof label !== 'string' || !label) throw new TypeError('The review needs its label');
start.textContent = label;
start.setAttribute('aria-disabled', String(Boolean(disabled)));
},
};
}