Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
640413afaf | ||
|
|
ea0908f78f | ||
|
|
a782c9830d | ||
|
|
aa125d82e3 | ||
|
|
704db4be8a | ||
|
|
50989a7bd6 | ||
|
|
c2a7ba523c | ||
|
|
fbaf02f827 | ||
|
|
ebccf02b0a | ||
|
|
0faf2bea50 | ||
|
|
b968730cc4 | ||
|
|
18685e0c83 | ||
|
|
ba30b8f94c | ||
|
|
f87f2c43d0 | ||
|
|
d0ffcccc23 | ||
|
|
6fdcbacfc1 | ||
|
|
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 |
+24
-1
@@ -2646,7 +2646,7 @@ Category". It is built only from the concept's Images parts:
|
||||
- `div#library-bulkbar` sits between the toolbar and the caption, empty until something
|
||||
is checked, then the concept's `.bulkbar`: "N selected", Set category, Clear (D151
|
||||
adds Add tags before Set category, in the Images bar's order; D152 adds Delete… before
|
||||
Clear, and Restore alone on the archived shelf). The
|
||||
Clear, and Restore then Delete… on the archived shelf, #492). The
|
||||
selection is a set of prompt ids over the loaded shelf: windowed-out cards keep their
|
||||
state, Load more keeps it, and any shelf reload (search, category, sort, tags, a
|
||||
write) and leaving the page clear it. Clicking a checkbox toggles it and sets the
|
||||
@@ -2861,6 +2861,29 @@ with its own negative, a self-pair, each drift, validation); `tests/test_library
|
||||
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
|
||||
change: ComfyUI needs a restart.
|
||||
|
||||
### Delete… on the archived shelf (#492)
|
||||
|
||||
Extends **D160** (coordinator ruling 2026-10-29, cutover item): the archived shelf's bar reads "N selected · Restore ·
|
||||
Delete… · Clear". Its Delete… review is the permanent delete only, **without** #482's "Prompts in a saved pair" choice
|
||||
(on this shelf "Archive" would change nothing; Cancel keeps them archived). Title "Delete archived prompts"; the grid
|
||||
is #482's Delete permanently grid ("Saved pairs removed" and "Negatives kept" only when a selected prompt is in a saved
|
||||
pair); an archived prompt used as another prompt's negative cannot be deleted and **stays archived** ("Stays archived: R
|
||||
prompts used as another prompt's negative"); one restored since the shelf was read is "No longer archived (kept)" and
|
||||
never written; a review with nothing to delete is close-only ("None of the selected prompts can be deleted."). The
|
||||
primary is "Delete N prompts"; the review opens with focus on Cancel (it is only a permanent delete; `openReviewDialog`'s
|
||||
`focusCancel`). The same preview, safety backup, review digests, `records` hold and epoch fence apply.
|
||||
|
||||
The routes take an optional `shelf` (`"active"`, the default, or `"archived"`; anything else 400). Under it an archived
|
||||
prompt is classified by its pairs and a prompt without an archive row is `not_archived`; the held write classifies the
|
||||
same shelf, so a permanent delete is backed up. The client sends only what it deletes. On this shelf the write archives
|
||||
and rewrites nothing; every reviewed id not deleted counts `changed`, and the answer adds `not_archived` ("N were
|
||||
restored since the review and kept."). The main shelf's archive sentence now ends "To delete them later or bring them
|
||||
back, press Show archived prompts, select them and press Delete… or Restore."
|
||||
|
||||
Tests: `tests/test_bulk_library_delete.py` TestArchivedShelfDelete, `tests/test_library_delete_routes.py`
|
||||
TestArchivedShelfDeleteRoutes, `tests/js/workspace-library-delete.test.js` (archived shelf) and
|
||||
`tests/browser/workspace-library-delete.spec.mjs` (#492 cases). 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
|
||||
|
||||
@@ -49,7 +49,26 @@ _NEGATIVE_OF_OTHER = (
|
||||
"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")
|
||||
CLASSES = (
|
||||
"not_found",
|
||||
"protected",
|
||||
"already_archived",
|
||||
"not_archived",
|
||||
"archive",
|
||||
"delete",
|
||||
)
|
||||
# #492: the shelf a Delete… runs on. On the archived shelf an archived prompt is
|
||||
# classified by its pairs, and one restored since is ``not_archived``.
|
||||
SHELVES = ("active", "archived")
|
||||
|
||||
|
||||
def _check_shelf(shelf: str) -> bool:
|
||||
"""Whether ``shelf`` is the archived shelf (ValueError when unknown)."""
|
||||
if shelf not in SHELVES:
|
||||
raise ValueError("shelf must be active or archived")
|
||||
return shelf == "archived"
|
||||
|
||||
|
||||
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
@@ -86,8 +105,9 @@ class LibraryArchiveOperations:
|
||||
"""PromptDatabase mixin: classify, archive or delete, and restore parents."""
|
||||
|
||||
def _classify_library_delete(
|
||||
self, conn: sqlite3.Connection, prompt_ids: list[int]
|
||||
self, conn: sqlite3.Connection, prompt_ids: list[int], shelf: str = "active"
|
||||
) -> dict:
|
||||
on_archived_shelf = _check_shelf(shelf)
|
||||
found, deletable = {}, set()
|
||||
for chunk in _batches(prompt_ids):
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
@@ -100,7 +120,9 @@ class LibraryArchiveOperations:
|
||||
protected, archived, paired, negative_of_other = row[1:5]
|
||||
if protected:
|
||||
found[row[0]] = "protected"
|
||||
elif archived:
|
||||
elif on_archived_shelf and not archived:
|
||||
found[row[0]] = "not_archived"
|
||||
elif archived and not on_archived_shelf:
|
||||
found[row[0]] = "already_archived"
|
||||
elif paired:
|
||||
found[row[0]] = "archive"
|
||||
@@ -237,18 +259,20 @@ class LibraryArchiveOperations:
|
||||
return list(dict.fromkeys(prompt_ids))
|
||||
|
||||
def classify_library_delete(
|
||||
self, prompt_ids: list[int], *, expected_epoch: str
|
||||
self, prompt_ids: list[int], *, expected_epoch: str, shelf: str = "active"
|
||||
) -> dict:
|
||||
"""What a delete of ``prompt_ids`` would do now (reads only).
|
||||
|
||||
Returns the id lists per class, in request order, ``image_links``
|
||||
(the image link rows the ``delete`` class would remove) and
|
||||
``fingerprints`` of the ``delete`` class (``_fingerprints``).
|
||||
``fingerprints`` of the ``delete`` class (``_fingerprints``). On the
|
||||
archived ``shelf`` (#492) archived prompts are classified by their pairs.
|
||||
"""
|
||||
_check_shelf(shelf)
|
||||
prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
|
||||
with self.library_snapshot() as (conn, _epoch, _ceiling):
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
classes = self._classify_library_delete(conn, prompt_ids)
|
||||
classes = self._classify_library_delete(conn, prompt_ids, shelf)
|
||||
deletable = classes["deletable"]
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
@@ -290,6 +314,7 @@ class LibraryArchiveOperations:
|
||||
expected_epoch: str,
|
||||
candidates: dict | None,
|
||||
delete_paired: dict | None = None,
|
||||
shelf: str = "active",
|
||||
) -> dict:
|
||||
"""Archive and delete what the user reviewed, all or nothing.
|
||||
|
||||
@@ -305,7 +330,12 @@ class LibraryArchiveOperations:
|
||||
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``.
|
||||
|
||||
On the archived ``shelf`` (#492) the ids are archived already: nothing
|
||||
is archived or rewritten, every reviewed id not deleted (protected or
|
||||
changed) counts ``changed``, and one restored since ``not_archived``.
|
||||
"""
|
||||
on_archived_shelf = _check_shelf(shelf)
|
||||
_validate_epoch(expected_epoch)
|
||||
for ids in (archive_ids, delete_ids):
|
||||
if not isinstance(ids, list) or any(
|
||||
@@ -330,7 +360,7 @@ class LibraryArchiveOperations:
|
||||
prompt_ids = list(dict.fromkeys([*archive_ids, *delete_ids]))
|
||||
with self._pair_transaction() as conn:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
classes = self._classify_library_delete(conn, prompt_ids)
|
||||
classes = self._classify_library_delete(conn, prompt_ids, shelf)
|
||||
now_prints = self._fingerprints(conn, classes["delete"])
|
||||
deletes, archives, changed = [], [], 0
|
||||
paired_now = set(classes["deletable"])
|
||||
@@ -356,6 +386,11 @@ class LibraryArchiveOperations:
|
||||
else:
|
||||
changed += 1
|
||||
changed += len(classes["protected"])
|
||||
if on_archived_shelf:
|
||||
# Already archived: the rest stay as they are, each one counted.
|
||||
archives = []
|
||||
changed = len(prompt_ids) - len(deletes) - len(paired_deletes)
|
||||
changed -= len(classes["not_found"]) + len(classes["not_archived"])
|
||||
image_links = self._image_links(conn, [*deletes, *paired_deletes])
|
||||
now = utc_now_iso()
|
||||
for pid in archives:
|
||||
@@ -393,6 +428,7 @@ class LibraryArchiveOperations:
|
||||
"deleted": deleted,
|
||||
"archived": len(archives),
|
||||
"already_archived": len(classes["already_archived"]),
|
||||
"not_archived": len(classes["not_archived"]),
|
||||
"not_found": len(classes["not_found"]),
|
||||
"changed": changed,
|
||||
"image_links": image_links,
|
||||
|
||||
@@ -49,6 +49,8 @@ _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"})
|
||||
# #492: the shelf the Delete… runs on, optional on its own (default "active").
|
||||
_SHELF_KEYS = frozenset({"shelf"})
|
||||
STORAGE_CHANGED = "Prompt storage was restored; refresh the library"
|
||||
|
||||
|
||||
@@ -66,8 +68,8 @@ 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, *groups):
|
||||
"""The JSON object with exactly ``keys``, plus all or none of each group."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
@@ -75,11 +77,24 @@ async def _body(request, keys, optional=frozenset()):
|
||||
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):
|
||||
partial = False
|
||||
for group in groups:
|
||||
if extra & group:
|
||||
partial = partial or not group <= extra
|
||||
extra -= group
|
||||
if not keys <= set(data) or extra or partial:
|
||||
raise ValueError("Request must contain exactly " + ", ".join(sorted(keys)))
|
||||
return data
|
||||
|
||||
|
||||
def _shelf(data):
|
||||
"""#492: the shelf a Delete… was reviewed on."""
|
||||
shelf = data.get("shelf", "active")
|
||||
if shelf not in ("active", "archived"):
|
||||
raise ValueError("shelf must be active or archived")
|
||||
return shelf
|
||||
|
||||
|
||||
def _paired_delete(data, archive_ids):
|
||||
"""#482: the reviewed archive ids to delete with their pairs -> digest."""
|
||||
if "delete_paired_ids" not in data:
|
||||
@@ -119,8 +134,9 @@ class LibraryDeleteRoutesMixin:
|
||||
|
||||
async def preview_library_delete(self, request):
|
||||
try:
|
||||
data = await _body(request, _SELECTION_KEYS)
|
||||
data = await _body(request, _SELECTION_KEYS, _SHELF_KEYS)
|
||||
ids = parse_bulk_prompt_ids(data["prompt_ids"])
|
||||
shelf = _shelf(data)
|
||||
except ValueError as exc:
|
||||
return _invalid(str(exc))
|
||||
db = self.db
|
||||
@@ -131,6 +147,7 @@ class LibraryDeleteRoutesMixin:
|
||||
db.classify_library_delete,
|
||||
ids,
|
||||
expected_epoch=data["expected_epoch"],
|
||||
shelf=shelf,
|
||||
)
|
||||
disk = None
|
||||
if (review["delete"] or review["deletable"]) and policy == "on":
|
||||
@@ -165,6 +182,7 @@ class LibraryDeleteRoutesMixin:
|
||||
"archive": review["archive"],
|
||||
"protected": review["protected"],
|
||||
"already_archived": len(review["already_archived"]),
|
||||
"not_archived": len(review["not_archived"]),
|
||||
"not_found": len(review["not_found"]),
|
||||
"image_links": review["image_links"],
|
||||
# #482: the archive ids Delete permanently may delete with pairs.
|
||||
@@ -181,7 +199,8 @@ class LibraryDeleteRoutesMixin:
|
||||
|
||||
async def delete_library_prompts(self, request):
|
||||
try:
|
||||
data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS)
|
||||
data = await _body(request, _DELETE_KEYS, _PAIRED_KEYS, _SHELF_KEYS)
|
||||
shelf = _shelf(data)
|
||||
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:
|
||||
@@ -203,7 +222,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, paired, shelf
|
||||
)
|
||||
if response.status >= 400:
|
||||
job.finish("failed")
|
||||
@@ -211,7 +230,9 @@ 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, paired, shelf="active"
|
||||
):
|
||||
epoch = data["expected_epoch"]
|
||||
promised = data["backup"] == "on" or self._safety_backup_policy() == "on"
|
||||
backup = None
|
||||
@@ -222,6 +243,8 @@ class LibraryDeleteRoutesMixin:
|
||||
db.classify_library_delete,
|
||||
[*delete_ids, *paired],
|
||||
expected_epoch=epoch,
|
||||
# #492: the same shelf, or archived ids would skip the backup.
|
||||
shelf=shelf,
|
||||
)
|
||||
candidates = review["fingerprints"]
|
||||
# #482: a permanent delete of a prompt in a saved pair is backed up too.
|
||||
@@ -248,6 +271,7 @@ class LibraryDeleteRoutesMixin:
|
||||
expected_epoch=epoch,
|
||||
candidates=candidates if candidates is not None else {},
|
||||
delete_paired=paired,
|
||||
shelf=shelf,
|
||||
)
|
||||
except LibraryCommandError as exc:
|
||||
return _storage_changed(exc)
|
||||
|
||||
@@ -96,8 +96,8 @@ function mapDeleteConcept({ danger, review }) {
|
||||
['Backup', 'Create and verify before deleting']] },
|
||||
notes: [
|
||||
{ ...note, text: 'Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the '
|
||||
+ 'pair and its runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, '
|
||||
+ 'select them and press Restore.' },
|
||||
+ 'pair and its runs stay intact. To delete them later or bring them back, press Show archived prompts, select them '
|
||||
+ 'and press Delete… or Restore.' },
|
||||
// A second paragraph takes the concept's own `p + p` margin (style.css 93-95).
|
||||
{ ...note, marginTop: '10px', text: "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and "
|
||||
+ 'image links. Deleted prompts cannot be brought back one by one: the only way back is to restore the safety '
|
||||
|
||||
@@ -94,7 +94,8 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
|
||||
await expect(page.locator('.section-caption > span').first()).toHaveText(/^2 archived prompts · \d+ negative variants? hidden with them$/);
|
||||
await expect(card(page,cat.id).locator('.card-title .chip',{hasText:'Archived'})).toHaveCount(1);
|
||||
await box(page,cat.id).check();
|
||||
await expect(bar(page).getByRole('button')).toHaveText(['Select all 2 loaded','Restore','Clear']);
|
||||
// #492: the archived shelf offers Delete… after Restore.
|
||||
await expect(bar(page).getByRole('button')).toHaveText(['Select all 2 loaded','Restore','Delete…','Clear']);
|
||||
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
@@ -175,6 +176,62 @@ test('the choice resets to Archive on every Delete…, and a selection with no p
|
||||
await expect(choice(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// #492: Delete… on the archived shelf deletes archived prompts permanently, with their own saved pairs; no choice.
|
||||
async function archiveByApi(request,ids) {
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
const preview=await(await request.post(PREVIEW,{data:{prompt_ids:ids,expected_epoch:epoch}})).json();
|
||||
expect(preview.archive).toEqual(ids);
|
||||
await expect(await request.post(DELETE,{data:{archive_ids:ids,delete_ids:[],expected_epoch:epoch,backup:'on'}})).toBeOK();
|
||||
return epoch;
|
||||
}
|
||||
test('Delete… on the archived shelf deletes an archived prompt and its saved pair permanently (#492)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
const epoch=await archiveByApi(request,[dual.id]);
|
||||
const previews=recordPosts(page,PREVIEW),writes=recordPosts(page,DELETE);
|
||||
await page.goto(route);await showArchived(page).click();await expect(card(page,dual.id)).toHaveCount(1);
|
||||
await box(page,dual.id).check();
|
||||
await openReview(page);
|
||||
await expect(review(page)).toHaveAccessibleName('Delete archived prompts');
|
||||
await expect(choice(page)).toHaveCount(0);
|
||||
// A permanent delete only: the review opens on Cancel, and its copy says it cannot be undone and takes the pairs.
|
||||
await expect(review(page).getByRole('button',{name:'Cancel',exact:true})).toBeFocused();
|
||||
await expect(review(page).locator('p.small').first()).toHaveText(/^Deleting a prompt in a saved pair also removes its saved pairs/);
|
||||
await expect(review(page).locator('p.small').nth(1)).toContainText('the only way back is to restore the safety backup taken first');
|
||||
await expect(row(page,'Delete permanently')).toHaveText('1 prompt in a saved pair');
|
||||
await expect(row(page,'Saved pairs removed')).toHaveText(/^1 saved pair/);
|
||||
await expect(row(page,'Backup')).toHaveText('Create and verify before deleting');
|
||||
expect(previews).toEqual([{prompt_ids:[dual.id],expected_epoch:epoch,shelf:'archived'}]);
|
||||
// Cancel writes nothing and returns focus to Delete….
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await expect(review(page)).toHaveCount(0);expect(writes).toEqual([]);
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).toBeFocused();
|
||||
await openReview(page);
|
||||
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: /);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]).toMatchObject({archive_ids:[dual.id],delete_ids:[],delete_paired_ids:[dual.id],shelf:'archived'});
|
||||
await expect(page.locator('.library-status')).toHaveText('No archived prompts.');
|
||||
await backToLibrary(page).click();
|
||||
await expect(showArchived(page)).toHaveText('Show archived prompts (0)');
|
||||
expect(byText(await items(request),'Dual role portrait')).toBeUndefined();
|
||||
expect(await items(request,'archived')).toEqual([]);
|
||||
});
|
||||
|
||||
test('an archived prompt restored since the shelf was read is kept, in a close-only review (#492)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
await archiveByApi(request,[dual.id]);
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
await page.goto(route);await showArchived(page).click();await expect(card(page,dual.id)).toHaveCount(1);
|
||||
await box(page,dual.id).check();
|
||||
// Restored elsewhere after the shelf was read: the review counts it and deletes nothing.
|
||||
await expect(await request.post('/prompt_manager/library/restore',{data:{prompt_ids:[dual.id],expected_epoch:epoch}})).toBeOK();
|
||||
await openReview(page);
|
||||
await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted.');
|
||||
await expect(row(page,'No longer archived \\(kept\\)')).toHaveText('1');
|
||||
await expect(review(page).getByRole('button',{name:/^Delete \d/})).toHaveCount(0);
|
||||
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
|
||||
});
|
||||
|
||||
// #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});
|
||||
|
||||
@@ -64,7 +64,8 @@ test('the sentences explain archive, irreversibility per backup policy, re-creat
|
||||
// Live test (#470): the archive reason is the saved pair, which a prompt with 0 negative variants has too.
|
||||
assert.match(archive, /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the pair and its runs stay intact\./);
|
||||
assert.doesNotMatch(archive, /variant/);
|
||||
assert.match(archive, /Archived prompts cannot be deleted\. To bring them back, press Show archived prompts/);
|
||||
// #492: the archived shelf deletes them too.
|
||||
assert.match(archive, /stay intact\. To delete them later or bring them back, press Show archived prompts, select them and press Delete… or Restore\.$/);
|
||||
assert.match(remove, /the only way back is to restore the safety backup taken first \(System › Backup & export\), which returns the whole library to that moment\. It is kept among the last 10 safety backups\./);
|
||||
assert.match(remove, /A later scan, a new image or a node run with the same text adds it again as a new prompt\.$/);
|
||||
assert.equal(guarded, 'Protected prompts are tagged __protected__; remove the tag to delete them.');
|
||||
@@ -132,8 +133,8 @@ test('a failed preview reloads only after a restore; Restore outcomes have their
|
||||
test('reviewOf keeps only well-formed ids and counts from an answer', async () => {
|
||||
const {reviewOf} = await source;
|
||||
const review = reviewOf({library_epoch: 3, delete: [1, 'x', -2, 2], archive: null, protected: [1.5], already_archived: -1,
|
||||
not_found: 'x', image_links: 2, backup: 'maybe'});
|
||||
assert.deepEqual(review, {epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
|
||||
not_found: 'x', image_links: 2, backup: 'maybe', not_archived: -3});
|
||||
assert.deepEqual(review, {shelf: 'active', notArchived: 0, 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'});
|
||||
});
|
||||
@@ -229,3 +230,64 @@ test('a short disk blocks only the permanent delete; the outcome names removed p
|
||||
assert.equal(toast, 'Deleted 2 prompts. Removed 3 saved pairs. Archived 1 prompt. 1 changed since the review and ' +
|
||||
'was not deleted.');
|
||||
});
|
||||
|
||||
// #492: Delete… on the archived shelf: no choice, only the permanent delete; what cannot be deleted stays archived.
|
||||
const shelfPreview = (patch = {}) => preview({deletable: [], review_digests: {}, paired_counts: {pairs: 0, archived_pairs: 0,
|
||||
variants: 0, kept_negatives: 0}, paired_image_links: 0, not_archived: 0, ...patch});
|
||||
|
||||
test('the archived shelf review is the permanent delete, without the choice', async () => {
|
||||
const {reviewOf, hasPairedChoice, withChoice, reviewTitle, primaryLabel, reviewGrid, deleteBody, blockedReason} = await source;
|
||||
const review = reviewOf(shelfPreview({delete: [1], archive: [2, 3], deletable: [2], review_digests: {2: D1},
|
||||
paired_counts: {pairs: 2, archived_pairs: 1, variants: 1, kept_negatives: 1}, paired_image_links: 1, image_links: 1,
|
||||
not_archived: 1}), 'archived');
|
||||
assert.equal(review.shelf, 'archived');
|
||||
assert.equal(review.choice, 'delete');
|
||||
assert.equal(hasPairedChoice(review), false);
|
||||
assert.equal(withChoice(review, 'archive').choice, 'delete');
|
||||
assert.equal(reviewTitle(review), 'Delete archived prompts');
|
||||
assert.equal(primaryLabel(review), 'Delete 2 prompts');
|
||||
assert.equal(blockedReason(review), null);
|
||||
assert.deepEqual(reviewGrid(review), [
|
||||
['Delete permanently', '2 prompts: 1 not in a saved pair, 1 in a saved pair'],
|
||||
['Saved pairs removed', '2 saved pairs (1 with a negative prompt, 1 archived)'],
|
||||
['Negatives kept', '1 negative prompt is kept'],
|
||||
['Stays archived', "1 prompt used as another prompt's negative"],
|
||||
['No longer archived (kept)', '1'],
|
||||
['Image links removed', '2 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
// Only what it deletes is sent: the stays-archived id is left out.
|
||||
assert.deepEqual(deleteBody(review), {archive_ids: [2], delete_ids: [1], expected_epoch: 'e', backup: 'on',
|
||||
delete_paired_ids: [2], paired_digests: {2: D1}, shelf: 'archived'});
|
||||
});
|
||||
|
||||
test('the archived shelf: unpaired only, nothing deletable, and its sentences', async () => {
|
||||
const {reviewOf, reviewGrid, reviewSentences, primaryLabel, blockedReason, COPY} = await source;
|
||||
const loose = reviewOf(shelfPreview({delete: [1]}), 'archived');
|
||||
assert.deepEqual(reviewGrid(loose).map(([term]) => term), ['Delete permanently', 'Image links removed', 'Backup']);
|
||||
assert.equal(reviewGrid(loose)[0][1], '1 prompt not in a saved pair');
|
||||
assert.equal(primaryLabel(loose), 'Delete 1 prompt');
|
||||
const [remove] = reviewSentences(loose);
|
||||
assert.match(remove, /^Deleting removes each prompt's text/);
|
||||
assert.doesNotMatch(reviewSentences(loose).join(' '), /saved pair/);
|
||||
const stays = reviewOf(shelfPreview({archive: [3], protected: [4]}), 'archived');
|
||||
assert.equal(blockedReason(stays), COPY.noneArchived);
|
||||
assert.equal(COPY.noneArchived, 'None of the selected prompts can be deleted.');
|
||||
const paired = reviewOf(shelfPreview({archive: [2, 3], deletable: [2], review_digests: {2: D1},
|
||||
paired_counts: {pairs: 1, archived_pairs: 0, variants: 0, kept_negatives: 0}}), 'archived');
|
||||
const sentences = reviewSentences(paired);
|
||||
assert.match(sentences[0], /^Deleting a prompt in a saved pair also removes its saved pairs/);
|
||||
assert.equal(sentences.at(-1), "Prompts used as another prompt's negative stay archived, so that prompt's variant stays.");
|
||||
// The active shelf is unchanged: a preview without a shelf is #482's.
|
||||
assert.equal(reviewOf(shelfPreview({archive: [2], deletable: [2], review_digests: {2: D1}})).choice, 'archive');
|
||||
});
|
||||
|
||||
test('the outcome names prompts restored since the review', async () => {
|
||||
const {deleteOutcome} = await source;
|
||||
const outcome = deleteOutcome({success: true, deleted: 1, archived: 0, not_archived: 2, changed: 0}, null, {protected: []});
|
||||
assert.equal(outcome.toast, 'Deleted 1 prompt. 2 were restored since the review and kept.');
|
||||
const none = deleteOutcome({success: true, deleted: 0, archived: 0, not_archived: 1, changed: 1}, null,
|
||||
{protected: [], shelf: 'archived'});
|
||||
assert.equal(none.toast, 'Nothing was deleted. 1 was restored since the review and kept. 1 changed since the review and ' +
|
||||
'was not deleted. The Library was reloaded.');
|
||||
});
|
||||
|
||||
@@ -137,6 +137,7 @@ class TestClassifyAndApply(LibraryDeleteCase):
|
||||
"archive": [positive, both, archived_pair_positive],
|
||||
"protected": [protected, protected_paired],
|
||||
"already_archived": [already],
|
||||
"not_archived": [],
|
||||
"not_found": [negative_only, 99999],
|
||||
"image_links": 1,
|
||||
"fingerprints": result["fingerprints"],
|
||||
@@ -173,6 +174,7 @@ class TestClassifyAndApply(LibraryDeleteCase):
|
||||
"deleted": 1,
|
||||
"archived": 1,
|
||||
"already_archived": 0,
|
||||
"not_archived": 0,
|
||||
"not_found": 0,
|
||||
"changed": 0,
|
||||
"image_links": 1,
|
||||
@@ -726,3 +728,162 @@ class TestPairedDelete(LibraryDeleteCase):
|
||||
candidates={},
|
||||
delete_paired={fox: "not a digest"},
|
||||
)
|
||||
|
||||
|
||||
class TestArchivedShelfDelete(LibraryDeleteCase):
|
||||
"""#492: Delete… on the archived shelf deletes archived prompts permanently."""
|
||||
|
||||
def archive(self, *ids):
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.executemany(
|
||||
f"INSERT INTO {ARCHIVE}(prompt_id, archived_at) VALUES (?, ?)",
|
||||
[(pid, "2026-10-29T00:00:00Z") for pid in ids],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def shelf_classify(self, ids):
|
||||
return self.db.classify_library_delete(
|
||||
ids, expected_epoch=self.epoch, shelf="archived"
|
||||
)
|
||||
|
||||
def shelf_write(self, review, deletes=None, paired=None):
|
||||
"""What the archived shelf's client sends: only what it deletes."""
|
||||
deletes = review["delete"] if deletes is None else deletes
|
||||
paired = review["deletable"] if paired is None else paired
|
||||
return self.db.archive_or_delete_library_prompts(
|
||||
list(paired),
|
||||
list(deletes),
|
||||
expected_epoch=self.epoch,
|
||||
candidates=review["fingerprints"],
|
||||
delete_paired={pid: review["review_digests"][pid] for pid in paired},
|
||||
shelf="archived",
|
||||
)
|
||||
|
||||
def test_classify_on_the_archived_shelf(self):
|
||||
owl, _neg, _pair = self.paired("Owl", "dark")
|
||||
shared = save(self.db, "Shared")
|
||||
self.image(shared, "shared.png")
|
||||
self.db.save_prompt_pair("Dog", "Shared") # another positive's negative
|
||||
loose = save(self.db, "Loose")
|
||||
self.image(loose, "loose.png")
|
||||
guarded = save(self.db, "Guarded", tags=["__protected__"])
|
||||
self.image(guarded, "guarded.png")
|
||||
shown = save(self.db, "Shown")
|
||||
self.image(shown, "shown.png")
|
||||
self.archive(owl, shared, loose, guarded)
|
||||
review = self.shelf_classify([owl, shared, loose, guarded, shown, 999999])
|
||||
self.assertEqual(review["archive"], [owl, shared])
|
||||
self.assertEqual(review["deletable"], [owl])
|
||||
self.assertEqual(review["delete"], [loose])
|
||||
self.assertEqual(review["protected"], [guarded])
|
||||
self.assertEqual(review["not_archived"], [shown])
|
||||
self.assertEqual(review["already_archived"], [])
|
||||
self.assertEqual(review["not_found"], [999999])
|
||||
self.assertEqual(review["image_links"], 1)
|
||||
# The active shelf is unchanged: archived prompts are only counted there.
|
||||
active = self.classify([owl, loose, shown])
|
||||
self.assertEqual(active["already_archived"], [owl, loose])
|
||||
self.assertEqual(active["not_archived"], [])
|
||||
self.assertEqual(active["delete"], [shown])
|
||||
|
||||
def test_the_write_deletes_archived_prompts_and_their_pairs(self):
|
||||
owl, owl_negative, pair = self.paired("Owl", "dark")
|
||||
loose = save(self.db, "Loose")
|
||||
self.image(loose, "loose.png")
|
||||
self.archive(owl, loose)
|
||||
result = self.shelf_write(self.shelf_classify([owl, loose]))
|
||||
self.assertEqual(
|
||||
(result["deleted"], result["archived"], result["changed"]), (2, 0, 0)
|
||||
)
|
||||
self.assertEqual((result["pairs_removed"], result["not_archived"]), (1, 0))
|
||||
self.assertNotIn(owl, prompt_ids(self.db))
|
||||
self.assertNotIn(loose, prompt_ids(self.db))
|
||||
self.assertIn(owl_negative, prompt_ids(self.db))
|
||||
self.assertIsNone(self.db.get_prompt_pair(pair))
|
||||
self.assertEqual(archived_ids(self.db), [])
|
||||
self.assertEqual(rows(self.db, "PRAGMA foreign_key_check"), [])
|
||||
|
||||
def test_drift_leaves_the_prompt_archived_and_untouched(self):
|
||||
owl, _neg, pair = self.paired("Owl", "dark")
|
||||
hen, _hen_negative, hen_pair = self.paired("Hen", "fog")
|
||||
loose = save(self.db, "Loose")
|
||||
self.image(loose, "loose.png")
|
||||
self.archive(owl, hen, loose)
|
||||
review = self.shelf_classify([owl, hen, loose])
|
||||
before = rows(self.db, "SELECT id,hash,updated_at FROM prompts ORDER BY id")
|
||||
self.db.update_prompt_pair_metadata(pair, notes="edited after the review")
|
||||
# Reviewed as unpaired, it gains a pair before the write.
|
||||
self.db.save_prompt_pair("Loose", "grain")
|
||||
# A pair removed since: no longer what the review promised.
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute("DELETE FROM prompt_pairs WHERE id=?", (hen_pair,))
|
||||
conn.commit()
|
||||
result = self.shelf_write(review)
|
||||
self.assertEqual(
|
||||
(result["deleted"], result["archived"], result["changed"]), (0, 0, 3)
|
||||
)
|
||||
self.assertEqual(archived_ids(self.db), sorted([owl, hen, loose]))
|
||||
self.assertEqual(
|
||||
rows(self.db, "SELECT id,hash,updated_at FROM prompts ORDER BY id")[
|
||||
: len(before)
|
||||
],
|
||||
before,
|
||||
)
|
||||
|
||||
def test_a_prompt_restored_since_the_review_is_kept(self):
|
||||
owl, _neg, _pair = self.paired("Owl", "dark")
|
||||
self.archive(owl)
|
||||
review = self.shelf_classify([owl])
|
||||
self.restore([owl])
|
||||
result = self.shelf_write(review)
|
||||
self.assertEqual((result["deleted"], result["not_archived"]), (0, 1))
|
||||
self.assertIn(owl, prompt_ids(self.db))
|
||||
self.assertEqual(archived_ids(self.db), [])
|
||||
|
||||
def test_the_negative_of_a_deleted_positive_stays_archived(self):
|
||||
# #482's rule, kept on this shelf: X is only Dog's negative.
|
||||
dog, shared, _pair = self.paired("Dog", "Shared")
|
||||
self.image(shared, "shared.png")
|
||||
self.archive(dog, shared)
|
||||
review = self.shelf_classify([dog, shared])
|
||||
self.assertEqual(review["deletable"], [dog])
|
||||
self.assertEqual(review["archive"], [dog, shared])
|
||||
result = self.shelf_write(review)
|
||||
self.assertEqual((result["deleted"], result["pairs_removed"]), (1, 1))
|
||||
self.assertEqual(archived_ids(self.db), [shared])
|
||||
|
||||
def test_mixed_outcomes_are_each_counted_once(self):
|
||||
owl, _neg, _pair = self.paired("Owl", "dark")
|
||||
hen, _neg2, hen_pair = self.paired("Hen", "fog")
|
||||
loose = save(self.db, "Loose")
|
||||
self.image(loose, "loose.png")
|
||||
guarded = save(self.db, "Guarded", tags=["__protected__"])
|
||||
self.image(guarded, "guarded.png")
|
||||
gone = save(self.db, "Gone")
|
||||
self.image(gone, "gone.png")
|
||||
self.archive(owl, hen, loose, guarded, gone)
|
||||
review = self.shelf_classify([owl, hen, loose, guarded, gone])
|
||||
self.db.update_prompt_pair_metadata(hen_pair, notes="drifted")
|
||||
self.restore([loose])
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute("DELETE FROM generated_images WHERE prompt_id=?", (gone,))
|
||||
conn.execute("DELETE FROM prompts WHERE id=?", (gone,))
|
||||
conn.commit()
|
||||
result = self.shelf_write(review, deletes=[loose, gone])
|
||||
self.assertEqual(
|
||||
{k: result[k] for k in ("deleted", "changed", "not_found", "not_archived")},
|
||||
{"deleted": 1, "changed": 1, "not_found": 1, "not_archived": 1},
|
||||
)
|
||||
self.assertNotIn(owl, prompt_ids(self.db))
|
||||
self.assertEqual(archived_ids(self.db), sorted([hen, guarded]))
|
||||
|
||||
def test_shelf_must_be_active_or_archived(self):
|
||||
owl, _neg, _pair = self.paired("Owl")
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.classify_library_delete(
|
||||
[owl], expected_epoch=self.epoch, shelf="trash"
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_or_delete_library_prompts(
|
||||
[owl], [], expected_epoch=self.epoch, candidates={}, shelf="trash"
|
||||
)
|
||||
|
||||
@@ -402,6 +402,98 @@ class TestPairedDeleteRoutes(LibraryDeleteRouteCase):
|
||||
self.assertIn(cat, self.ids())
|
||||
|
||||
|
||||
class TestArchivedShelfDeleteRoutes(LibraryDeleteRouteCase):
|
||||
"""#492: Delete… on the archived shelf (``shelf: "archived"``)."""
|
||||
|
||||
def archive(self, *ids):
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.executemany(
|
||||
f"INSERT INTO {ARCHIVE}(prompt_id, archived_at) VALUES (?, ?)",
|
||||
[(pid, "2026-10-29T00:00:00Z") for pid in ids],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def shelf_review(self, ids):
|
||||
status, body = await self.post(
|
||||
PREVIEW,
|
||||
{"prompt_ids": ids, "expected_epoch": self.epoch(), "shelf": "archived"},
|
||||
)
|
||||
self.assertEqual(status, 200, body)
|
||||
return body
|
||||
|
||||
async def shelf_confirm(self, review):
|
||||
paired = review["deletable"]
|
||||
return await self.post(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": paired,
|
||||
"delete_ids": review["delete"],
|
||||
"expected_epoch": review["library_epoch"],
|
||||
"backup": review["backup"],
|
||||
"delete_paired_ids": paired,
|
||||
"paired_digests": {
|
||||
str(pid): review["review_digests"][str(pid)] for pid in paired
|
||||
},
|
||||
"shelf": "archived",
|
||||
},
|
||||
)
|
||||
|
||||
async def test_an_archived_paired_prompt_is_deleted_after_a_backup(self):
|
||||
self.policy(True)
|
||||
cat = self.paired("Cat")
|
||||
shown = self.save("Shown")
|
||||
self.api.db.link_image_to_prompt(shown, "/tmp/shown-492.png")
|
||||
self.archive(cat)
|
||||
review = await self.shelf_review([cat, shown])
|
||||
self.assertEqual(
|
||||
(review["deletable"], review["delete"], review["not_archived"]),
|
||||
([cat], [], 1),
|
||||
)
|
||||
self.assertTrue(review["disk"]["sufficient"])
|
||||
status, body = await self.shelf_confirm(review)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(
|
||||
(body["deleted"], body["archived"], body["pairs_removed"]), (1, 0, 1)
|
||||
)
|
||||
# The held write classified the archived shelf too, so it backed up.
|
||||
self.assertEqual(self.backups(), [body["backup"]])
|
||||
self.assertNotIn(cat, self.ids())
|
||||
self.assertEqual(self.archived(), [])
|
||||
|
||||
async def test_an_archived_unpaired_prompt_is_deleted_after_a_backup(self):
|
||||
self.policy(True)
|
||||
loose = self.save("Loose")
|
||||
self.api.db.link_image_to_prompt(loose, "/tmp/loose-492.png")
|
||||
self.archive(loose)
|
||||
review = await self.shelf_review([loose])
|
||||
self.assertEqual(review["delete"], [loose])
|
||||
status, body = await self.shelf_confirm(review)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual((body["deleted"], body["changed"]), (1, 0))
|
||||
self.assertEqual(self.backups(), [body["backup"]])
|
||||
|
||||
async def test_the_shelf_is_validated_and_optional(self):
|
||||
cat = self.paired("Cat")
|
||||
status, body = await self.post(
|
||||
PREVIEW,
|
||||
{"prompt_ids": [cat], "expected_epoch": self.epoch(), "shelf": "trash"},
|
||||
)
|
||||
self.assertEqual((status, body["code"]), (400, "invalid-request"))
|
||||
review = await self.review([cat])
|
||||
base = {
|
||||
"archive_ids": review["archive"],
|
||||
"delete_ids": review["delete"],
|
||||
"expected_epoch": review["library_epoch"],
|
||||
"backup": "off",
|
||||
}
|
||||
status, body = await self.post(DELETE, {**base, "shelf": 1})
|
||||
self.assertEqual((status, body["code"]), (400, "invalid-request"))
|
||||
# The active shelf named explicitly is #443's delete.
|
||||
status, body = await self.post(DELETE, {**base, "shelf": "active"})
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(self.archived(), [cat])
|
||||
|
||||
|
||||
class TestIngestionAfterArchive(LibraryDeleteRouteCase):
|
||||
def scan(self, name, positive, negative="blur"):
|
||||
meta = {"prompt": json.dumps(graph(positive, negative))}
|
||||
|
||||
@@ -25,6 +25,7 @@ export const COPY = Object.freeze({
|
||||
failed: 'The selected prompts could not be changed. Try again.',
|
||||
nothing: `${NOTHING} The Library was reloaded.`,
|
||||
none: 'None of the selected prompts can be deleted or archived.',
|
||||
noneArchived: 'None of the selected prompts can be deleted.',
|
||||
restoreStorage: 'The library was restored or moved since this page loaded. No prompt was returned to the Library.',
|
||||
restoreUnconfirmed: 'Returning the prompts could not be confirmed. The Library was reloaded.',
|
||||
restoreFailed: 'The prompts could not be returned to the Library. Try again.',
|
||||
@@ -36,17 +37,23 @@ const ids = value => (Array.isArray(value) ? value.filter(id => Number.isSafeInt
|
||||
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) {
|
||||
/**
|
||||
* The preview's classes as counts and id lists, whatever else the answer holds. On the archived `shelf` (#492) the
|
||||
* review is the permanent delete only: the choice is fixed to `delete`.
|
||||
*/
|
||||
export function reviewOf(preview, shelf = 'active') {
|
||||
const archivedShelf = shelf === 'archived';
|
||||
return {
|
||||
shelf: archivedShelf ? 'archived' : 'active', notArchived: count(preview?.not_archived),
|
||||
epoch: typeof preview?.library_epoch === 'string' ? preview.library_epoch : null,
|
||||
delete: ids(preview?.delete), archive: ids(preview?.archive), protected: ids(preview?.protected),
|
||||
alreadyArchived: count(preview?.already_archived), notFound: count(preview?.not_found),
|
||||
imageLinks: count(preview?.image_links), backup: preview?.backup === 'off' ? 'off' : 'on',
|
||||
disk: preview?.disk && typeof preview.disk === 'object' ? preview.disk : null,
|
||||
...pairedOf(preview),
|
||||
...pairedOf(preview), ...(archivedShelf ? { choice: 'delete' } : {}),
|
||||
};
|
||||
}
|
||||
const onArchivedShelf = review => review.shelf === 'archived';
|
||||
|
||||
/** #482: the archive ids that may be deleted permanently (each with its review digest), and the pair counts. */
|
||||
function pairedOf(preview) {
|
||||
@@ -62,10 +69,11 @@ function pairedOf(preview) {
|
||||
}
|
||||
|
||||
/** #482: whether the review asks what to do with prompts in a saved pair. */
|
||||
export const hasPairedChoice = review => review.deletable.length > 0;
|
||||
export const hasPairedChoice = review => !onArchivedShelf(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) {
|
||||
if (onArchivedShelf(review)) return { ...review, choice: 'delete' };
|
||||
return { ...review, choice: choice === 'delete' && hasPairedChoice(review) ? 'delete' : 'archive' };
|
||||
}
|
||||
|
||||
@@ -79,6 +87,7 @@ function plan(review) {
|
||||
|
||||
export function reviewTitle(review) {
|
||||
const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length;
|
||||
if (onArchivedShelf(review)) return 'Delete archived prompts';
|
||||
if (!archive.length) return 'Delete selected prompts';
|
||||
if (!deletes) return 'Archive selected prompts';
|
||||
return 'Delete or archive selected prompts';
|
||||
@@ -119,6 +128,7 @@ function keptRows(rows, 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.notArchived) rows.push(['No longer archived (kept)', review.notArchived.toLocaleString('en-US')]);
|
||||
if (review.notFound) rows.push(['No longer exist', review.notFound.toLocaleString('en-US')]);
|
||||
}
|
||||
|
||||
@@ -137,10 +147,14 @@ function permanentGrid(review, titleOf) {
|
||||
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`]);
|
||||
const rows = [['Delete permanently', paired.length ? deleted : `${countOf(total, 'prompt')} not in a saved pair`]];
|
||||
// #492: on the archived shelf a selection may hold no prompt in a saved pair.
|
||||
if (paired.length) {
|
||||
rows.push(['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 && onArchivedShelf(review)) rows.push(['Stays archived', `${countOf(archive.length, 'prompt')} used as another prompt's negative`]);
|
||||
else 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;
|
||||
@@ -152,8 +166,8 @@ export function reviewSentences(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.');
|
||||
'pair and its runs stay intact. To delete them later or bring them back, press Show archived prompts, select them ' +
|
||||
'and press Delete… or Restore.');
|
||||
}
|
||||
if (review.delete.length) sentences.push(removeSentence(review));
|
||||
if (review.protected.length) sentences.push(PROTECTED_SENTENCE);
|
||||
@@ -174,10 +188,12 @@ function removeSentence(review, pairs = false) {
|
||||
|
||||
/** #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, " +
|
||||
const { paired, archive } = plan(review);
|
||||
const sentences = paired.length ? ['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)] : [removeSentence(review)];
|
||||
if (archive.length && onArchivedShelf(review)) sentences.push("Prompts used as another prompt's negative stay archived, so that prompt's variant stays.");
|
||||
else if (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;
|
||||
@@ -186,12 +202,15 @@ function permanentSentences(review) {
|
||||
export function primaryLabel(review) {
|
||||
const { plain, paired, archive } = plan(review);
|
||||
const deletes = plain.length + paired.length, archives = archive.length;
|
||||
// #492: on the archived shelf what cannot be deleted stays archived; the primary names only the deletes.
|
||||
if (onArchivedShelf(review)) return `Delete ${countOf(deletes, 'prompt')}`;
|
||||
if (deletes && archives) return `Delete ${deletes.toLocaleString('en-US')} and archive ${countOf(archives, 'prompt')}`;
|
||||
return deletes ? `Delete ${countOf(deletes, 'prompt')}` : `Archive ${countOf(archives, 'prompt')}`;
|
||||
}
|
||||
|
||||
/** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */
|
||||
export function blockedReason(review) {
|
||||
if (onArchivedShelf(review) && !review.delete.length && !review.deletable.length) return COPY.noneArchived;
|
||||
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) {
|
||||
@@ -221,7 +240,8 @@ export function openDeleteReview({ scope, trigger, review, titleOf, onStart, onD
|
||||
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 });
|
||||
// #492: the archived shelf's review is only a permanent delete, so it opens on Cancel.
|
||||
focusCancel: onArchivedShelf(current), onStart: () => onStart(current), onDismiss });
|
||||
function update(value) {
|
||||
current = withChoice(review, value);
|
||||
dialog.setRows(reviewGrid(current, titleOf));
|
||||
@@ -267,6 +287,11 @@ function pairedChoice(onChange) {
|
||||
/** 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 };
|
||||
// #492: the archived shelf sends only what it deletes (its prompts are archived already).
|
||||
if (onArchivedShelf(review)) {
|
||||
return { ...body, archive_ids: review.deletable, delete_paired_ids: review.deletable, paired_digests: review.digests,
|
||||
shelf: 'archived' };
|
||||
}
|
||||
// #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;
|
||||
@@ -298,9 +323,12 @@ export function deleteOutcome(result, failure, review) {
|
||||
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);
|
||||
// #492: the archived shelf archives nothing, so it says only that nothing was deleted.
|
||||
if (!parts.length) parts.push(review?.shelf === 'archived' ? 'Nothing was deleted.' : 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.`);
|
||||
const restored = count(result.not_archived);
|
||||
if (restored) parts.push(`${were(restored, 'was', 'were')} restored since the review and kept.`);
|
||||
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.`);
|
||||
const kept = review?.protected?.length || 0;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { COPY as DELETE_COPY, DELETE_LABEL, DELETE_URL, PREVIEW_URL, REVIEWING,
|
||||
*
|
||||
* Delete… (#443, D152) previews the selection, opens its review (`library-delete.mjs`) and posts the reviewed lists:
|
||||
* unpaired prompts are deleted, paired ones archived, protected ones kept. On the archived shelf (`getShelf()` is
|
||||
* 'archived') the bar offers Restore instead of the other actions.
|
||||
* 'archived') the bar offers Restore and Delete… (#492: a permanent delete only) instead of the other actions.
|
||||
*/
|
||||
export const CATEGORY_URL = '/prompt_manager/bulk/category';
|
||||
export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' });
|
||||
@@ -168,7 +168,8 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
const shelf = getShelf() === 'archived' ? 'archived' : 'active';
|
||||
if (shelf !== shownShelf) {
|
||||
shownShelf = shelf;
|
||||
bulk.replaceChildren(count, ...(shelf === 'archived' ? [restoreButton] : [addTags, setCategory, deleteButton]), clearButton);
|
||||
// #492: the archived shelf deletes too, after Restore.
|
||||
bulk.replaceChildren(count, ...(shelf === 'archived' ? [restoreButton, deleteButton] : [addTags, setCategory, deleteButton]), clearButton);
|
||||
}
|
||||
const label = selectionLabel(checked.size);
|
||||
if (count.textContent !== label) count.textContent = label;
|
||||
@@ -271,10 +272,12 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
async function openDelete() {
|
||||
const chosen = target(DELETE_COPY.reviewFailed);
|
||||
if (!chosen) return;
|
||||
// #492: the shelf the selection was made on; the archived one reviews a permanent delete only.
|
||||
const shelf = getShelf() === 'archived' ? 'archived' : 'active';
|
||||
lock(REVIEWING);
|
||||
let preview = null, failure = null;
|
||||
try {
|
||||
preview = await api.post(PREVIEW_URL, chosen, { signal: scope.signal });
|
||||
preview = await api.post(PREVIEW_URL, shelf === 'archived' ? { ...chosen, shelf } : chosen, { signal: scope.signal });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
@@ -286,7 +289,7 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
if (outcome.reload) onReload();
|
||||
return;
|
||||
}
|
||||
const review = { ...reviewOf(preview), epoch: chosen.expected_epoch };
|
||||
const review = { ...reviewOf(preview, shelf), epoch: chosen.expected_epoch };
|
||||
// The review is open: the bar stays held behind it, under its own name again.
|
||||
deleteButton.textContent = DELETE_LABEL;
|
||||
openDeleteReview({ scope, trigger: deleteButton, review, titleOf: nameOf,
|
||||
|
||||
@@ -477,7 +477,7 @@ function requireGrid(grid) {
|
||||
* an option, and `{setTitle(term, text)}` for a value's hover title.
|
||||
*/
|
||||
export function openReviewDialog({ scope, title, heading = null, grid, options = [], sentence = null, backup = null,
|
||||
cancelLabel, primary, onStart, trigger = null, onDismiss = null }) {
|
||||
cancelLabel, primary, onStart, trigger = null, onDismiss = null, focusCancel = false }) {
|
||||
requireScope(scope);
|
||||
requireCallbacks({ onStart, ...(onDismiss ? { onDismiss } : {}) });
|
||||
for (const [name, text] of Object.entries({ title, cancelLabel, label: primary?.label })) {
|
||||
@@ -519,7 +519,9 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
|
||||
actions.append(cancel, start);
|
||||
nodes.push(actions);
|
||||
// Cancel, ×, Escape, a replacing dialog or the page leaving: everything but the primary is a dismissal.
|
||||
show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null, review: true });
|
||||
// `focusCancel` (#492): a review whose primary cannot be undone opens on Cancel instead of ×.
|
||||
show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null, review: true,
|
||||
...(focusCancel ? { focusTarget: cancel } : {}) });
|
||||
return {
|
||||
setValue(term, text) {
|
||||
const dd = values.get(term);
|
||||
|
||||
Reference in New Issue
Block a user