Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704db4be8a | ||
|
|
50989a7bd6 | ||
|
|
0faf2bea50 | ||
|
|
b968730cc4 | ||
|
|
f87f2c43d0 | ||
|
|
bbc8c418de | ||
|
|
fd20968bc8 | ||
|
|
96c2a293a4 | ||
|
|
da136415da | ||
|
|
e47f05e6be | ||
|
|
3e2339fe96 | ||
|
|
52531a8be4 | ||
|
|
7ff785cb5a | ||
|
|
775e85b73a | ||
|
|
c1b5fd3fc0 | ||
|
|
e9d01c917b | ||
|
|
0d59de637e | ||
|
|
917aa838cb | ||
|
|
8e9750226d | ||
|
|
78993dcb4d | ||
|
|
c36ea7a0fb | ||
|
|
1153de26fc | ||
|
|
e14ec03945 | ||
|
|
990ebee79b | ||
|
|
d44cd198c4 | ||
|
|
7ae30212b9 | ||
|
|
94355a6311 | ||
|
|
4a7875c71d | ||
|
|
d9988f246a | ||
|
|
4186c9a053 | ||
|
|
07c16dca58 | ||
|
|
cd82cdcd1b | ||
|
|
a6927489a8 | ||
|
|
323f2617d8 | ||
|
|
53cfd8b035 | ||
|
|
f30db7713d | ||
|
|
567b06ac48 | ||
|
|
a543ecff6f | ||
|
|
4888427a1e | ||
|
|
9f55f0cc79 | ||
|
|
4022298446 | ||
|
|
6372bc7c63 | ||
|
|
d4d99b7e18 | ||
|
|
ebd43a8140 | ||
|
|
72df65113a |
+47
-4
@@ -2783,14 +2783,15 @@ rulings of 2026-10-28):
|
||||
504: "The server did not confirm the change. The Library was reloaded: check the shelf and
|
||||
Show archived prompts."; other refusals: "The selected prompts could not be changed. Try
|
||||
again.", the selection kept.
|
||||
- **Show archived prompts (N)** (heading actions, before Export, `aria-pressed`; N the
|
||||
- **Show archived prompts (N)** (heading actions, before Export, a plain button; N the
|
||||
archived prompts matching the filters, `archived_total`; the shelf counterpart of #235's
|
||||
Show archived) lists only archived prompts (`GET /prompt_manager/library?shelf=archived`,
|
||||
a cursor never crosses shelves), each card with an "Archived" `.chip`, the caption "N
|
||||
archived prompts · M negative variants hidden with them", the bar "N selected", Restore,
|
||||
Clear, and "No archived prompts." / "No archived prompts match." when empty. The pressed
|
||||
state stays with the shelf visit, never in a saved view; Browse and a saved view open the
|
||||
shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST
|
||||
Clear, and "No archived prompts." / "No archived prompts match." when empty. On the archived
|
||||
shelf the button reads the concept's own way back, "← Prompt library" (app.js 594; #482), with
|
||||
no `aria-pressed` (its label names where it goes). The shelf stays with the shelf visit, never
|
||||
in a saved view; Browse and a saved view open the active shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST
|
||||
/prompt_manager/library/restore` (every requested archived id, protected or not): "Restored
|
||||
N prompts to the Prompt library."
|
||||
- An archived prompt's page shows the concept's `.notice` ("Archived" / "This prompt is archived
|
||||
@@ -2818,6 +2819,48 @@ failed backup, busy, unconfirmed, restore fence); `tests/browser/library-delete-
|
||||
`tests/test_prompt_archive_schema.py`, `tests/test_bulk_library_delete.py`,
|
||||
`tests/test_library_delete_routes.py`. A Python change: ComfyUI needs a restart.
|
||||
|
||||
### Deleting a prompt in a saved pair permanently (#482)
|
||||
|
||||
**D160** (beyond legacy, maintainer ruling 2026-10-29; follow-up to D152): when the Delete… selection
|
||||
holds prompts in a saved pair that no other positive uses as its negative ("deletable"), the review
|
||||
asks, in a D90 radio row "Prompts in a saved pair" after the grid: **Archive (hidden, restorable)**
|
||||
(checked on every open, never remembered, so the review is D152's) or **Delete permanently, with
|
||||
their saved pairs**. Choosing it rewrites the review in place (`openReviewDialog` `setRows`,
|
||||
`setPrimary`):
|
||||
- Delete permanently "D prompts: X not in a saved pair, Y in a saved pair" (or "Y prompts in a saved
|
||||
pair"), Saved pairs removed "S saved pairs (V with a negative prompt, A archived)", Negatives kept
|
||||
"K negative prompts are kept", Archive "R prompts used as another prompt's negative: hidden,
|
||||
restorable", then D152's protected, already archived, no longer exist, Image links removed (images
|
||||
of every deleted prompt) and Backup rows.
|
||||
- Sentences: the pairs go with their titles, categories, ratings, notes, tags, run counts and image
|
||||
links; their negative prompts stay; recorded runs stay in History and Activity by their text; D152's
|
||||
irreversibility (per backup policy) and re-creation sentences, plus "A pair node run with the same
|
||||
text saves a new pair."; and, when R > 0, why those are archived.
|
||||
- Primary (`danger`): "Delete D prompts" / "Delete D and archive R prompts"; a short disk keeps it
|
||||
unavailable (`aria-disabled`) with the disk sentence, while Archive still starts.
|
||||
|
||||
The preview adds `deletable` (a subset of `archive`, which keeps every archive id for D152 clients),
|
||||
`review_digests` (sha256 of each deletable prompt's row and its pairs: negative, archived flag,
|
||||
metadata, usage, tags, image-pair links), `paired_counts` and `paired_image_links`, and plans the
|
||||
disk whenever anything could be deleted. The write takes optional `delete_paired_ids` with their
|
||||
`paired_digests` (both or neither; ids must be reviewed archive ids). In one transaction, after the
|
||||
archives, each chosen prompt whose digest is unchanged loses its image-pair links, then its own saved
|
||||
pairs (pair tags cascade; review decisions keep their rows with `pair_id` cleared), then itself exactly
|
||||
as D152 deletes. Any drift (pair added or edited, new link, pairs gone, now another prompt's negative)
|
||||
archives it and counts it `changed`. A paired delete is backed up and disk-checked as a delete. The
|
||||
toast adds "Removed S saved pairs."; "N changed since the review and was not deleted." replaces "…was
|
||||
kept.". Negatives are never deleted: one no remaining pair uses is listed in the Library afterwards (as
|
||||
after the legacy pair delete). Legacy still refuses paired prompts (409).
|
||||
|
||||
Tests: `tests/test_bulk_library_delete.py` `TestPairedDelete` (classes and counts, the 7896 empty
|
||||
negative, the row diff against the legacy pair deletes then prompt delete, archive-only, a positive
|
||||
with its own negative, a self-pair, each drift, validation); `tests/test_library_delete_routes.py`
|
||||
`TestPairedDeleteRoutes` (preview, paired-only backup, a D152 body, validation, short disk); units
|
||||
`tests/js/workspace-library-delete.test.js`, `tests/js/workspace-toolbar.test.js` (`setRows`,
|
||||
`setPrimary`); browser `tests/browser/workspace-library-delete.spec.mjs` (the choice, its grid and
|
||||
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
|
||||
change: ComfyUI needs a restart.
|
||||
|
||||
### No 1–5 prompt rating in the workspace (#447)
|
||||
|
||||
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
|
||||
|
||||
+155
-7
@@ -13,6 +13,9 @@ and delete lists, and deletes only reviewed deletes the route backed up
|
||||
(``candidates``) whose fingerprint is unchanged since that backup.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Iterable
|
||||
|
||||
@@ -39,8 +42,15 @@ _PAIRED = (
|
||||
"EXISTS(SELECT 1 FROM prompt_pairs pp "
|
||||
"WHERE pp.positive_prompt_id=p.id OR pp.negative_prompt_id=p.id)"
|
||||
)
|
||||
# #482: used as the negative of another positive's pair (archived or not):
|
||||
# never deleted, so that positive's variant stays (archive only).
|
||||
_NEGATIVE_OF_OTHER = (
|
||||
"EXISTS(SELECT 1 FROM prompt_pairs op "
|
||||
"WHERE op.negative_prompt_id=p.id AND op.positive_prompt_id!=p.id)"
|
||||
)
|
||||
# Classes in the order a prompt is tested; the first that holds wins.
|
||||
CLASSES = ("not_found", "protected", "already_archived", "archive", "delete")
|
||||
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _batches(ids: list) -> Iterable[list]:
|
||||
@@ -78,24 +88,31 @@ class LibraryArchiveOperations:
|
||||
def _classify_library_delete(
|
||||
self, conn: sqlite3.Connection, prompt_ids: list[int]
|
||||
) -> dict:
|
||||
found = {}
|
||||
found, deletable = {}, set()
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for row in conn.execute(
|
||||
f"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED} " # nosec B608
|
||||
f"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED}," # nosec B608
|
||||
f"{_NEGATIVE_OF_OTHER} "
|
||||
f"FROM prompts p WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
|
||||
chunk,
|
||||
):
|
||||
protected, archived, paired = row[1], row[2], row[3]
|
||||
protected, archived, paired, negative_of_other = row[1:5]
|
||||
if protected:
|
||||
found[row[0]] = "protected"
|
||||
elif archived:
|
||||
found[row[0]] = "already_archived"
|
||||
elif paired:
|
||||
found[row[0]] = "archive"
|
||||
if not negative_of_other:
|
||||
deletable.add(row[0])
|
||||
else:
|
||||
found[row[0]] = "archive" if paired else "delete"
|
||||
found[row[0]] = "delete"
|
||||
classes = {name: [] for name in CLASSES}
|
||||
for pid in prompt_ids:
|
||||
classes[found.get(pid, "not_found")].append(pid)
|
||||
# #482: the archive ids Delete permanently may delete with their pairs.
|
||||
classes["deletable"] = [pid for pid in classes["archive"] if pid in deletable]
|
||||
return classes
|
||||
|
||||
@staticmethod
|
||||
@@ -137,6 +154,82 @@ class LibraryArchiveOperations:
|
||||
]
|
||||
return prints
|
||||
|
||||
@staticmethod
|
||||
def _distinct_images(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
|
||||
"""Image rows of ``prompt_ids`` (each image once; #482 deletes them)."""
|
||||
return LibraryArchiveOperations._image_links(conn, prompt_ids)
|
||||
|
||||
@staticmethod
|
||||
def _pair_prints(conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
|
||||
"""#482: each prompt's own saved pairs, as the review saw them.
|
||||
|
||||
Per pair (by id): its negative, archived flag, metadata, usage, ordered
|
||||
tag ids and its image-pair links (count and newest id).
|
||||
"""
|
||||
prints = {pid: [] for pid in prompt_ids}
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for row in conn.execute(
|
||||
"SELECT pp.positive_prompt_id,pp.id,pp.negative_prompt_id," # nosec
|
||||
"pp.archived,pp.title,pp.category,pp.rating,pp.notes,pp.run_count,"
|
||||
"pp.last_used_at,pp.updated_at,"
|
||||
"(SELECT group_concat(tag_id) FROM (SELECT tag_id FROM "
|
||||
"prompt_pair_tags WHERE pair_id=pp.id ORDER BY tag_id)),"
|
||||
"(SELECT COUNT(*) FROM image_prompt_pairs WHERE pair_id=pp.id),"
|
||||
"(SELECT MAX(id) FROM image_prompt_pairs WHERE pair_id=pp.id) "
|
||||
f"FROM prompt_pairs pp WHERE pp.positive_prompt_id IN ({marks}) "
|
||||
"ORDER BY pp.id",
|
||||
chunk,
|
||||
):
|
||||
prints[row[0]].append(
|
||||
[
|
||||
v if isinstance(v, (int, str, type(None))) else repr(v)
|
||||
for v in tuple(row)[1:]
|
||||
]
|
||||
)
|
||||
return prints
|
||||
|
||||
@classmethod
|
||||
def _review_digests(cls, conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
|
||||
"""#482: one digest per deletable prompt of its row and its pairs."""
|
||||
prompts = cls._fingerprints(conn, prompt_ids)
|
||||
pairs = cls._pair_prints(conn, prompt_ids)
|
||||
return {
|
||||
pid: hashlib.sha256(
|
||||
json.dumps(
|
||||
[prompts.get(pid), pairs[pid]], separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
for pid in prompt_ids
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _paired_counts(
|
||||
conn: sqlite3.Connection, prompt_ids: list[int], selected: list[int]
|
||||
) -> dict:
|
||||
"""#482: what Delete permanently would remove with ``prompt_ids``."""
|
||||
pairs = archived = variants = 0
|
||||
negatives = set()
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
for archived_flag, negative, positive in conn.execute(
|
||||
"SELECT archived,negative_prompt_id,positive_prompt_id " # nosec
|
||||
f"FROM prompt_pairs WHERE positive_prompt_id IN ({marks})",
|
||||
chunk,
|
||||
):
|
||||
pairs += 1
|
||||
archived += 1 if archived_flag else 0
|
||||
if negative is not None:
|
||||
variants += 1
|
||||
if negative != positive:
|
||||
negatives.add(negative)
|
||||
return {
|
||||
"pairs": pairs,
|
||||
"archived_pairs": archived,
|
||||
"variants": variants,
|
||||
"kept_negatives": len(negatives - set(selected)),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _checked_ids(prompt_ids: list[int], expected_epoch: str) -> list[int]:
|
||||
_validate_epoch(expected_epoch)
|
||||
@@ -156,11 +249,16 @@ class LibraryArchiveOperations:
|
||||
with self.library_snapshot() as (conn, _epoch, _ceiling):
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
classes = self._classify_library_delete(conn, prompt_ids)
|
||||
deletable = classes["deletable"]
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
**classes,
|
||||
"image_links": self._image_links(conn, classes["delete"]),
|
||||
"fingerprints": self._fingerprints(conn, classes["delete"]),
|
||||
# #482: Delete permanently, with the deletable prompts' pairs.
|
||||
"review_digests": self._review_digests(conn, deletable),
|
||||
"paired_counts": self._paired_counts(conn, deletable, prompt_ids),
|
||||
"paired_image_links": self._distinct_images(conn, deletable),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -191,6 +289,7 @@ class LibraryArchiveOperations:
|
||||
*,
|
||||
expected_epoch: str,
|
||||
candidates: dict | None,
|
||||
delete_paired: dict | None = None,
|
||||
) -> dict:
|
||||
"""Archive and delete what the user reviewed, all or nothing.
|
||||
|
||||
@@ -201,6 +300,11 @@ class LibraryArchiveOperations:
|
||||
still classifies ``delete`` and its fingerprint is unchanged; one that
|
||||
is now paired, and a reviewed archive now unpaired, are archived (the
|
||||
restorable direction); anything else is kept and counted.
|
||||
|
||||
``delete_paired`` (#482) maps reviewed archive ids the user chose to
|
||||
Delete permanently to their review digest. Each is deleted with its
|
||||
own saved pairs only while it is still deletable and its digest is
|
||||
unchanged; otherwise it is archived and counted ``changed``.
|
||||
"""
|
||||
_validate_epoch(expected_epoch)
|
||||
for ids in (archive_ids, delete_ids):
|
||||
@@ -209,6 +313,16 @@ class LibraryArchiveOperations:
|
||||
):
|
||||
raise ValueError("prompt ids must be lists of positive integers")
|
||||
reviewed_delete = set(delete_ids)
|
||||
delete_paired = {} if delete_paired is None else delete_paired
|
||||
if not isinstance(delete_paired, dict) or any(
|
||||
type(pid) is not int
|
||||
or not isinstance(digest, str)
|
||||
or not _DIGEST.match(digest)
|
||||
for pid, digest in delete_paired.items()
|
||||
):
|
||||
raise ValueError("delete_paired must map prompt ids to review digests")
|
||||
if not set(delete_paired) <= set(archive_ids):
|
||||
raise ValueError("A permanent delete must be a reviewed archive id")
|
||||
if not archive_ids and not delete_ids:
|
||||
raise ValueError("Nothing to delete or archive")
|
||||
if reviewed_delete & set(archive_ids):
|
||||
@@ -218,16 +332,31 @@ class LibraryArchiveOperations:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
classes = self._classify_library_delete(conn, prompt_ids)
|
||||
now_prints = self._fingerprints(conn, classes["delete"])
|
||||
deletes, archives, changed = [], list(classes["archive"]), 0
|
||||
deletes, archives, changed = [], [], 0
|
||||
paired_now = set(classes["deletable"])
|
||||
chosen = [pid for pid in classes["archive"] if pid in delete_paired]
|
||||
digests = self._review_digests(
|
||||
conn, [pid for pid in chosen if pid in paired_now]
|
||||
)
|
||||
paired_deletes = []
|
||||
for pid in classes["archive"]:
|
||||
if pid not in delete_paired:
|
||||
archives.append(pid)
|
||||
elif digests.get(pid) == delete_paired[pid]:
|
||||
paired_deletes.append(pid)
|
||||
else:
|
||||
archives.append(pid) # changed since the review: restorable
|
||||
changed += 1
|
||||
for pid in classes["delete"]:
|
||||
if pid not in reviewed_delete:
|
||||
archives.append(pid) # reviewed as archive, now unpaired
|
||||
changed += 1 if pid in delete_paired else 0
|
||||
elif candidates is None or candidates.get(pid) == now_prints[pid]:
|
||||
deletes.append(pid)
|
||||
else:
|
||||
changed += 1
|
||||
changed += len(classes["protected"])
|
||||
image_links = self._image_links(conn, deletes)
|
||||
image_links = self._image_links(conn, [*deletes, *paired_deletes])
|
||||
now = utc_now_iso()
|
||||
for pid in archives:
|
||||
self._repair_hash(conn, pid)
|
||||
@@ -240,7 +369,25 @@ class LibraryArchiveOperations:
|
||||
"UPDATE prompts SET updated_at=? WHERE id=?",
|
||||
[(now, pid) for pid in archives],
|
||||
)
|
||||
deleted = delete_prompt_rows(conn, deletes)
|
||||
# #482: the image-pair links (RESTRICT) go first, then the pairs
|
||||
# (pair tags CASCADE, review decisions SET NULL), then the prompts.
|
||||
pairs_removed = 0
|
||||
for chunk in _batches(paired_deletes):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
pairs_of = (
|
||||
"SELECT id FROM prompt_pairs " # nosec B608
|
||||
f"WHERE positive_prompt_id IN ({marks})"
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM image_prompt_pairs WHERE pair_id IN ({pairs_of})",
|
||||
chunk,
|
||||
)
|
||||
pairs_removed += conn.execute(
|
||||
"DELETE FROM prompt_pairs " # nosec B608
|
||||
f"WHERE positive_prompt_id IN ({marks})",
|
||||
chunk,
|
||||
).rowcount
|
||||
deleted = delete_prompt_rows(conn, [*deletes, *paired_deletes])
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
"deleted": deleted,
|
||||
@@ -249,6 +396,7 @@ class LibraryArchiveOperations:
|
||||
"not_found": len(classes["not_found"]),
|
||||
"changed": changed,
|
||||
"image_links": image_links,
|
||||
"pairs_removed": pairs_removed,
|
||||
}
|
||||
|
||||
def restore_library_prompts(
|
||||
|
||||
+44
-10
@@ -47,6 +47,8 @@ RESTORE_URL = "/prompt_manager/library/restore"
|
||||
DELETE_TITLE = "Delete prompts (Library)"
|
||||
_SELECTION_KEYS = frozenset({"prompt_ids", "expected_epoch"})
|
||||
_DELETE_KEYS = frozenset({"archive_ids", "delete_ids", "expected_epoch", "backup"})
|
||||
# #482: Delete permanently, with its digests; both or neither (a #443 body).
|
||||
_PAIRED_KEYS = frozenset({"delete_paired_ids", "paired_digests"})
|
||||
STORAGE_CHANGED = "Prompt storage was restored; refresh the library"
|
||||
|
||||
|
||||
@@ -64,19 +66,39 @@ def _storage_changed(exc):
|
||||
return _failure(409, exc.code, STORAGE_CHANGED, library_epoch=exc.epoch)
|
||||
|
||||
|
||||
async def _body(request, keys):
|
||||
"""The JSON object with exactly ``keys``, or raise ValueError."""
|
||||
async def _body(request, keys, optional=frozenset()):
|
||||
"""The JSON object with exactly ``keys`` (plus all or none of ``optional``)."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
raise ValueError("Expected a JSON object") from None
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Expected a JSON object")
|
||||
if set(data) != keys:
|
||||
extra = set(data) - keys
|
||||
if not keys <= set(data) or (extra and extra != optional):
|
||||
raise ValueError("Request must contain exactly " + ", ".join(sorted(keys)))
|
||||
return data
|
||||
|
||||
|
||||
def _paired_delete(data, archive_ids):
|
||||
"""#482: the reviewed archive ids to delete with their pairs -> digest."""
|
||||
if "delete_paired_ids" not in data:
|
||||
return {}
|
||||
ids = _reviewed_ids(data["delete_paired_ids"], "delete_paired_ids")
|
||||
digests = data["paired_digests"]
|
||||
if not isinstance(digests, dict):
|
||||
raise ValueError("paired_digests must map prompt ids to digests")
|
||||
if not set(ids) <= set(archive_ids):
|
||||
raise ValueError("delete_paired_ids must be reviewed archive ids")
|
||||
chosen = {}
|
||||
for pid in ids:
|
||||
digest = digests.get(str(pid))
|
||||
if not isinstance(digest, str) or len(digest) != 64:
|
||||
raise ValueError("Every delete_paired_ids entry needs its review digest")
|
||||
chosen[pid] = digest
|
||||
return chosen
|
||||
|
||||
|
||||
def _reviewed_ids(value, name):
|
||||
"""A reviewed id list: possibly empty, else as ``parse_bulk_prompt_ids``."""
|
||||
if value == []:
|
||||
@@ -111,7 +133,7 @@ class LibraryDeleteRoutesMixin:
|
||||
expected_epoch=data["expected_epoch"],
|
||||
)
|
||||
disk = None
|
||||
if review["delete"] and policy == "on":
|
||||
if (review["delete"] or review["deletable"]) and policy == "on":
|
||||
public, short, _size = await self._run_in_executor(
|
||||
_maintenance.disk_plan,
|
||||
db.model.db_path,
|
||||
@@ -145,6 +167,13 @@ class LibraryDeleteRoutesMixin:
|
||||
"already_archived": len(review["already_archived"]),
|
||||
"not_found": len(review["not_found"]),
|
||||
"image_links": review["image_links"],
|
||||
# #482: the archive ids Delete permanently may delete with pairs.
|
||||
"deletable": review["deletable"],
|
||||
"review_digests": {
|
||||
str(pid): digest for pid, digest in review["review_digests"].items()
|
||||
},
|
||||
"paired_counts": review["paired_counts"],
|
||||
"paired_image_links": review["paired_image_links"],
|
||||
"backup": policy,
|
||||
"disk": disk,
|
||||
}
|
||||
@@ -152,7 +181,7 @@ class LibraryDeleteRoutesMixin:
|
||||
|
||||
async def delete_library_prompts(self, request):
|
||||
try:
|
||||
data = await _body(request, _DELETE_KEYS)
|
||||
data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS)
|
||||
archive_ids = _reviewed_ids(data["archive_ids"], "archive_ids")
|
||||
delete_ids = _reviewed_ids(data["delete_ids"], "delete_ids")
|
||||
if not archive_ids and not delete_ids:
|
||||
@@ -163,6 +192,7 @@ class LibraryDeleteRoutesMixin:
|
||||
raise ValueError("A prompt cannot be both archived and deleted")
|
||||
if data["backup"] not in ("on", "off"):
|
||||
raise ValueError("backup must be on or off")
|
||||
paired = _paired_delete(data, archive_ids)
|
||||
except ValueError as exc:
|
||||
return _invalid(str(exc))
|
||||
busy = self._jobs.blocker(PROMPT_DELETE_JOB_KIND, admission="records")
|
||||
@@ -173,7 +203,7 @@ class LibraryDeleteRoutesMixin:
|
||||
PROMPT_DELETE_JOB_KIND, DELETE_TITLE, admission="records"
|
||||
) as job:
|
||||
response = await self._held_library_delete(
|
||||
self.db, data, archive_ids, delete_ids
|
||||
self.db, data, archive_ids, delete_ids, paired
|
||||
)
|
||||
if response.status >= 400:
|
||||
job.finish("failed")
|
||||
@@ -181,18 +211,21 @@ class LibraryDeleteRoutesMixin:
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
|
||||
async def _held_library_delete(self, db, data, archive_ids, delete_ids):
|
||||
async def _held_library_delete(self, db, data, archive_ids, delete_ids, paired):
|
||||
epoch = data["expected_epoch"]
|
||||
promised = data["backup"] == "on" or self._safety_backup_policy() == "on"
|
||||
backup = None
|
||||
try:
|
||||
candidates = None
|
||||
if delete_ids:
|
||||
if delete_ids or paired:
|
||||
review = await self._run_in_executor(
|
||||
db.classify_library_delete, delete_ids, expected_epoch=epoch
|
||||
db.classify_library_delete,
|
||||
[*delete_ids, *paired],
|
||||
expected_epoch=epoch,
|
||||
)
|
||||
candidates = review["fingerprints"]
|
||||
if candidates and promised:
|
||||
# #482: a permanent delete of a prompt in a saved pair is backed up too.
|
||||
if (candidates or review["deletable"]) and promised:
|
||||
_disk, short, _size = await self._run_in_executor(
|
||||
_maintenance.disk_plan,
|
||||
db.model.db_path,
|
||||
@@ -214,6 +247,7 @@ class LibraryDeleteRoutesMixin:
|
||||
delete_ids,
|
||||
expected_epoch=epoch,
|
||||
candidates=candidates if candidates is not None else {},
|
||||
delete_paired=paired,
|
||||
)
|
||||
except LibraryCommandError as exc:
|
||||
return _storage_changed(exc)
|
||||
|
||||
@@ -88,7 +88,8 @@ function mapDeleteConcept({ danger, review }) {
|
||||
return {
|
||||
...review,
|
||||
title: 'Delete or archive selected prompts',
|
||||
tree: ['dl.meta-grid', 'p.small', 'p.small', 'div.dialog-actions'],
|
||||
// #482: the paired prompt is deletable, so the review asks Archive or Delete permanently (a D90 radio row).
|
||||
tree: ['dl.meta-grid', 'div.field', 'p.small', 'p.small', 'div.dialog-actions'],
|
||||
grid: { ...review.grid, rows: [['Delete permanently', '1 prompt not in a saved pair'],
|
||||
['Archive', '1 prompt in a saved pair: hidden, restorable'],
|
||||
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
|
||||
|
||||
@@ -13,6 +13,8 @@ const bar=page=>page.locator('#library-bulkbar > .bulkbar');
|
||||
const toast=page=>page.locator('.toast');
|
||||
const review=page=>page.getByRole('dialog');
|
||||
const showArchived=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:/^Show archived prompts/});
|
||||
// #482: on the archived shelf the same button reads the concept's way back (app.js 594 "← Prompt library").
|
||||
const backToLibrary=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'← Prompt library',exact:true});
|
||||
|
||||
async function items(request,shelf='active') {
|
||||
const query=shelf==='archived'?'&shelf=archived':'';
|
||||
@@ -83,9 +85,11 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
|
||||
await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Archived 2 prompts.');
|
||||
await expect(cards(page)).toHaveCount(before.length-2);
|
||||
await expect(showArchived(page)).toHaveAttribute('aria-pressed','false');
|
||||
// #482: a plain button that names where it goes (no pressed state that its changing label would contradict).
|
||||
await expect(showArchived(page)).not.toHaveAttribute('aria-pressed');
|
||||
await showArchived(page).click();
|
||||
await expect(showArchived(page)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(backToLibrary(page)).toBeVisible();await expect(backToLibrary(page)).not.toHaveAttribute('aria-pressed');
|
||||
await expect(showArchived(page)).toHaveCount(0);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(page.locator('.section-caption > span').first()).toHaveText(/^2 archived prompts · \d+ negative variants? hidden with them$/);
|
||||
await expect(card(page,cat.id).locator('.card-title .chip',{hasText:'Archived'})).toHaveCount(1);
|
||||
@@ -94,7 +98,8 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
|
||||
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await showArchived(page).click();
|
||||
await backToLibrary(page).click();
|
||||
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
|
||||
await expect(cards(page)).toHaveCount(before.length-1);
|
||||
await expect(card(page,cat.id)).toHaveCount(1);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
|
||||
@@ -104,6 +109,72 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
|
||||
await expect(page.locator('.library-status')).toHaveText('No archived prompts.');
|
||||
});
|
||||
|
||||
// #482 (D154): a prompt in a saved pair can be deleted permanently, with its own saved pairs; Archive stays the default.
|
||||
const choice=page=>review(page).getByRole('radiogroup',{name:'Prompts in a saved pair'});
|
||||
const row=(page,term)=>review(page).locator('dl.meta-grid > div').filter({has:page.locator('dt',{hasText:new RegExp(`^${term}$`)})}).locator('dd');
|
||||
test('Delete permanently removes a paired prompt with its own saved pair; its negative stays (#482)',async({page,request})=>{
|
||||
const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait');
|
||||
const writes=recordPosts(page,DELETE);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,dual.id).check();await openReview(page);
|
||||
// Archive is checked: the review is #443's.
|
||||
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
|
||||
// The D90 radio row (the auto-tag "How to apply"): each radio keeps its own size beside its label.
|
||||
expect(await choice(page).evaluate(group=>{const label=group.querySelector('label'),radio=label.querySelector('input');
|
||||
return {group:group.className,display:getComputedStyle(label).display,gap:getComputedStyle(label).columnGap,
|
||||
margin:getComputedStyle(radio).marginLeft,full:radio.getBoundingClientRect().width>=label.getBoundingClientRect().width/2};}))
|
||||
.toEqual({group:'field',display:'flex',gap:'8px',margin:'0px',full:false});
|
||||
await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible();
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
|
||||
await expect(row(page,'Delete permanently')).toHaveText('1 prompt in a saved pair');
|
||||
await expect(row(page,'Saved pairs removed')).toHaveText('1 saved pair (1 with a negative prompt)');
|
||||
await expect(row(page,'Negatives kept')).toHaveText('1 negative prompt is kept');
|
||||
await expect(row(page,'Backup')).toHaveText('Create and verify before deleting');
|
||||
await expect(review(page).locator('p.small').first()).toHaveText(/^Deleting a prompt in a saved pair also removes its saved pairs/);
|
||||
// Back to Archive: the review is #443's again.
|
||||
await choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'}).check();
|
||||
await expect(row(page,'Saved pairs removed')).toHaveCount(0);
|
||||
await expect(review(page).getByRole('button',{name:'Archive 1 prompt',exact:true})).toBeVisible();
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /);
|
||||
await expect(card(page,dual.id)).toHaveCount(0);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0].delete_paired_ids).toEqual([dual.id]);
|
||||
expect(Object.keys(writes[0].paired_digests)).toEqual([String(dual.id)]);
|
||||
const after=await items(request);
|
||||
expect(byText(after,'Dual role portrait')).toBeUndefined();
|
||||
// Its negative (Cat portrait) is kept, with its own variants.
|
||||
expect(byText(after,'Cat portrait')?.id).toBe(cat.id);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a prompt used as another prompt\'s negative is archived even when Delete permanently is chosen (#482)',async({page,request})=>{
|
||||
const before=await items(request);const dual=byText(before,'Dual role portrait'),cat=byText(before,'Cat portrait');
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,cat.id).check();await box(page,dual.id).check();await openReview(page);
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await expect(row(page,'Archive')).toHaveText('1 prompt used as another prompt\'s negative: hidden, restorable');
|
||||
await expect(review(page).locator('p.small',{hasText:'archived instead of deleted'})).toHaveCount(1);
|
||||
await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Archived 1 prompt\./);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]);
|
||||
});
|
||||
|
||||
test('the choice resets to Archive on every Delete…, and a selection with no paired prompt has none (#482)',async({page,request})=>{
|
||||
const before=await items(request);const dual=byText(before,'Dual role portrait'),dog=byText(before,'Dog landscape');
|
||||
await page.goto(route);await box(page,dual.id).check();await openReview(page);
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await openReview(page);
|
||||
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
|
||||
await box(page,dog.id).check();await openReview(page);
|
||||
await expect(choice(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an archived prompt\'s page says so and restores it from its notice',async({page,request})=>{
|
||||
const cat=byText(await items(request),'Cat portrait');
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
|
||||
@@ -493,9 +493,10 @@ test('a restore seen finishing keeps New prompt open and reloads the Library aft
|
||||
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
||||
const actions=headingActions(page).locator('button');
|
||||
// D152 (#443): Show archived prompts (N) comes first, a plain toggle.
|
||||
// D152 (#443): Show archived prompts (N) comes first, a plain button (#482: no pressed state; on the archived shelf it
|
||||
// reads "← Prompt library").
|
||||
expect(await visibleNames(actions)).toEqual(['Show archived prompts (0)','Export','+ New prompt']);
|
||||
await expect(actions.nth(0)).toHaveAttribute('aria-pressed','false');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
await expect(actions.nth(0)).not.toHaveAttribute('aria-pressed');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
// One image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1);
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(1)).not.toHaveClass(/ca-primary/);
|
||||
|
||||
@@ -91,10 +91,10 @@ test('a delete outcome counts each part, singular and plural, and names the back
|
||||
toast: 'Deleted 2 prompts. Archived 3 prompts. 1 protected prompt was kept. Safety backup saved: prompts.db.backup_x (System › Backup & export).'});
|
||||
assert.equal(deleteOutcome({success: true, deleted: 1, archived: 0, already_archived: 1, not_found: 1, changed: 1,
|
||||
backup: null}, null, reviewOf(preview())).toast,
|
||||
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was kept.');
|
||||
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was not deleted.');
|
||||
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0, already_archived: 2, not_found: 3, changed: 2,
|
||||
backup: null}, null, reviewOf(preview({protected: [1, 2]}))).toast,
|
||||
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were kept. 2 protected prompts were kept. The Library was reloaded.');
|
||||
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were not deleted. 2 protected prompts were kept. The Library was reloaded.');
|
||||
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0}, null, reviewOf(preview())).committed, false);
|
||||
});
|
||||
|
||||
@@ -134,7 +134,8 @@ test('reviewOf keeps only well-formed ids and counts from an answer', async () =
|
||||
const review = reviewOf({library_epoch: 3, delete: [1, 'x', -2, 2], archive: null, protected: [1.5], already_archived: -1,
|
||||
not_found: 'x', image_links: 2, backup: 'maybe'});
|
||||
assert.deepEqual(review, {epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
|
||||
imageLinks: 2, backup: 'on', disk: null});
|
||||
imageLinks: 2, backup: 'on', disk: null, deletable: [], digests: {},
|
||||
pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive'});
|
||||
});
|
||||
|
||||
test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => {
|
||||
@@ -148,3 +149,83 @@ test('the archived notice says why (the saved pair) and names negative variants
|
||||
// An unknown count (an older server) never claims variants.
|
||||
assert.equal(archivedNoticeText(undefined), archivedNoticeText(0));
|
||||
});
|
||||
|
||||
// #482 (D160): prompts in a saved pair can be deleted permanently, with their own saved pairs.
|
||||
const D1 = 'a'.repeat(64), D2 = 'b'.repeat(64);
|
||||
const paired = (patch = {}) => preview({archive: [3, 4, 5], deletable: [3, 4], review_digests: {3: D1, 4: D2},
|
||||
paired_counts: {pairs: 3, archived_pairs: 1, variants: 2, kept_negatives: 2}, paired_image_links: 4, ...patch});
|
||||
|
||||
test('reviewOf reads the deletable ids, their digests and the pair counts; the choice starts at Archive', async () => {
|
||||
const {reviewOf, hasPairedChoice} = await source;
|
||||
const review = reviewOf(paired({deletable: [3, 4, 99], review_digests: {3: D1, 4: 'short', 99: D1}}));
|
||||
// Only reviewed archive ids with a well-formed digest can be deleted permanently.
|
||||
assert.deepEqual(review.deletable, [3]);
|
||||
assert.deepEqual(review.digests, {3: D1});
|
||||
assert.deepEqual(review.pairedCounts, {pairs: 3, archivedPairs: 1, variants: 2, keptNegatives: 2});
|
||||
assert.equal(review.pairedImageLinks, 4);
|
||||
assert.equal(review.choice, 'archive');
|
||||
assert.equal(hasPairedChoice(review), true);
|
||||
assert.equal(hasPairedChoice(reviewOf(preview({archive: [5]}))), false);
|
||||
});
|
||||
|
||||
test('Archive chosen keeps the #443 review; Delete permanently names the pairs, the kept negatives and archive-only', async () => {
|
||||
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel, deleteBody} = await source;
|
||||
const review = reviewOf(paired({delete: [1], image_links: 2}));
|
||||
assert.deepEqual(reviewGrid(review).map(([term]) => term), ['Delete permanently', 'Archive', 'Image links removed', 'Backup']);
|
||||
assert.equal(primaryLabel(review), 'Delete 1 and archive 3 prompts');
|
||||
assert.deepEqual(deleteBody(review), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on'});
|
||||
const permanent = withChoice(review, 'delete');
|
||||
assert.notEqual(permanent, review);
|
||||
assert.equal(review.choice, 'archive');
|
||||
assert.deepEqual(reviewGrid(permanent), [
|
||||
['Delete permanently', '3 prompts: 1 not in a saved pair, 2 in a saved pair'],
|
||||
['Saved pairs removed', '3 saved pairs (2 with a negative prompt, 1 archived)'],
|
||||
['Negatives kept', '2 negative prompts are kept'],
|
||||
['Archive', '1 prompt used as another prompt\'s negative: hidden, restorable'],
|
||||
['Image links removed', '6 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
assert.equal(reviewTitle(permanent), 'Delete or archive selected prompts');
|
||||
assert.equal(primaryLabel(permanent), 'Delete 3 and archive 1 prompt');
|
||||
assert.deepEqual(deleteBody(permanent), {archive_ids: [3, 4, 5], delete_ids: [1], expected_epoch: 'e', backup: 'on',
|
||||
delete_paired_ids: [3, 4], paired_digests: {3: D1, 4: D2}});
|
||||
});
|
||||
|
||||
test('edge counts: one prompt, no negative, nothing archive-only', async () => {
|
||||
const {reviewOf, withChoice, reviewGrid, reviewTitle, primaryLabel} = await source;
|
||||
// Live prompt 7896: one pair with an explicitly empty negative.
|
||||
const one = withChoice(reviewOf(preview({archive: [7], deletable: [7], review_digests: {7: D1},
|
||||
paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}, paired_image_links: 0})), 'delete');
|
||||
assert.deepEqual(reviewGrid(one), [
|
||||
['Delete permanently', '1 prompt in a saved pair'],
|
||||
['Saved pairs removed', '1 saved pair'],
|
||||
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
assert.equal(reviewTitle(one), 'Delete selected prompts');
|
||||
assert.equal(primaryLabel(one), 'Delete 1 prompt');
|
||||
});
|
||||
|
||||
test('Delete permanently sentences: what goes with the pairs, what stays, and how it comes back', async () => {
|
||||
const {reviewOf, withChoice, reviewSentences} = await source;
|
||||
const [pairs, remove, archive] = reviewSentences(withChoice(reviewOf(paired()), 'delete'));
|
||||
assert.equal(pairs, 'Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' +
|
||||
'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' +
|
||||
'and Activity, listed by their text.');
|
||||
assert.match(remove, /the only way back is to restore the safety backup/);
|
||||
assert.match(remove, /A pair node run with the same text saves a new pair\.$/);
|
||||
assert.equal(archive, 'Prompts used as another prompt\'s negative are archived instead of deleted, so that ' +
|
||||
'prompt\'s variant stays.');
|
||||
const [, off] = reviewSentences(withChoice(reviewOf(paired({backup: 'off'})), 'delete'));
|
||||
assert.match(off, /Deleted prompts cannot be brought back\./);
|
||||
});
|
||||
|
||||
test('a short disk blocks only the permanent delete; the outcome names removed pairs', async () => {
|
||||
const {reviewOf, withChoice, blockedReason, deleteOutcome} = await source;
|
||||
const short = reviewOf(paired({disk: {sufficient: false, message: 'Not enough space. Nothing was changed.'}}));
|
||||
assert.equal(blockedReason(short), null);
|
||||
assert.equal(blockedReason(withChoice(short, 'delete')), 'Not enough space. Nothing was changed.');
|
||||
const {toast} = deleteOutcome({success: true, deleted: 2, archived: 1, pairs_removed: 3, changed: 1}, null, short);
|
||||
assert.equal(toast, 'Deleted 2 prompts. Removed 3 saved pairs. Archived 1 prompt. 1 changed since the review and ' +
|
||||
'was not deleted.');
|
||||
});
|
||||
|
||||
@@ -859,6 +859,42 @@ test('backupNotice is the concept .notice with its copy and one Back up now acti
|
||||
}
|
||||
});
|
||||
|
||||
test('openReviewDialog replaces its rows and its primary in place (#482)', async () => {
|
||||
const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const started = [];
|
||||
const review = openReviewDialog({ scope, title: 'Delete or archive', grid: [['Archive', '2 prompts']],
|
||||
cancelLabel: 'Cancel', primary: { label: 'Archive 2 prompts', danger: true }, onStart: () => started.push('start') });
|
||||
review.setRows([['Delete permanently', '2 prompts'], ['Saved pairs removed', '3 saved pairs']]);
|
||||
const grid = byId(dom, 'modal-body').children[0];
|
||||
assert.deepEqual(grid.children.map(entry => [entry.children[0].textContent, entry.children[1].textContent]),
|
||||
[['Delete permanently', '2 prompts'], ['Saved pairs removed', '3 saved pairs']]);
|
||||
// setValue reaches the new rows only.
|
||||
review.setValue('Saved pairs removed', '4 saved pairs');
|
||||
review.setValue('Archive', 'x');
|
||||
assert.equal(grid.children[1].children[1].textContent, '4 saved pairs');
|
||||
const start = button(byId(dom, 'modal-body'), 'Archive 2 prompts');
|
||||
review.setPrimary({ label: 'Delete 2 prompts', disabled: true });
|
||||
assert.equal(start.textContent, 'Delete 2 prompts');
|
||||
assert.equal(start.getAttribute('aria-disabled'), 'true');
|
||||
start.click();
|
||||
assert.deepEqual(started, []);
|
||||
assert.equal(modal(dom).open, true);
|
||||
review.setPrimary({ label: 'Delete 2 prompts', disabled: false });
|
||||
assert.equal(start.getAttribute('aria-disabled'), 'false');
|
||||
start.click();
|
||||
assert.deepEqual(started, ['start']);
|
||||
assert.throws(() => review.setRows([['only a term']]), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
closeWorkspaceDialog();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('openReviewDialog lays out heading, grid, options, sentence, backup box and actions', async () => {
|
||||
const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
|
||||
@@ -62,6 +62,8 @@ def table_dump(db):
|
||||
|
||||
|
||||
class LibraryDeleteCase(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
# Cleanups run last-in first-out and after every cleanup a test adds,
|
||||
@@ -138,9 +140,23 @@ class TestClassifyAndApply(LibraryDeleteCase):
|
||||
"not_found": [negative_only, 99999],
|
||||
"image_links": 1,
|
||||
"fingerprints": result["fingerprints"],
|
||||
# #482: the archive ids Delete permanently could remove (with pairs).
|
||||
"deletable": [positive, archived_pair_positive],
|
||||
"review_digests": result["review_digests"],
|
||||
"paired_counts": {
|
||||
"pairs": 2,
|
||||
"archived_pairs": 1,
|
||||
"variants": 2,
|
||||
# "only negative" is itself selected; only "blur" is kept.
|
||||
"kept_negatives": 1,
|
||||
},
|
||||
"paired_image_links": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(list(result["fingerprints"]), [plain])
|
||||
self.assertEqual(
|
||||
list(result["review_digests"]), [positive, archived_pair_positive]
|
||||
)
|
||||
|
||||
def test_apply_archives_paired_deletes_unpaired_and_skips_protected(self):
|
||||
plain = save(self.db, "Plain", tags=["red"])
|
||||
@@ -160,6 +176,7 @@ class TestClassifyAndApply(LibraryDeleteCase):
|
||||
"not_found": 0,
|
||||
"changed": 0,
|
||||
"image_links": 1,
|
||||
"pairs_removed": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(prompt_ids(self.db), sorted([protected, positive, negative]))
|
||||
@@ -524,3 +541,188 @@ class TestArchivedReaders(LibraryDeleteCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestPairedDelete(LibraryDeleteCase):
|
||||
"""#482 (D160): Delete permanently also removes a prompt's own saved pairs."""
|
||||
|
||||
def pair_rows(self, positive):
|
||||
return rows(
|
||||
self.db,
|
||||
"SELECT id FROM prompt_pairs WHERE positive_prompt_id=? ORDER BY id",
|
||||
(positive,),
|
||||
)
|
||||
|
||||
def pair_link(self, pair, image):
|
||||
return self.db.link_image_to_pair(pair, image, {"resolver_version": 1})
|
||||
|
||||
def delete_paired(self, ids, review, archive=None, epoch=None):
|
||||
"""Write ``ids`` as Delete permanently, with the digests ``review`` gave."""
|
||||
archive = review["archive"] if archive is None else archive
|
||||
return self.db.archive_or_delete_library_prompts(
|
||||
archive,
|
||||
review["delete"],
|
||||
expected_epoch=epoch or self.epoch,
|
||||
candidates=review["fingerprints"],
|
||||
delete_paired={pid: review["review_digests"][pid] for pid in ids},
|
||||
)
|
||||
|
||||
def test_classify_marks_deletable_and_archive_only_with_pair_counts(self):
|
||||
cat, cat_negative, _pair = self.paired("Cat", "blur")
|
||||
self.db.save_prompt_pair("Cat", "grain")
|
||||
archived_pair = self.db.save_prompt_pair("Cat", "noise")
|
||||
self.db.archive_prompt_pair(archived_pair, True)
|
||||
shared = save(self.db, "Shared")
|
||||
self.image(shared, "shared.png")
|
||||
self.db.save_prompt_pair("Dog", "Shared") # used as another positive's negative
|
||||
review = self.classify([cat, shared])
|
||||
self.assertEqual(review["archive"], [cat, shared])
|
||||
self.assertEqual(review["deletable"], [cat])
|
||||
self.assertEqual(list(review["review_digests"]), [cat])
|
||||
self.assertRegex(review["review_digests"][cat], r"^[0-9a-f]{64}$")
|
||||
self.assertEqual(
|
||||
review["paired_counts"],
|
||||
{"pairs": 3, "archived_pairs": 1, "variants": 3, "kept_negatives": 3},
|
||||
)
|
||||
self.assertEqual(review["paired_image_links"], 0)
|
||||
|
||||
def test_an_empty_negative_pair_is_deleted_with_its_prompt(self):
|
||||
# Live prompt 7896: an auto-created pair with an explicitly empty negative.
|
||||
pair = self.db.save_prompt_pair("été", "")
|
||||
prompt = self.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
review = self.classify([prompt])
|
||||
self.assertEqual(review["deletable"], [prompt])
|
||||
self.assertEqual(
|
||||
review["paired_counts"],
|
||||
{"pairs": 1, "archived_pairs": 0, "variants": 0, "kept_negatives": 0},
|
||||
)
|
||||
result = self.delete_paired([prompt], review)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (1, 0))
|
||||
self.assertEqual(result["pairs_removed"], 1)
|
||||
self.assertNotIn(prompt, prompt_ids(self.db))
|
||||
self.assertEqual(self.pair_rows(prompt), [])
|
||||
self.assertEqual(rows(self.db, "PRAGMA foreign_key_check"), [])
|
||||
|
||||
def test_rows_removed_are_the_legacy_pair_deletes_then_the_prompt_delete(self):
|
||||
twin = PromptDatabase(str(self.root / "twin.db"))
|
||||
self.addCleanup(twin.close_all)
|
||||
twin_epoch = twin.get_library_options()["library_epoch"]
|
||||
made = {}
|
||||
for db in (self.db, twin):
|
||||
plain = db.save_prompt_pair("Owl", "", tags=["night"], title="Owl")
|
||||
with_negative = db.save_prompt_pair("Owl", "dark", rating=4)
|
||||
hidden = db.save_prompt_pair("Owl", "fog")
|
||||
db.archive_prompt_pair(hidden, True)
|
||||
owl = db.get_prompt_pair(plain)["positive_prompt_id"]
|
||||
path = self.root / "owl.png"
|
||||
path.write_bytes(b"png")
|
||||
image = db.link_image_to_prompt(owl, str(path), {"workflow": {"nodes": []}})
|
||||
db.link_image_to_pair(with_negative, image, {"resolver_version": 1})
|
||||
save(db, "Bystander", tags=["night"])
|
||||
made[id(db)] = (owl, [plain, with_negative, hidden])
|
||||
owl, pairs = made[id(self.db)]
|
||||
review = self.classify([owl])
|
||||
self.delete_paired([owl], review)
|
||||
twin_owl, twin_pairs = made[id(twin)]
|
||||
with twin.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM image_prompt_pairs WHERE pair_id IN (SELECT id FROM "
|
||||
"prompt_pairs WHERE positive_prompt_id=?)",
|
||||
(twin_owl,),
|
||||
)
|
||||
conn.commit()
|
||||
for pair in twin_pairs:
|
||||
self.assertTrue(twin.delete_prompt_pair(pair))
|
||||
twin.archive_or_delete_library_prompts(
|
||||
[], [twin_owl], expected_epoch=twin_epoch, candidates=None
|
||||
)
|
||||
self.assertEqual(table_dump(self.db), table_dump(twin))
|
||||
# The negatives stay, and the image file stays on disk.
|
||||
self.assertTrue((self.root / "owl.png").exists())
|
||||
texts = [row[0] for row in rows(self.db, "SELECT text FROM prompts")]
|
||||
self.assertTrue({"dark", "fog", "Bystander"} <= set(texts))
|
||||
|
||||
def test_a_negative_used_by_another_positive_is_archived_never_deleted(self):
|
||||
shared = save(self.db, "Shared")
|
||||
self.image(shared, "s.png")
|
||||
self.db.save_prompt_pair("Dog", "Shared")
|
||||
review = self.classify([shared])
|
||||
self.assertEqual(review["deletable"], [])
|
||||
result = self.db.archive_or_delete_library_prompts(
|
||||
[shared],
|
||||
[],
|
||||
expected_epoch=self.epoch,
|
||||
candidates={},
|
||||
delete_paired={shared: "0" * 64},
|
||||
)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (0, 1))
|
||||
self.assertEqual(result["changed"], 1)
|
||||
self.assertEqual(archived_ids(self.db), [shared])
|
||||
|
||||
def test_a_positive_and_its_own_negative_selected_together(self):
|
||||
cat, blur, _pair = self.paired("Cat", "blur")
|
||||
self.image(blur, "blur.png") # the negative is a Library prompt too
|
||||
review = self.classify([cat, blur])
|
||||
self.assertEqual(review["deletable"], [cat])
|
||||
result = self.delete_paired([cat], review)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (1, 1))
|
||||
self.assertEqual(archived_ids(self.db), [blur])
|
||||
self.assertNotIn(cat, prompt_ids(self.db))
|
||||
|
||||
def test_a_self_pair_is_deleted(self):
|
||||
pair = self.db.save_prompt_pair("Same", "Same")
|
||||
same = self.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
review = self.classify([same])
|
||||
self.assertEqual(review["deletable"], [same])
|
||||
self.assertEqual(review["paired_counts"]["kept_negatives"], 0)
|
||||
self.assertEqual(self.delete_paired([same], review)["deleted"], 1)
|
||||
self.assertNotIn(same, prompt_ids(self.db))
|
||||
|
||||
def test_drift_after_the_review_archives_and_counts_changed(self):
|
||||
cases = {
|
||||
"pair added": lambda p: self.db.save_prompt_pair("Fox", "new"),
|
||||
"pair edited": lambda p: self.db.update_prompt_pair_metadata(
|
||||
self.pair_rows(p)[0][0], title="Renamed"
|
||||
),
|
||||
"pairs gone": lambda p: self.db.delete_prompt_pair(self.pair_rows(p)[0][0]),
|
||||
"now another's negative": lambda p: self.db.save_prompt_pair("Hen", "Fox"),
|
||||
}
|
||||
for name, change in cases.items():
|
||||
with self.subTest(name):
|
||||
fox, _neg, _pair = self.paired("Fox", "mist " + name)
|
||||
review = self.classify([fox])
|
||||
self.assertEqual(review["deletable"], [fox])
|
||||
change(fox)
|
||||
result = self.delete_paired([fox], review)
|
||||
self.assertEqual(
|
||||
(result["deleted"], result["archived"], result["changed"]),
|
||||
(0, 1, 1),
|
||||
)
|
||||
self.assertIn(fox, archived_ids(self.db))
|
||||
self.restore([fox])
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM prompt_pairs WHERE positive_prompt_id IN "
|
||||
"(SELECT id FROM prompts WHERE text IN ('Hen','Fox'))"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def test_delete_paired_must_name_reviewed_archive_ids(self):
|
||||
fox, _neg, _pair = self.paired("Fox")
|
||||
review = self.classify([fox])
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_or_delete_library_prompts(
|
||||
[],
|
||||
[],
|
||||
expected_epoch=self.epoch,
|
||||
candidates={},
|
||||
delete_paired={fox: review["review_digests"][fox]},
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_or_delete_library_prompts(
|
||||
[fox],
|
||||
[],
|
||||
expected_epoch=self.epoch,
|
||||
candidates={},
|
||||
delete_paired={fox: "not a digest"},
|
||||
)
|
||||
|
||||
@@ -130,8 +130,13 @@ class TestLibraryDeleteRoutes(LibraryDeleteRouteCase):
|
||||
async def test_archive_only_takes_no_backup(self):
|
||||
self.policy(True)
|
||||
positive = self.paired("Cat")
|
||||
shared = self.save("Shared")
|
||||
self.api.db.save_prompt_pair("Dog", "Shared") # archive-only (#482)
|
||||
self.api.db.link_image_to_prompt(shared, "/tmp/shared-443.png")
|
||||
# Nothing could be deleted: no disk plan. (#482: a deletable prompt has one.)
|
||||
self.assertIsNone((await self.review([shared]))["disk"])
|
||||
review = await self.review([positive])
|
||||
self.assertIsNone(review["disk"])
|
||||
self.assertIsNotNone(review["disk"])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["archived"], body["backup"]), (200, 1, None))
|
||||
self.assertEqual(self.backups(), [])
|
||||
@@ -309,6 +314,94 @@ class TestLibraryDeleteRoutes(LibraryDeleteRouteCase):
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
|
||||
class TestPairedDeleteRoutes(LibraryDeleteRouteCase):
|
||||
"""#482 (D160): Delete permanently with a prompt's own saved pairs."""
|
||||
|
||||
async def confirm_paired(self, review, ids, backup=None, digests=None):
|
||||
return await self.post(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": review["archive"],
|
||||
"delete_ids": review["delete"],
|
||||
"expected_epoch": review["library_epoch"],
|
||||
"backup": backup or review["backup"],
|
||||
"delete_paired_ids": ids,
|
||||
"paired_digests": digests
|
||||
or {str(pid): review["review_digests"][str(pid)] for pid in ids},
|
||||
},
|
||||
)
|
||||
|
||||
async def test_preview_reports_deletable_and_a_paired_only_delete_backs_up(self):
|
||||
self.policy(True)
|
||||
cat = self.paired("Cat")
|
||||
shared = self.save("Shared")
|
||||
self.api.db.save_prompt_pair("Dog", "Shared")
|
||||
self.api.db.link_image_to_prompt(shared, "/tmp/shared-482.png")
|
||||
review = await self.review([cat, shared])
|
||||
self.assertEqual(review["archive"], [cat, shared])
|
||||
self.assertEqual(review["deletable"], [cat])
|
||||
self.assertEqual(list(review["review_digests"]), [str(cat)])
|
||||
self.assertEqual(
|
||||
review["paired_counts"],
|
||||
{"pairs": 1, "archived_pairs": 0, "variants": 1, "kept_negatives": 1},
|
||||
)
|
||||
self.assertEqual(review["paired_image_links"], 0)
|
||||
# A selection with nothing in the delete class still checks the disk.
|
||||
self.assertTrue(review["disk"]["sufficient"])
|
||||
status, body = await self.confirm_paired(review, [cat])
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(
|
||||
(body["deleted"], body["archived"], body["pairs_removed"]), (1, 1, 1)
|
||||
)
|
||||
self.assertEqual(self.backups(), [body["backup"]])
|
||||
self.assertNotIn(cat, self.ids())
|
||||
self.assertEqual(self.archived(), [shared])
|
||||
|
||||
async def test_a_body_without_the_new_keys_is_the_443_delete(self):
|
||||
cat = self.paired("Cat")
|
||||
review = await self.review([cat])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual((body["deleted"], body["archived"]), (0, 1))
|
||||
self.assertIn(cat, self.ids())
|
||||
|
||||
async def test_paired_bodies_are_validated(self):
|
||||
cat = self.paired("Cat")
|
||||
plain = self.save("Plain")
|
||||
review = await self.review([cat, plain])
|
||||
digest = review["review_digests"][str(cat)]
|
||||
base = {
|
||||
"archive_ids": review["archive"],
|
||||
"delete_ids": review["delete"],
|
||||
"expected_epoch": review["library_epoch"],
|
||||
"backup": "off",
|
||||
}
|
||||
cases = [
|
||||
{"delete_paired_ids": [cat]}, # without its digests
|
||||
{"delete_paired_ids": [plain], "paired_digests": {str(plain): digest}},
|
||||
{"delete_paired_ids": [cat], "paired_digests": {}},
|
||||
{"delete_paired_ids": [cat], "paired_digests": {str(cat): "x"}},
|
||||
{"delete_paired_ids": "all", "paired_digests": {}},
|
||||
]
|
||||
for extra in cases:
|
||||
with self.subTest(extra=extra):
|
||||
status, body = await self.post(DELETE, {**base, **extra})
|
||||
self.assertEqual((status, body["code"]), (400, "invalid-request"))
|
||||
self.assertIn(cat, self.ids())
|
||||
|
||||
async def test_a_short_disk_refuses_a_paired_only_delete(self):
|
||||
self.policy(True)
|
||||
cat = self.paired("Cat")
|
||||
from py.api import maintenance
|
||||
|
||||
with patch.object(maintenance, "_free_bytes", return_value=0):
|
||||
review = await self.review([cat])
|
||||
self.assertFalse(review["disk"]["sufficient"])
|
||||
status, body = await self.confirm_paired(review, [cat])
|
||||
self.assertEqual((status, body["code"]), (507, "insufficient-space"))
|
||||
self.assertIn(cat, self.ids())
|
||||
|
||||
|
||||
class TestIngestionAfterArchive(LibraryDeleteRouteCase):
|
||||
def scan(self, name, positive, negative="blur"):
|
||||
meta = {"prompt": json.dumps(graph(positive, negative))}
|
||||
|
||||
@@ -34,6 +34,7 @@ export const COPY = Object.freeze({
|
||||
const count = value => (Number.isSafeInteger(value) && value >= 0 ? value : 0);
|
||||
const ids = value => (Array.isArray(value) ? value.filter(id => Number.isSafeInteger(id) && id > 0) : []);
|
||||
const were = (value, singular, plural) => `${value.toLocaleString('en-US')} ${value === 1 ? singular : plural}`;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
/** The preview's classes as counts and id lists, whatever else the answer holds. */
|
||||
export function reviewOf(preview) {
|
||||
@@ -43,12 +44,43 @@ export function reviewOf(preview) {
|
||||
alreadyArchived: count(preview?.already_archived), notFound: count(preview?.not_found),
|
||||
imageLinks: count(preview?.image_links), backup: preview?.backup === 'off' ? 'off' : 'on',
|
||||
disk: preview?.disk && typeof preview.disk === 'object' ? preview.disk : null,
|
||||
...pairedOf(preview),
|
||||
};
|
||||
}
|
||||
|
||||
/** #482: the archive ids that may be deleted permanently (each with its review digest), and the pair counts. */
|
||||
function pairedOf(preview) {
|
||||
const archive = new Set(ids(preview?.archive)), given = preview?.review_digests ?? {};
|
||||
const deletable = ids(preview?.deletable).filter(id => archive.has(id) && DIGEST.test(String(given?.[id] ?? '')));
|
||||
const counts = preview?.paired_counts ?? {};
|
||||
return {
|
||||
deletable, digests: Object.fromEntries(deletable.map(id => [id, given[id]])),
|
||||
pairedCounts: { pairs: count(counts.pairs), archivedPairs: count(counts.archived_pairs),
|
||||
variants: count(counts.variants), keptNegatives: count(counts.kept_negatives) },
|
||||
pairedImageLinks: count(preview?.paired_image_links), choice: 'archive',
|
||||
};
|
||||
}
|
||||
|
||||
/** #482: whether the review asks what to do with prompts in a saved pair. */
|
||||
export const hasPairedChoice = review => review.deletable.length > 0;
|
||||
|
||||
/** #482: the review with its saved-pair choice (`archive`, the default, or `delete`); the original is unchanged. */
|
||||
export function withChoice(review, choice) {
|
||||
return { ...review, choice: choice === 'delete' && hasPairedChoice(review) ? 'delete' : 'archive' };
|
||||
}
|
||||
|
||||
const permanent = review => review.choice === 'delete';
|
||||
/** What is deleted and archived: Delete permanently moves the deletable ids from archive to delete. */
|
||||
function plan(review) {
|
||||
if (!permanent(review)) return { plain: review.delete, paired: [], archive: review.archive };
|
||||
const chosen = new Set(review.deletable);
|
||||
return { plain: review.delete, paired: review.deletable, archive: review.archive.filter(id => !chosen.has(id)) };
|
||||
}
|
||||
|
||||
export function reviewTitle(review) {
|
||||
if (!review.archive.length) return 'Delete selected prompts';
|
||||
if (!review.delete.length) return 'Archive selected prompts';
|
||||
const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length;
|
||||
if (!archive.length) return 'Delete selected prompts';
|
||||
if (!deletes) return 'Archive selected prompts';
|
||||
return 'Delete or archive selected prompts';
|
||||
}
|
||||
|
||||
@@ -70,49 +102,90 @@ export function archivedNoticeText(variantCount) {
|
||||
|
||||
/** The review's `[term, value]` rows (design §7.2); rows that do not apply are left out. */
|
||||
export function reviewGrid(review, titleOf = () => null) {
|
||||
if (permanent(review)) return permanentGrid(review, titleOf);
|
||||
const rows = [
|
||||
['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`],
|
||||
// The archive reason is the saved pair (a pair's positive or negative), with or without negative variants.
|
||||
['Archive', `${countOf(review.archive.length, 'prompt')} in a saved pair: hidden, restorable`],
|
||||
];
|
||||
keptRows(rows, review, titleOf);
|
||||
if (review.delete.length) deleteRows(rows, review, review.imageLinks);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function keptRows(rows, review, titleOf) {
|
||||
if (review.protected.length) {
|
||||
const names = protectedNames(review, titleOf);
|
||||
rows.push(['Protected (kept)', `${review.protected.length.toLocaleString('en-US')}${names ? `: ${names}` : ''}`]);
|
||||
}
|
||||
if (review.alreadyArchived) rows.push(['Already archived', review.alreadyArchived.toLocaleString('en-US')]);
|
||||
if (review.notFound) rows.push(['No longer exist', review.notFound.toLocaleString('en-US')]);
|
||||
if (review.delete.length) {
|
||||
rows.push(['Image links removed', `${countOf(review.imageLinks, 'image')} will no longer show these prompts (files stay on disk)`]);
|
||||
rows.push(['Backup', review.backup === 'on' ? 'Create and verify before deleting'
|
||||
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteRows(rows, review, images) {
|
||||
rows.push(['Image links removed', `${countOf(images, 'image')} will no longer show these prompts (files stay on disk)`]);
|
||||
rows.push(['Backup', review.backup === 'on' ? 'Create and verify before deleting'
|
||||
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
|
||||
}
|
||||
|
||||
/** #482: the grid when Delete permanently is chosen. */
|
||||
function permanentGrid(review, titleOf) {
|
||||
const { plain, paired, archive } = plan(review), counts = review.pairedCounts;
|
||||
const parts = [plain.length && `${plain.length.toLocaleString('en-US')} not in a saved pair`,
|
||||
`${paired.length.toLocaleString('en-US')} in a saved pair`].filter(Boolean);
|
||||
const total = plain.length + paired.length;
|
||||
const deleted = plain.length ? `${countOf(total, 'prompt')}: ${parts.join(', ')}` : `${countOf(total, 'prompt')} in a saved pair`;
|
||||
const details = [counts.variants && `${counts.variants.toLocaleString('en-US')} with a negative prompt`,
|
||||
counts.archivedPairs && `${counts.archivedPairs.toLocaleString('en-US')} archived`].filter(Boolean);
|
||||
const rows = [['Delete permanently', deleted],
|
||||
['Saved pairs removed', `${countOf(counts.pairs, 'saved pair')}${details.length ? ` (${details.join(', ')})` : ''}`]];
|
||||
if (counts.keptNegatives) rows.push(['Negatives kept', `${countOf(counts.keptNegatives, 'negative prompt')} ${counts.keptNegatives === 1 ? 'is' : 'are'} kept`]);
|
||||
if (archive.length) rows.push(['Archive', `${countOf(archive.length, 'prompt')} used as another prompt's negative: hidden, restorable`]);
|
||||
keptRows(rows, review, titleOf);
|
||||
deleteRows(rows, review, review.imageLinks + review.pairedImageLinks);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The review's sentences, in order, each when it applies. */
|
||||
export function reviewSentences(review) {
|
||||
if (permanent(review)) return permanentSentences(review);
|
||||
const sentences = [];
|
||||
if (review.archive.length) {
|
||||
sentences.push('Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the ' +
|
||||
'pair and its runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, ' +
|
||||
'select them and press Restore.');
|
||||
}
|
||||
if (review.delete.length) {
|
||||
const undo = review.backup === 'on'
|
||||
? ' Deleted prompts cannot be brought back one by one: the only way back is to restore the safety backup taken ' +
|
||||
'first (System › Backup & export), which returns the whole library to that moment. It is kept among the last ' +
|
||||
'10 safety backups.'
|
||||
: ' Deleted prompts cannot be brought back.';
|
||||
sentences.push("Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " +
|
||||
`image links.${undo} Image files, favorites, collections and recorded runs stay. A later scan, a new image or a ` +
|
||||
'node run with the same text adds it again as a new prompt.');
|
||||
}
|
||||
if (review.protected.length) sentences.push('Protected prompts are tagged __protected__; remove the tag to delete them.');
|
||||
if (review.delete.length) sentences.push(removeSentence(review));
|
||||
if (review.protected.length) sentences.push(PROTECTED_SENTENCE);
|
||||
return sentences;
|
||||
}
|
||||
|
||||
const PROTECTED_SENTENCE = 'Protected prompts are tagged __protected__; remove the tag to delete them.';
|
||||
function removeSentence(review, pairs = false) {
|
||||
const undo = review.backup === 'on'
|
||||
? ' Deleted prompts cannot be brought back one by one: the only way back is to restore the safety backup taken ' +
|
||||
'first (System › Backup & export), which returns the whole library to that moment. It is kept among the last ' +
|
||||
'10 safety backups.'
|
||||
: ' Deleted prompts cannot be brought back.';
|
||||
return "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " +
|
||||
`image links.${undo} Image files, favorites, collections and recorded runs stay. A later scan, a new image or a ` +
|
||||
`node run with the same text adds it again as a new prompt.${pairs ? ' A pair node run with the same text saves a new pair.' : ''}`;
|
||||
}
|
||||
|
||||
/** #482: the sentences when Delete permanently is chosen. */
|
||||
function permanentSentences(review) {
|
||||
const sentences = ['Deleting a prompt in a saved pair also removes its saved pairs, with their titles, categories, ' +
|
||||
'ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay in History ' +
|
||||
'and Activity, listed by their text.', removeSentence(review, true)];
|
||||
if (plan(review).archive.length) sentences.push("Prompts used as another prompt's negative are archived instead of deleted, " +
|
||||
"so that prompt's variant stays.");
|
||||
if (review.protected.length) sentences.push(PROTECTED_SENTENCE);
|
||||
return sentences;
|
||||
}
|
||||
|
||||
export function primaryLabel(review) {
|
||||
const deletes = review.delete.length, archives = review.archive.length;
|
||||
const { plain, paired, archive } = plan(review);
|
||||
const deletes = plain.length + paired.length, archives = archive.length;
|
||||
if (deletes && archives) return `Delete ${deletes.toLocaleString('en-US')} and archive ${countOf(archives, 'prompt')}`;
|
||||
return deletes ? `Delete ${countOf(deletes, 'prompt')}` : `Archive ${countOf(archives, 'prompt')}`;
|
||||
}
|
||||
@@ -120,7 +193,8 @@ export function primaryLabel(review) {
|
||||
/** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */
|
||||
export function blockedReason(review) {
|
||||
if (!review.delete.length && !review.archive.length) return COPY.none;
|
||||
if (review.delete.length && review.disk && review.disk.sufficient === false) {
|
||||
const deletes = review.delete.length + plan(review).paired.length;
|
||||
if (deletes && review.disk && review.disk.sufficient === false) {
|
||||
return typeof review.disk.message === 'string' && review.disk.message
|
||||
? review.disk.message : 'There is not enough free space for the safety backup. Nothing was changed.';
|
||||
}
|
||||
@@ -133,21 +207,69 @@ export function blockedReason(review) {
|
||||
* close `onDismiss()`.
|
||||
*/
|
||||
export function openDeleteReview({ scope, trigger, review, titleOf, onStart, onDismiss }) {
|
||||
const title = reviewTitle(review), grid = reviewGrid(review, titleOf);
|
||||
const options = reviewSentences(review).map(text => Object.assign(document.createElement('p'), { className: 'small', textContent: text }));
|
||||
const blocked = blockedReason(review);
|
||||
let current = withChoice(review, 'archive');
|
||||
const paragraphs = target => reviewSentences(target).map(text => paragraph(text));
|
||||
const title = reviewTitle(current), grid = reviewGrid(current, titleOf);
|
||||
let sentences = paragraphs(current);
|
||||
const blocked = blockedReason(current);
|
||||
if (blocked) {
|
||||
onDismiss();
|
||||
openCloseOnlyReview({ scope, trigger, title, grid, options, sentence: blocked });
|
||||
openCloseOnlyReview({ scope, trigger, title, grid, options: sentences, sentence: blocked });
|
||||
return;
|
||||
}
|
||||
openReviewDialog({ scope, trigger, title, grid, options, cancelLabel: 'Cancel',
|
||||
primary: { label: primaryLabel(review), danger: true }, onStart, onDismiss });
|
||||
// #482: prompts in a saved pair ask Archive (the default, as #443) or Delete permanently, with their pairs.
|
||||
const choice = hasPairedChoice(review) ? pairedChoice(value => update(value)) : null;
|
||||
const dialog = openReviewDialog({ scope, trigger, title, grid, options: choice ? [choice, ...sentences] : sentences,
|
||||
cancelLabel: 'Cancel', primary: { label: primaryLabel(current), danger: true },
|
||||
onStart: () => onStart(current), onDismiss });
|
||||
function update(value) {
|
||||
current = withChoice(review, value);
|
||||
dialog.setRows(reviewGrid(current, titleOf));
|
||||
const reason = blockedReason(current);
|
||||
const next = paragraphs(current);
|
||||
if (reason) next.push(paragraph(reason));
|
||||
for (const node of sentences) node.remove();
|
||||
choice.after(...next);
|
||||
sentences = next;
|
||||
const heading = choice.closest('dialog')?.querySelector('#modal-title');
|
||||
if (heading) heading.textContent = reviewTitle(current);
|
||||
dialog.setPrimary({ label: primaryLabel(current), disabled: Boolean(reason) });
|
||||
}
|
||||
}
|
||||
|
||||
const paragraph = text => Object.assign(document.createElement('p'), { className: 'small', textContent: text });
|
||||
export const PAIRED_CHOICES = Object.freeze([
|
||||
Object.freeze({ value: 'archive', label: 'Archive (hidden, restorable)' }),
|
||||
Object.freeze({ value: 'delete', label: 'Delete permanently, with their saved pairs' }),
|
||||
]);
|
||||
|
||||
/** #482: the D90 radio row ("Prompts in a saved pair"), Archive checked; it is built fresh for every review. */
|
||||
function pairedChoice(onChange) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'field';
|
||||
group.setAttribute('role', 'radiogroup');
|
||||
const caption = document.createElement('span');
|
||||
caption.textContent = 'Prompts in a saved pair';
|
||||
caption.id = 'library-delete-choice-label';
|
||||
group.setAttribute('aria-labelledby', caption.id);
|
||||
group.append(caption);
|
||||
for (const choice of PAIRED_CHOICES) {
|
||||
const label = document.createElement('label');
|
||||
const input = document.createElement('input');
|
||||
Object.assign(input, { type: 'radio', name: 'library-delete-choice', value: choice.value, checked: choice.value === 'archive' });
|
||||
input.addEventListener('change', () => { if (input.checked) onChange(choice.value); });
|
||||
label.append(input, ` ${choice.label}`);
|
||||
group.append(label);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/** The write's body: the reviewed lists, the epoch and the reviewed backup promise. */
|
||||
export function deleteBody(review) {
|
||||
return { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup };
|
||||
const body = { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup };
|
||||
// #482: the archive ids to delete permanently, each with the digest of what the review showed.
|
||||
if (permanent(review)) Object.assign(body, { delete_paired_ids: review.deletable, paired_digests: review.digests });
|
||||
return body;
|
||||
}
|
||||
|
||||
function failureOutcome(failure, copy) {
|
||||
@@ -172,14 +294,15 @@ export function deleteOutcome(result, failure, review) {
|
||||
return failureOutcome(failure, { storage: COPY.storage, unconfirmed: COPY.unconfirmed, failed: COPY.failed });
|
||||
}
|
||||
const deleted = count(result.deleted), archived = count(result.archived);
|
||||
const parts = [];
|
||||
const parts = [], pairs = count(result.pairs_removed);
|
||||
if (deleted) parts.push(`Deleted ${countOf(deleted, 'prompt')}.`);
|
||||
if (pairs) parts.push(`Removed ${countOf(pairs, 'saved pair')}.`);
|
||||
if (archived) parts.push(`Archived ${countOf(archived, 'prompt')}.`);
|
||||
if (!parts.length) parts.push(NOTHING);
|
||||
const already = count(result.already_archived), missing = count(result.not_found), changed = count(result.changed);
|
||||
if (already) parts.push(`${were(already, 'was', 'were')} already archived.`);
|
||||
if (missing) parts.push(`${missing.toLocaleString('en-US')} no longer ${missing === 1 ? 'exists' : 'exist'}.`);
|
||||
if (changed) parts.push(`${changed.toLocaleString('en-US')} changed since the review and ${changed === 1 ? 'was' : 'were'} kept.`);
|
||||
if (changed) parts.push(`${changed.toLocaleString('en-US')} changed since the review and ${changed === 1 ? 'was' : 'were'} not deleted.`);
|
||||
const kept = review?.protected?.length || 0;
|
||||
if (kept) parts.push(`${countOf(kept, 'protected prompt')} ${kept === 1 ? 'was' : 'were'} kept.`);
|
||||
if (typeof result.backup === 'string' && result.backup) parts.push(`Safety backup saved: ${result.backup} (System › Backup & export).`);
|
||||
|
||||
@@ -291,8 +291,9 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
deleteButton.textContent = DELETE_LABEL;
|
||||
openDeleteReview({ scope, trigger: deleteButton, review, titleOf: nameOf,
|
||||
onDismiss: unlock,
|
||||
onStart: () => write(DELETE_URL, deleteBody(review), 'delete', (result, error) => deleteOutcome(result, error, review),
|
||||
review.delete.length ? 'Deleting…' : 'Archiving…') });
|
||||
// #482: the review as chosen (Archive, or Delete permanently with the prompts' saved pairs).
|
||||
onStart: chosen => write(DELETE_URL, deleteBody(chosen), 'delete', (result, error) => deleteOutcome(result, error, chosen),
|
||||
chosen.delete.length || chosen.choice === 'delete' ? 'Deleting…' : 'Archiving…') });
|
||||
}
|
||||
/** Restore (#443): the selected archived prompts go back on the shelf. */
|
||||
function restoreSelected() {
|
||||
|
||||
@@ -194,12 +194,15 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
|
||||
const actions=headingActions(main,section);
|
||||
const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON');
|
||||
// #443 D152: the shelf's counterpart of Negative variants' Show archived (#235); N follows the filters.
|
||||
const showArchived=createButton('Show archived prompts');showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived'));
|
||||
// #443 D152: the shelf's counterpart of Negative variants' Show archived (#235); N follows the filters. #482: on
|
||||
// the archived shelf it reads the concept's own way back ("← Prompt library", app.js 594); a plain button whose
|
||||
// label names where it goes, so it carries no pressed state its changing label would contradict.
|
||||
const BACK_TO_LIBRARY='← Prompt library';
|
||||
const showArchived=createButton(filters.shelf==='archived'?BACK_TO_LIBRARY:'Show archived prompts');
|
||||
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(showArchived,exportButton,newPrompt);
|
||||
scope.listen(showArchived,'click',()=>{
|
||||
if(selection.writing()) return;
|
||||
filters.shelf=filters.shelf==='archived'?'active':'archived';showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived'));
|
||||
filters.shelf=filters.shelf==='archived'?'active':'archived';
|
||||
reset();selection.shelfChanged();
|
||||
});
|
||||
const exportStatus=el('p','','library-options-status');exportStatus.hidden=true;exportStatus.setAttribute('role','alert');
|
||||
@@ -408,7 +411,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
:`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
|
||||
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
|
||||
}
|
||||
const archivedLabel=next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`;
|
||||
const archivedLabel=filters.shelf==='archived'?BACK_TO_LIBRARY
|
||||
:next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`;
|
||||
if(showArchived.textContent!==archivedLabel) showArchived.textContent=archivedLabel;
|
||||
const filtered=Boolean(filters.text || filters.category || filters.tags.length || filters.folder || filters.tagMode!=='all');
|
||||
const empty=filters.shelf==='archived'?(filtered?'No archived prompts match.':'No archived prompts.'):'No matching prompts. Try changing the filters.';
|
||||
|
||||
@@ -491,13 +491,17 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
|
||||
if (heading) nodes.push(el('h3', heading));
|
||||
const list = el('dl', undefined, 'meta-grid');
|
||||
const values = new Map();
|
||||
for (const [term, value] of grid) {
|
||||
const entry = el('div');
|
||||
const dd = el('dd', value);
|
||||
values.set(term, dd);
|
||||
entry.append(el('dt', term), dd);
|
||||
list.append(entry);
|
||||
}
|
||||
const fill = rows => {
|
||||
values.clear();
|
||||
list.replaceChildren(...rows.map(([term, value]) => {
|
||||
const entry = el('div');
|
||||
const dd = el('dd', value);
|
||||
values.set(term, dd);
|
||||
entry.append(el('dt', term), dd);
|
||||
return entry;
|
||||
}));
|
||||
};
|
||||
fill(grid);
|
||||
nodes.push(list, ...options);
|
||||
if (sentence) nodes.push(el('p', sentence, 'small'));
|
||||
if (backup) nodes.push(backupNotice(backup));
|
||||
@@ -506,6 +510,7 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
|
||||
let started = false;
|
||||
cancel.addEventListener('click', () => finish());
|
||||
start.addEventListener('click', () => {
|
||||
if (start.getAttribute('aria-disabled') === 'true') return;
|
||||
started = true;
|
||||
finish();
|
||||
onStart();
|
||||
@@ -524,6 +529,17 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
|
||||
const dd = values.get(term);
|
||||
if (dd) dd.title = text;
|
||||
},
|
||||
/** #482: the rows a choice in the review changes (`[term, value]` pairs, as `grid`). */
|
||||
setRows(rows) {
|
||||
requireGrid(rows);
|
||||
fill(rows);
|
||||
},
|
||||
/** #482: the primary's label, and whether it can start (an unavailable start stays in place, aria-disabled). */
|
||||
setPrimary({ label, disabled = false }) {
|
||||
if (typeof label !== 'string' || !label) throw new TypeError('The review needs its label');
|
||||
start.textContent = label;
|
||||
start.setAttribute('aria-disabled', String(Boolean(disabled)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user