Compare commits

..
Author SHA1 Message Date
Vito Sansevero 87166539e8 test(workspace): separate sidebar count reads from listing reads (#547)
Visiting Overview re-reads the counts, so two specs recording listing requests
also saw its limit=1 reads. They now record listings only.
2026-10-30 14:34:38 -07:00
Vito Sansevero 23c01455fa fix(workspace): Overview cards show the sidebar's counts (#547)
Overview read /prompt_manager/stats (raw rows) while the sidebar reads each
page's listing total, so the same name showed different numbers. The cards now
show the shell's counts, without a second Images scan. Stats stays for the
legacy page.
2026-10-30 13:58:08 -07:00
Vito a6d6129ec6 fix(library): keep the prompt page heading on one row; no history subtitle on a deleted prompt's page (#524) (#526)
* fix(library): keep the prompt page heading on one row; no history subtitle on a deleted prompt's page (#524)

From 951px the heading actions keep one row and a long title clamps to two lines beside them (D164). A deleted prompt's page hides the Prompt history description.

* fix(library): drop the CSS file name from a comment the consumer check reads (#524)
2026-10-30 12:16:55 +00:00
Vito e8d96a0f55 fix(library): delete review and prompt page polish from the live test, and a More menu on the prompt page (#512) (#522)
* feat(library): set the category on many selected prompts (#437)

The Prompt library shelf gets the concept's Images selection checkbox on each
card and its .bulkbar (N selected, Set category, Clear), recorded as D148.
Set category opens the shared one-value dialog and posts
{prompt_ids, category, expected_epoch} to POST /prompt_manager/bulk/category:
the server checks the storage epoch the shelf was read under, then sets the
category on the Library parents in one BEGIN IMMEDIATE transaction, writing
only rows that change. A restored storage answers 409 storage-changed and
writes nothing. The legacy admin and images_filter bodies are unchanged.

This replaces the legacy admin page's bulk Set Category, a cutover
prerequisite (#334).

Closes #437

* feat(library): add tags to many selected prompts and select every loaded prompt (#442)

The Library selection bar (D148) gets Add tags and select-all of the loaded
shelf, the legacy admin page's bulk Add Tags and select-all checkbox.

Add tags posts {prompt_ids, tags, expected_epoch} to
POST /prompt_manager/bulk/tags: in one BEGIN IMMEDIATE transaction the server
checks the storage epoch, normalizes the names as image tags are, gives each
Library parent only the names it lacks (casefold), finds or inserts tag rows
by exact name only when some prompt gets them, and skips prompts that would
pass 20 tags. Prompt tags only. Both bulk/tags forms refuse a non-object body
with 400.

Ctrl/Cmd+A in the grid and "Select all N loaded" in the bar select every
loaded prompt; the offer follows each loaded batch.

Closes #442

* feat(vocabulary): tag search, tag sort and an Untagged prompts entry (#444)

Tags & categories gains a server-side tag search and sort (keyset pages,
cursor scoped to the query) and an "Untagged prompts" row whose Browse opens
the Library with tag_mode=untagged. Refine this view gets Tag match, saved
Library views keep it, and the routes echo the applied query so a stale
server is an error, never an unfiltered list. Recorded as D149.

Refs #444

* feat(library): filter prompts by output folder and browse tags with Any (#444)

Refine this view on the Prompt library gains the concept's Folder (folders
below an output root, read when the dialog opens) and a Tag match select
(All / Any). The listing takes folder and tag_mode, matches folders on the
normalized file_path under every spelling of each output root, echoes both
so a stale server is an error, and saved Library views keep them. Recorded
as D150.

Refs #444

* docs(library): record Add tags and select-all as D151 (#442)

* feat(library): archive paired and delete unpaired selected prompts on the server (#443)

A prompt_archive table marks Library parents hidden from the shelf; older
builds ignore it. New routes /prompt_manager/library/delete/preview,
/library/delete and /library/restore classify a selection (delete,
archive, protected, already archived, not found), execute exactly what was
reviewed under a records hold, take a verified safety backup (disk checked)
before any delete, and restore archived prompts. The shelf, search and the
shelf-facing counts honor the archive (?shelf=active|archived); archiving
repairs a stale prompt hash so scans find the archived row.

* feat(library): Delete… on the selection bar, Show archived prompts and Restore (#443)

The Library bar's Delete… previews the selection and opens the shared
review: what is deleted (in no saved pair), archived (paired: hidden,
restorable) or kept (tagged __protected__), the safety backup and what
cannot be undone. Show archived prompts (N) lists archived prompts with
Restore on their bar; an archived prompt's page shows an Archived notice
with Restore. TESTING.md records D152; the route snapshot gains the three
workspace routes.

* test(library): cover the Delete… review failure and close-only paths; record coverage floors (#443)

* fix(library): let saved-view defaults fill the missing #444 filters

libraryViewState passed `?? 'all'` and `?? ''` fallbacks that
normalizeViewState already applies to an undefined value. The redundant
branches were never taken in the page, which dropped saved-views.mjs below
its 95% branch floor. A unit now covers a shelf without the new filters.

Refs #444

* fix(library): lay out a loaded page of prompts without waiting for an animation frame (#443)

A starved renderer (headless WebKit under CPU load, a background tab) can
hold requestAnimationFrame for seconds. The shelf laid out each loaded page
only in its next frame, so the grid stayed empty after the first load or a
delete's reload while the caption already showed the new count. A loaded
batch is now laid out at once; measurements still flush per frame.

The regression test holds every animation frame and fails before the fix.

* test(saved-views): give the D153 Library view the tag_mode key (#444)

The Library saved-view state now holds tag_mode, and the API requires
every key, so main's new "Highest rated" spec posts it too.

Refs #444

* test(saved-views): give the D153 Library view the folder key (#444)

Refs #444

* fix(library): name the saved pair, not negative variants, as the archive reason (#443)

A prompt in a saved pair with no negative variants was described in the
Delete… review as "1 prompt with negative variants: hidden, restorable".
Paired prompts are archived because of the pair, so the Archive row now
reads "N prompts in a saved pair: hidden, restorable" and the first
sentence explains the pair. Found in the live test of #470.

* fix(library): nest Refine folders by path segment; backslash separates only on Windows (#444)

Live test B1: the Folder list indented a server list sorted as full-path
text, so "-" and "_" sorted before "/" and children landed under the wrong
parent (kiko/char under kiko-v6), with case variants interleaved. The
options are now a tree built by segment: each folder directly under its own
parent, case variants distinct siblings.

On POSIX a folder named "Metadata\Klein..." was listed but filtered to no
prompts, because normalize_folder read "\" as a separator. It now does so
only on Windows (ntpath), as the listing already did.

Refs #444

* fix(library): say why an archived prompt is archived, and name variants only when it has any (#443)

The archived prompt page's notice always said "This prompt and its
negative variants are hidden", also for a saved-pair prompt with none.
It now says the prompt is archived because it is in a saved pair, and
counts its negative variants only when there are any. From the live
test of #470.

* feat(library): delete a prompt in a saved pair permanently, with its own pairs (#482, server)

The Delete review's preview now marks which archive ids may be deleted
permanently (in a saved pair, and nobody else's negative), with a review
digest of the prompt and its pairs and the counts the review shows. The
write accepts an optional delete_paired_ids with their digests. Each is
deleted with its own saved pairs (image-pair links, then pairs, then the
prompt as #443 deletes it) only while its digest is unchanged. Any drift
archives it and counts it changed. Paired deletes are backed up and
disk-checked like deletes.

* feat(library): ask Archive or Delete permanently for prompts in a saved pair (#482, review)

The Delete… review asks, for prompts in a saved pair, Archive (checked
on every open, D152's review) or Delete permanently, with their saved
pairs, in a D90 radio row. The choice rewrites the grid, sentences, title
and primary in place (openReviewDialog setRows/setPrimary), and the body
carries the chosen ids with their review digests. On the archived shelf,
Show archived prompts now reads the concept's way back, "← Prompt
library", as a plain button. TESTING.md records D154.

* docs(testing): number #482's deviation D160 (D154-D159 are taken)

* Revert "docs(testing): number #482's deviation D160 (D154-D159 are taken)"

This reverts commit 96c2a293a4.

* docs(testing): number #482's deviation D160 (D154-D159 are taken)

* feat(library): Delete prompt… on a prompt's page (#481)

The recipe editor's actions read Create text revision, Archive variant…
(the selected negative variant's archive, renamed from Archive… so the two
danger buttons cannot be confused) and Delete prompt…, which closes the
editor and runs the Library's Delete… review for that prompt: the same
preview, saved-pair choice (Archive stays the default), backup and write.
A delete opens the Prompt library; an archive reads the page again.

Recorded as D161 in TESTING.md and in the editor concept comparison.

* fix(library): hold Edit metadata while a prompt's page delete runs (#481)

While the page's Delete… review or its write runs, Edit metadata is
aria-disabled and its click ignored, so no editor save can race the delete.
Adds tests for a failed review, a write answered after the page was left
(no toast, no jump, the shelf reads again) and heading focus after an
archive.

* feat(library): delete archived prompts from the archived shelf (#492)

The archived shelf's bar offers Delete… after Restore. Its review is the
permanent delete only (no Archive choice: the prompts are archived
already), with #482's grid, safety backup, review digests, records hold
and epoch fence. A prompt used as another prompt's negative stays
archived; one restored since the shelf was read is kept and counted.

The preview and delete routes take an optional shelf ('active' or
'archived'). The held write classifies the same shelf, so the permanent
delete is backed up; on this shelf the write archives and rewrites
nothing and counts every reviewed id it did not delete as changed.

* fix(library): say only 'Nothing was deleted.' on the archived shelf (#492)

Review LOWs: the archived shelf archives nothing, so its empty outcome no
longer says 'Nothing was deleted or archived.'; a mixed write (deleted,
drifted, protected, gone and restored ids) counts each once.

* fix(library): open the archived shelf's delete review on Cancel (#492)

Its only action is a permanent delete, so focus starts on Cancel
(openReviewDialog's focusCancel); the spec also pins that the copy says
the delete cannot be undone and removes the prompt's saved pairs.

* fix(library): delete review and prompt page polish from the live test (#512)

- A prompt page whose prompt no longer exists says so, with Open the
  Prompt library and no Retry.
- The Delete review leaves out rows with nothing in them.
- A review from a prompt's page is titled Delete this prompt or Archive
  this prompt.
- An archived prompt used as another prompt's negative is named so.
- D162: a More menu button after Send to ComfyUI holds Delete prompt...,
  an accessible menu that fits 390px; Edit metadata keeps its entry.
- Item 5 already holds after #505; a regression test pins it.

* fix(library): act on a loaded prompt only, keep focus on a gone page, close the menu on focus loss (#512)

Review fixes: Edit metadata and the More menu are disabled while the
prompt loads or failed to load; on a gone prompt's page a focused control
hands focus to Open the Prompt library; the menu closes when focus leaves
it. A unit pins that a paired prompt with a missing digest is not called
another prompt's negative.

* docs(testing): number #512's deviation D163 (D162 is #514's)

* test(library): drive every More menu key and drop its unused close export (#512)

CI's coverage gate found more-menu.mjs functions at 71.42% (floor 80): the
ArrowUp-on-button, ArrowUp/Home/End-in-menu and Tab paths were untested and
an unused close() export counted as uncovered. The delete spec now drives
them; measured locally on that spec: 100% lines and functions.
2026-10-30 10:51:40 +00:00
Vito d0134b2d13 feat(library): delete archived prompts from the archived shelf (#492) (#509)
* feat(library): set the category on many selected prompts (#437)

The Prompt library shelf gets the concept's Images selection checkbox on each
card and its .bulkbar (N selected, Set category, Clear), recorded as D148.
Set category opens the shared one-value dialog and posts
{prompt_ids, category, expected_epoch} to POST /prompt_manager/bulk/category:
the server checks the storage epoch the shelf was read under, then sets the
category on the Library parents in one BEGIN IMMEDIATE transaction, writing
only rows that change. A restored storage answers 409 storage-changed and
writes nothing. The legacy admin and images_filter bodies are unchanged.

This replaces the legacy admin page's bulk Set Category, a cutover
prerequisite (#334).

Closes #437

* feat(library): add tags to many selected prompts and select every loaded prompt (#442)

The Library selection bar (D148) gets Add tags and select-all of the loaded
shelf, the legacy admin page's bulk Add Tags and select-all checkbox.

Add tags posts {prompt_ids, tags, expected_epoch} to
POST /prompt_manager/bulk/tags: in one BEGIN IMMEDIATE transaction the server
checks the storage epoch, normalizes the names as image tags are, gives each
Library parent only the names it lacks (casefold), finds or inserts tag rows
by exact name only when some prompt gets them, and skips prompts that would
pass 20 tags. Prompt tags only. Both bulk/tags forms refuse a non-object body
with 400.

Ctrl/Cmd+A in the grid and "Select all N loaded" in the bar select every
loaded prompt; the offer follows each loaded batch.

Closes #442

* feat(vocabulary): tag search, tag sort and an Untagged prompts entry (#444)

Tags & categories gains a server-side tag search and sort (keyset pages,
cursor scoped to the query) and an "Untagged prompts" row whose Browse opens
the Library with tag_mode=untagged. Refine this view gets Tag match, saved
Library views keep it, and the routes echo the applied query so a stale
server is an error, never an unfiltered list. Recorded as D149.

Refs #444

* feat(library): filter prompts by output folder and browse tags with Any (#444)

Refine this view on the Prompt library gains the concept's Folder (folders
below an output root, read when the dialog opens) and a Tag match select
(All / Any). The listing takes folder and tag_mode, matches folders on the
normalized file_path under every spelling of each output root, echoes both
so a stale server is an error, and saved Library views keep them. Recorded
as D150.

Refs #444

* docs(library): record Add tags and select-all as D151 (#442)

* feat(library): archive paired and delete unpaired selected prompts on the server (#443)

A prompt_archive table marks Library parents hidden from the shelf; older
builds ignore it. New routes /prompt_manager/library/delete/preview,
/library/delete and /library/restore classify a selection (delete,
archive, protected, already archived, not found), execute exactly what was
reviewed under a records hold, take a verified safety backup (disk checked)
before any delete, and restore archived prompts. The shelf, search and the
shelf-facing counts honor the archive (?shelf=active|archived); archiving
repairs a stale prompt hash so scans find the archived row.

* feat(library): Delete… on the selection bar, Show archived prompts and Restore (#443)

The Library bar's Delete… previews the selection and opens the shared
review: what is deleted (in no saved pair), archived (paired: hidden,
restorable) or kept (tagged __protected__), the safety backup and what
cannot be undone. Show archived prompts (N) lists archived prompts with
Restore on their bar; an archived prompt's page shows an Archived notice
with Restore. TESTING.md records D152; the route snapshot gains the three
workspace routes.

* test(library): cover the Delete… review failure and close-only paths; record coverage floors (#443)

* fix(library): let saved-view defaults fill the missing #444 filters

libraryViewState passed `?? 'all'` and `?? ''` fallbacks that
normalizeViewState already applies to an undefined value. The redundant
branches were never taken in the page, which dropped saved-views.mjs below
its 95% branch floor. A unit now covers a shelf without the new filters.

Refs #444

* fix(library): lay out a loaded page of prompts without waiting for an animation frame (#443)

A starved renderer (headless WebKit under CPU load, a background tab) can
hold requestAnimationFrame for seconds. The shelf laid out each loaded page
only in its next frame, so the grid stayed empty after the first load or a
delete's reload while the caption already showed the new count. A loaded
batch is now laid out at once; measurements still flush per frame.

The regression test holds every animation frame and fails before the fix.

* test(saved-views): give the D153 Library view the tag_mode key (#444)

The Library saved-view state now holds tag_mode, and the API requires
every key, so main's new "Highest rated" spec posts it too.

Refs #444

* test(saved-views): give the D153 Library view the folder key (#444)

Refs #444

* fix(library): name the saved pair, not negative variants, as the archive reason (#443)

A prompt in a saved pair with no negative variants was described in the
Delete… review as "1 prompt with negative variants: hidden, restorable".
Paired prompts are archived because of the pair, so the Archive row now
reads "N prompts in a saved pair: hidden, restorable" and the first
sentence explains the pair. Found in the live test of #470.

* fix(library): nest Refine folders by path segment; backslash separates only on Windows (#444)

Live test B1: the Folder list indented a server list sorted as full-path
text, so "-" and "_" sorted before "/" and children landed under the wrong
parent (kiko/char under kiko-v6), with case variants interleaved. The
options are now a tree built by segment: each folder directly under its own
parent, case variants distinct siblings.

On POSIX a folder named "Metadata\Klein..." was listed but filtered to no
prompts, because normalize_folder read "\" as a separator. It now does so
only on Windows (ntpath), as the listing already did.

Refs #444

* fix(library): say why an archived prompt is archived, and name variants only when it has any (#443)

The archived prompt page's notice always said "This prompt and its
negative variants are hidden", also for a saved-pair prompt with none.
It now says the prompt is archived because it is in a saved pair, and
counts its negative variants only when there are any. From the live
test of #470.

* feat(library): delete a prompt in a saved pair permanently, with its own pairs (#482, server)

The Delete review's preview now marks which archive ids may be deleted
permanently (in a saved pair, and nobody else's negative), with a review
digest of the prompt and its pairs and the counts the review shows. The
write accepts an optional delete_paired_ids with their digests. Each is
deleted with its own saved pairs (image-pair links, then pairs, then the
prompt as #443 deletes it) only while its digest is unchanged. Any drift
archives it and counts it changed. Paired deletes are backed up and
disk-checked like deletes.

* feat(library): ask Archive or Delete permanently for prompts in a saved pair (#482, review)

The Delete… review asks, for prompts in a saved pair, Archive (checked
on every open, D152's review) or Delete permanently, with their saved
pairs, in a D90 radio row. The choice rewrites the grid, sentences, title
and primary in place (openReviewDialog setRows/setPrimary), and the body
carries the chosen ids with their review digests. On the archived shelf,
Show archived prompts now reads the concept's way back, "← Prompt
library", as a plain button. TESTING.md records D154.

* docs(testing): number #482's deviation D160 (D154-D159 are taken)

* Revert "docs(testing): number #482's deviation D160 (D154-D159 are taken)"

This reverts commit 96c2a293a4.

* docs(testing): number #482's deviation D160 (D154-D159 are taken)

* feat(library): Delete prompt… on a prompt's page (#481)

The recipe editor's actions read Create text revision, Archive variant…
(the selected negative variant's archive, renamed from Archive… so the two
danger buttons cannot be confused) and Delete prompt…, which closes the
editor and runs the Library's Delete… review for that prompt: the same
preview, saved-pair choice (Archive stays the default), backup and write.
A delete opens the Prompt library; an archive reads the page again.

Recorded as D161 in TESTING.md and in the editor concept comparison.

* fix(library): hold Edit metadata while a prompt's page delete runs (#481)

While the page's Delete… review or its write runs, Edit metadata is
aria-disabled and its click ignored, so no editor save can race the delete.
Adds tests for a failed review, a write answered after the page was left
(no toast, no jump, the shelf reads again) and heading focus after an
archive.

* feat(library): delete archived prompts from the archived shelf (#492)

The archived shelf's bar offers Delete… after Restore. Its review is the
permanent delete only (no Archive choice: the prompts are archived
already), with #482's grid, safety backup, review digests, records hold
and epoch fence. A prompt used as another prompt's negative stays
archived; one restored since the shelf was read is kept and counted.

The preview and delete routes take an optional shelf ('active' or
'archived'). The held write classifies the same shelf, so the permanent
delete is backed up; on this shelf the write archives and rewrites
nothing and counts every reviewed id it did not delete as changed.

* fix(library): say only 'Nothing was deleted.' on the archived shelf (#492)

Review LOWs: the archived shelf archives nothing, so its empty outcome no
longer says 'Nothing was deleted or archived.'; a mixed write (deleted,
drifted, protected, gone and restored ids) counts each once.

* fix(library): open the archived shelf's delete review on Cancel (#492)

Its only action is a permanent delete, so focus starts on Cancel
(openReviewDialog's focusCancel); the spec also pins that the copy says
the delete cannot be undone and removes the prompt's saved pairs.
2026-10-30 04:18:01 +00:00
Vito 9af3271dbb feat(images): Show removed images and Return to library (#498) (#514)
* fix(images): background jobs skip files hidden by the removal mark; walkers skip trash folders inside output roots (#501)

- Thumbnail jobs and the legacy scan_output_dir listing leave
  library-removed files out (one mark check, mark_hides).
- Missing-file links also forgets removal marks that hide nothing any
  more (file gone from a folder that exists, or changed), only under a
  readable, non-empty source, re-checked at apply.
- The sidebar Images, Favorites and Needs review counts and the
  collection counts are read again when an image_delete job ends.
- utils/media_folders.py: one skip for thumbnails and trash folders
  (.Trash, .Trash-<uid>, .Trashes, $RECYCLE.BIN) used by every walker,
  the image monitor and the delete review.

* feat(images): Show removed images and Return to library (#498)

- GET /images/output?mode=workspace&removed=1 lists only files a removal
  mark hides; every workspace page carries removed_total.
- POST /images/removed/return {items | filter} starts a scan job,
  "Return images to the library": the marks that still hide their file
  go in one transaction, then exactly those files are scanned, so their
  prompt links come back from their metadata. Tags, categories,
  collections and favorites do not come back.
- The marks generation moves again after the commit, so a reader that
  cached the marks mid-transaction reloads them.
- Images: Show removed images (N) heading toggle (D162), Removed chips,
  a bar with only Return to library and Clear, Return to library in the
  side panel; the view is never saved or restored.

* test(images): record the return route in the legacy contract snapshot; removed_total in the workspace keys (#498)

The scan runner passes returned only when set, so a stand-in events
function without that keyword still runs.

* test(browser): wait for the answers two specs raced in the merge queue (#501)

- System diagnostics "level filter": wait for each level's own log
  answer, and poll the shown levels; the CRITICAL line is in the
  previous level's answer too, so it proved nothing (firefox).
- Library editor "restored storage after a confirmed save": wait for
  the PATCH answer, which waits for a whole restore in the test's
  route, instead of the 5 s expect default (webkit).

* fix(monitor): skip trash folders only below the watched folder (#501 review)

A source named .Trash-1000 (or another trash name) was ignored by the
image monitor while every walker still listed it; the monitor now tests
the folders below the longest watched folder holding the new file.

* fix(images): no default removedView function in the selection bar (#498)

The default arrow was never called, so images-selection.mjs fell under
its function coverage floor (97.29 < 98).

* fix(images): a category or tags chosen in Show removed images ends the view (#498 review)

The toolbar's Category and Refine's Tags stayed usable in the removed
view, and the listing then refused the combination with a page error.
Choosing one now leaves the view, as a removed file has neither.
2026-10-30 03:44:03 +00:00
Vito 98ddded73c feat(library): Delete prompt… on a prompt's page (#481) (#507)
* feat(library): set the category on many selected prompts (#437)

The Prompt library shelf gets the concept's Images selection checkbox on each
card and its .bulkbar (N selected, Set category, Clear), recorded as D148.
Set category opens the shared one-value dialog and posts
{prompt_ids, category, expected_epoch} to POST /prompt_manager/bulk/category:
the server checks the storage epoch the shelf was read under, then sets the
category on the Library parents in one BEGIN IMMEDIATE transaction, writing
only rows that change. A restored storage answers 409 storage-changed and
writes nothing. The legacy admin and images_filter bodies are unchanged.

This replaces the legacy admin page's bulk Set Category, a cutover
prerequisite (#334).

Closes #437

* feat(library): add tags to many selected prompts and select every loaded prompt (#442)

The Library selection bar (D148) gets Add tags and select-all of the loaded
shelf, the legacy admin page's bulk Add Tags and select-all checkbox.

Add tags posts {prompt_ids, tags, expected_epoch} to
POST /prompt_manager/bulk/tags: in one BEGIN IMMEDIATE transaction the server
checks the storage epoch, normalizes the names as image tags are, gives each
Library parent only the names it lacks (casefold), finds or inserts tag rows
by exact name only when some prompt gets them, and skips prompts that would
pass 20 tags. Prompt tags only. Both bulk/tags forms refuse a non-object body
with 400.

Ctrl/Cmd+A in the grid and "Select all N loaded" in the bar select every
loaded prompt; the offer follows each loaded batch.

Closes #442

* feat(vocabulary): tag search, tag sort and an Untagged prompts entry (#444)

Tags & categories gains a server-side tag search and sort (keyset pages,
cursor scoped to the query) and an "Untagged prompts" row whose Browse opens
the Library with tag_mode=untagged. Refine this view gets Tag match, saved
Library views keep it, and the routes echo the applied query so a stale
server is an error, never an unfiltered list. Recorded as D149.

Refs #444

* feat(library): filter prompts by output folder and browse tags with Any (#444)

Refine this view on the Prompt library gains the concept's Folder (folders
below an output root, read when the dialog opens) and a Tag match select
(All / Any). The listing takes folder and tag_mode, matches folders on the
normalized file_path under every spelling of each output root, echoes both
so a stale server is an error, and saved Library views keep them. Recorded
as D150.

Refs #444

* docs(library): record Add tags and select-all as D151 (#442)

* feat(library): archive paired and delete unpaired selected prompts on the server (#443)

A prompt_archive table marks Library parents hidden from the shelf; older
builds ignore it. New routes /prompt_manager/library/delete/preview,
/library/delete and /library/restore classify a selection (delete,
archive, protected, already archived, not found), execute exactly what was
reviewed under a records hold, take a verified safety backup (disk checked)
before any delete, and restore archived prompts. The shelf, search and the
shelf-facing counts honor the archive (?shelf=active|archived); archiving
repairs a stale prompt hash so scans find the archived row.

* feat(library): Delete… on the selection bar, Show archived prompts and Restore (#443)

The Library bar's Delete… previews the selection and opens the shared
review: what is deleted (in no saved pair), archived (paired: hidden,
restorable) or kept (tagged __protected__), the safety backup and what
cannot be undone. Show archived prompts (N) lists archived prompts with
Restore on their bar; an archived prompt's page shows an Archived notice
with Restore. TESTING.md records D152; the route snapshot gains the three
workspace routes.

* test(library): cover the Delete… review failure and close-only paths; record coverage floors (#443)

* fix(library): let saved-view defaults fill the missing #444 filters

libraryViewState passed `?? 'all'` and `?? ''` fallbacks that
normalizeViewState already applies to an undefined value. The redundant
branches were never taken in the page, which dropped saved-views.mjs below
its 95% branch floor. A unit now covers a shelf without the new filters.

Refs #444

* fix(library): lay out a loaded page of prompts without waiting for an animation frame (#443)

A starved renderer (headless WebKit under CPU load, a background tab) can
hold requestAnimationFrame for seconds. The shelf laid out each loaded page
only in its next frame, so the grid stayed empty after the first load or a
delete's reload while the caption already showed the new count. A loaded
batch is now laid out at once; measurements still flush per frame.

The regression test holds every animation frame and fails before the fix.

* test(saved-views): give the D153 Library view the tag_mode key (#444)

The Library saved-view state now holds tag_mode, and the API requires
every key, so main's new "Highest rated" spec posts it too.

Refs #444

* test(saved-views): give the D153 Library view the folder key (#444)

Refs #444

* fix(library): name the saved pair, not negative variants, as the archive reason (#443)

A prompt in a saved pair with no negative variants was described in the
Delete… review as "1 prompt with negative variants: hidden, restorable".
Paired prompts are archived because of the pair, so the Archive row now
reads "N prompts in a saved pair: hidden, restorable" and the first
sentence explains the pair. Found in the live test of #470.

* fix(library): nest Refine folders by path segment; backslash separates only on Windows (#444)

Live test B1: the Folder list indented a server list sorted as full-path
text, so "-" and "_" sorted before "/" and children landed under the wrong
parent (kiko/char under kiko-v6), with case variants interleaved. The
options are now a tree built by segment: each folder directly under its own
parent, case variants distinct siblings.

On POSIX a folder named "Metadata\Klein..." was listed but filtered to no
prompts, because normalize_folder read "\" as a separator. It now does so
only on Windows (ntpath), as the listing already did.

Refs #444

* fix(library): say why an archived prompt is archived, and name variants only when it has any (#443)

The archived prompt page's notice always said "This prompt and its
negative variants are hidden", also for a saved-pair prompt with none.
It now says the prompt is archived because it is in a saved pair, and
counts its negative variants only when there are any. From the live
test of #470.

* feat(library): delete a prompt in a saved pair permanently, with its own pairs (#482, server)

The Delete review's preview now marks which archive ids may be deleted
permanently (in a saved pair, and nobody else's negative), with a review
digest of the prompt and its pairs and the counts the review shows. The
write accepts an optional delete_paired_ids with their digests. Each is
deleted with its own saved pairs (image-pair links, then pairs, then the
prompt as #443 deletes it) only while its digest is unchanged. Any drift
archives it and counts it changed. Paired deletes are backed up and
disk-checked like deletes.

* feat(library): ask Archive or Delete permanently for prompts in a saved pair (#482, review)

The Delete… review asks, for prompts in a saved pair, Archive (checked
on every open, D152's review) or Delete permanently, with their saved
pairs, in a D90 radio row. The choice rewrites the grid, sentences, title
and primary in place (openReviewDialog setRows/setPrimary), and the body
carries the chosen ids with their review digests. On the archived shelf,
Show archived prompts now reads the concept's way back, "← Prompt
library", as a plain button. TESTING.md records D154.

* docs(testing): number #482's deviation D160 (D154-D159 are taken)

* Revert "docs(testing): number #482's deviation D160 (D154-D159 are taken)"

This reverts commit 96c2a293a4.

* docs(testing): number #482's deviation D160 (D154-D159 are taken)

* feat(library): Delete prompt… on a prompt's page (#481)

The recipe editor's actions read Create text revision, Archive variant…
(the selected negative variant's archive, renamed from Archive… so the two
danger buttons cannot be confused) and Delete prompt…, which closes the
editor and runs the Library's Delete… review for that prompt: the same
preview, saved-pair choice (Archive stays the default), backup and write.
A delete opens the Prompt library; an archive reads the page again.

Recorded as D161 in TESTING.md and in the editor concept comparison.

* fix(library): hold Edit metadata while a prompt's page delete runs (#481)

While the page's Delete… review or its write runs, Edit metadata is
aria-disabled and its click ignored, so no editor save can race the delete.
Adds tests for a failed review, a write answered after the page was left
(no toast, no jump, the shelf reads again) and heading focus after an
archive.
2026-10-30 03:28:19 +00:00
Vito abd26bb275 test(collections): wait for the restore job, not 5 s; no hard-coded saved-view id (#520) (#521)
The restore test waited 5 s for a whole restore. Under load (webkit,
merge queue of #514) the restore outlasted it and kept running into the
next tests on the worker, replacing their database. It now waits for
the restore job's event stream to end, as the backups spec does. The
route-change test used saved view 5, which a worker can have made.
2026-10-30 02:53:32 +00:00
Vito b12a9a14ba feat(library): delete a prompt in a saved pair permanently, with its own saved pairs (#482) (#505)
* feat(library): set the category on many selected prompts (#437)

The Prompt library shelf gets the concept's Images selection checkbox on each
card and its .bulkbar (N selected, Set category, Clear), recorded as D148.
Set category opens the shared one-value dialog and posts
{prompt_ids, category, expected_epoch} to POST /prompt_manager/bulk/category:
the server checks the storage epoch the shelf was read under, then sets the
category on the Library parents in one BEGIN IMMEDIATE transaction, writing
only rows that change. A restored storage answers 409 storage-changed and
writes nothing. The legacy admin and images_filter bodies are unchanged.

This replaces the legacy admin page's bulk Set Category, a cutover
prerequisite (#334).

Closes #437

* feat(library): add tags to many selected prompts and select every loaded prompt (#442)

The Library selection bar (D148) gets Add tags and select-all of the loaded
shelf, the legacy admin page's bulk Add Tags and select-all checkbox.

Add tags posts {prompt_ids, tags, expected_epoch} to
POST /prompt_manager/bulk/tags: in one BEGIN IMMEDIATE transaction the server
checks the storage epoch, normalizes the names as image tags are, gives each
Library parent only the names it lacks (casefold), finds or inserts tag rows
by exact name only when some prompt gets them, and skips prompts that would
pass 20 tags. Prompt tags only. Both bulk/tags forms refuse a non-object body
with 400.

Ctrl/Cmd+A in the grid and "Select all N loaded" in the bar select every
loaded prompt; the offer follows each loaded batch.

Closes #442

* feat(vocabulary): tag search, tag sort and an Untagged prompts entry (#444)

Tags & categories gains a server-side tag search and sort (keyset pages,
cursor scoped to the query) and an "Untagged prompts" row whose Browse opens
the Library with tag_mode=untagged. Refine this view gets Tag match, saved
Library views keep it, and the routes echo the applied query so a stale
server is an error, never an unfiltered list. Recorded as D149.

Refs #444

* feat(library): filter prompts by output folder and browse tags with Any (#444)

Refine this view on the Prompt library gains the concept's Folder (folders
below an output root, read when the dialog opens) and a Tag match select
(All / Any). The listing takes folder and tag_mode, matches folders on the
normalized file_path under every spelling of each output root, echoes both
so a stale server is an error, and saved Library views keep them. Recorded
as D150.

Refs #444

* docs(library): record Add tags and select-all as D151 (#442)

* feat(library): archive paired and delete unpaired selected prompts on the server (#443)

A prompt_archive table marks Library parents hidden from the shelf; older
builds ignore it. New routes /prompt_manager/library/delete/preview,
/library/delete and /library/restore classify a selection (delete,
archive, protected, already archived, not found), execute exactly what was
reviewed under a records hold, take a verified safety backup (disk checked)
before any delete, and restore archived prompts. The shelf, search and the
shelf-facing counts honor the archive (?shelf=active|archived); archiving
repairs a stale prompt hash so scans find the archived row.

* feat(library): Delete… on the selection bar, Show archived prompts and Restore (#443)

The Library bar's Delete… previews the selection and opens the shared
review: what is deleted (in no saved pair), archived (paired: hidden,
restorable) or kept (tagged __protected__), the safety backup and what
cannot be undone. Show archived prompts (N) lists archived prompts with
Restore on their bar; an archived prompt's page shows an Archived notice
with Restore. TESTING.md records D152; the route snapshot gains the three
workspace routes.

* test(library): cover the Delete… review failure and close-only paths; record coverage floors (#443)

* fix(library): let saved-view defaults fill the missing #444 filters

libraryViewState passed `?? 'all'` and `?? ''` fallbacks that
normalizeViewState already applies to an undefined value. The redundant
branches were never taken in the page, which dropped saved-views.mjs below
its 95% branch floor. A unit now covers a shelf without the new filters.

Refs #444

* fix(library): lay out a loaded page of prompts without waiting for an animation frame (#443)

A starved renderer (headless WebKit under CPU load, a background tab) can
hold requestAnimationFrame for seconds. The shelf laid out each loaded page
only in its next frame, so the grid stayed empty after the first load or a
delete's reload while the caption already showed the new count. A loaded
batch is now laid out at once; measurements still flush per frame.

The regression test holds every animation frame and fails before the fix.

* test(saved-views): give the D153 Library view the tag_mode key (#444)

The Library saved-view state now holds tag_mode, and the API requires
every key, so main's new "Highest rated" spec posts it too.

Refs #444

* test(saved-views): give the D153 Library view the folder key (#444)

Refs #444

* fix(library): name the saved pair, not negative variants, as the archive reason (#443)

A prompt in a saved pair with no negative variants was described in the
Delete… review as "1 prompt with negative variants: hidden, restorable".
Paired prompts are archived because of the pair, so the Archive row now
reads "N prompts in a saved pair: hidden, restorable" and the first
sentence explains the pair. Found in the live test of #470.

* fix(library): nest Refine folders by path segment; backslash separates only on Windows (#444)

Live test B1: the Folder list indented a server list sorted as full-path
text, so "-" and "_" sorted before "/" and children landed under the wrong
parent (kiko/char under kiko-v6), with case variants interleaved. The
options are now a tree built by segment: each folder directly under its own
parent, case variants distinct siblings.

On POSIX a folder named "Metadata\Klein..." was listed but filtered to no
prompts, because normalize_folder read "\" as a separator. It now does so
only on Windows (ntpath), as the listing already did.

Refs #444

* fix(library): say why an archived prompt is archived, and name variants only when it has any (#443)

The archived prompt page's notice always said "This prompt and its
negative variants are hidden", also for a saved-pair prompt with none.
It now says the prompt is archived because it is in a saved pair, and
counts its negative variants only when there are any. From the live
test of #470.

* feat(library): delete a prompt in a saved pair permanently, with its own pairs (#482, server)

The Delete review's preview now marks which archive ids may be deleted
permanently (in a saved pair, and nobody else's negative), with a review
digest of the prompt and its pairs and the counts the review shows. The
write accepts an optional delete_paired_ids with their digests. Each is
deleted with its own saved pairs (image-pair links, then pairs, then the
prompt as #443 deletes it) only while its digest is unchanged. Any drift
archives it and counts it changed. Paired deletes are backed up and
disk-checked like deletes.

* feat(library): ask Archive or Delete permanently for prompts in a saved pair (#482, review)

The Delete… review asks, for prompts in a saved pair, Archive (checked
on every open, D152's review) or Delete permanently, with their saved
pairs, in a D90 radio row. The choice rewrites the grid, sentences, title
and primary in place (openReviewDialog setRows/setPrimary), and the body
carries the chosen ids with their review digests. On the archived shelf,
Show archived prompts now reads the concept's way back, "← Prompt
library", as a plain button. TESTING.md records D154.

* docs(testing): number #482's deviation D160 (D154-D159 are taken)

* Revert "docs(testing): number #482's deviation D160 (D154-D159 are taken)"

This reverts commit 96c2a293a4.

* docs(testing): number #482's deviation D160 (D154-D159 are taken)
2026-10-30 02:35:52 +00:00
Vito bb744550f4 fix(tests): pin glibc's mmap threshold in the browser test server (#516) (#519)
Each tests/browser/server.py grew from about 13 MB to 0.6-1 GB RSS over
a page-workflow shard while its Python heap stayed near 12 MB. glibc
raises its mmap threshold (up to 32 MiB) after a large free, so the
seeder's image-sized buffers came from per-thread malloc arenas, which
fragmented and kept their pages. The server now fixes the threshold at
glibc's 128 KiB start value (mallopt M_MMAP_THRESHOLD) before it starts,
so large blocks go back to the OS on free. No memory limit is set; off
glibc it does nothing.

Closes #516
2026-10-30 01:43:33 +00:00
Vito 2650621a1b test(thumbnails): store the job id before the cancel test's first write cancels (#504) (#506)
The patched writer cancelled job_ids[0] after the first preview, but the id
was stored only after the POST response was read. A fast worker wrote first,
hit IndexError, cancelled nothing and the job finished. The first write now
waits for the id on an Event the test sets after storing it.
2026-10-30 01:28:44 +00:00
Vito d6371b7fad feat(images): move deleted image files to the macOS Trash (#499) (#503)
* feat(images): move deleted image files to the macOS Trash (#499)

Trash mode on macOS now calls -[NSFileManager trashItemAtURL:resultingItemURL:error:]
through ctypes (no dependency), in utils/trash_macos.py. Before any move it refuses
what it cannot show to be recoverable, already in the review dialog: network
volumes, read-only volumes, iCloud Drive and File Provider folders (cloud-sync),
a trash folder inside an output root (by file identity), and files macOS would
refuse for permission (no-permission). A refused move keeps the file and logs the
NSError; a move macOS reports done but whose place cannot be shown goes under
errors as trash-unconfirmed, keeps its records and stops the job.

The native proof runs in portability.yml's new native-trash job on macos-latest
and macos-15-intel (PM_NATIVE_TRASH=macos). Windows still reports no-trash (#500).

* test(trash): resolve the temp folder and wait for a busy image to be released (#499)

The permission tests keyed their lstat fakes by the unresolved temp path, but the
backend resolves paths (/var -> /private/var on macOS, 8.3 short names on Windows),
so on those runners the fakes never matched (portability run 40954618922; reproduced
on Linux with a symlinked TMPDIR). The native test's detach first proves this
process holds nothing under the mount, then retries the plain detach until macOS
releases a volume it has just written a trash folder to (macos-15-intel), and
still fails after a forced detach.

* test(trash): count a volume that unmounted after a busy detach as detached (#499)

On macos-15-intel a detach that reported "Resource busy" finished afterwards, so
the retry found no such volume (portability run 40957282805). The loop now checks
os.path.ismount before each attempt.
2026-10-30 00:55:09 +00:00
Vito 75cd9c6e13 test(browser): record each worker's browser log and attach it when a target closes (#513) (#518)
A browser that died between two tests left the next one only
'Target page, context or browser has been closed': Playwright logs a
browser's stderr and exit only on the pw:browser debug channel, which CI
leaves off. Each worker now keeps that channel as a bounded tail, prints
one pm-browser-exit| line on an abnormal exit and attaches the tail as
browser-stderr.txt to a test that fails on a closed or crashed target.
Nothing is retried or hidden.

Refs #513
2026-10-30 00:42:10 +00:00
Vito 77f08782f4 fix(tagging): keep tags typed before the image observation answers (#515) (#517)
The Tag image field is editable while the dialog observes the file, but the
observation's answer overwrote it with the saved set, so typing then Save
wrote the old tags ("0 tags saved"). The controller now reads the draft when
the answer arrives: text typed during a first observation (or before Retry
of a failed one) follows the saved tags, and a kept draft takes the text
typed while the reopen observes.
2026-10-30 00:35:48 +00:00
69 changed files with 5010 additions and 299 deletions
+28
View File
@@ -26,6 +26,9 @@ env:
# Measured 2026-10-29 on main runs 40935337099 (dd247e3), 40938407252 (dd247e3), 40941240624 (dd247e3):
# slowest leg: 7m24s (py3.12 windows) / 7m31s (py3.13 windows) / 7m32s (py3.14 windows) -> 15 min
# (windows 4m56s-7m32s, macos 4m39s-6m14s)
# native-trash (#499) has no green main runs yet: its probe, run 40949370943 (2026-10-29), took
# about 1 minute per macOS leg, so ceil(1.5 x 2) + 3 gives it 6 minutes (step 4). Recalibrate
# after three green runs.
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
@@ -76,3 +79,28 @@ jobs:
env:
PM_TEST_SLOWEST: "40"
run: python -m tests.run_suite -v
# The real Trash on both macOS CPUs (#499 design §10.2). It runs apart from the suite, so a red
# suite never skips the native proof.
native-trash:
name: native trash on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 6
strategy:
fail-fast: false
matrix:
os: [macos-latest, macos-15-intel]
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Native trash proof
timeout-minutes: 4
env:
PM_NATIVE_TRASH: macos
run: python -m unittest tests.test_trash_macos_native tests.test_trash_macos -v
+189 -12
View File
@@ -53,6 +53,14 @@ stdin open and the server (`--exit-on-stdin-eof`) shuts down cleanly, removing i
temp root, when that pipe closes, so a worker killed without teardown leaves no
server behind. A test that fails gets the stderr its server wrote during the test
attached to the report as `server-stderr` (an aiohttp traceback behind a 500).
Each worker also records its browser's own log (#513, `tools/vendor/browser-logs.mjs`):
Playwright's `pw:browser` channel, kept as a 300-line tail without printing it
unless `DEBUG` asks for it. A browser that exits abnormally writes one
`pm-browser-exit| browser pid=<n> exited: exitCode=<c>, signal=<s>` line to the job
log, and a test that fails on a closed or crashed target ("Target page, context or
browser has been closed") gets the tail attached as `browser-stderr.txt`, which the
uploaded test-results carry. `tests/browser/browser-logs.spec.mjs` kills a browser of
its own on purpose, so each browser job logs that line once for it.
A `/__test__/.../reset` still running after 2 s writes a report to the job log,
passing test or not: every thread's stack taken while it waits, the default
executor's threads and queue, the open connections and WAL size, then the total
@@ -1394,6 +1402,13 @@ seeds two seedless files (`seedless_images` on `/__test__/concept/reset`, with
Tag) and asserts that every row reserves the tallest footer and that their own footer
is shorter than the row it reserves.
### Overview counts (#547)
Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source,
so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads
the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts.
Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`.
### Images page controls and recorded deviations (#249 PR C)
The Images page renders the concept's DOM with real data. Its controls, top to bottom:
@@ -2479,22 +2494,65 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
`$RECYCLE.BIN` (any case), as none descends `thumbnails`: the workspace and legacy
listings (`_walk_media`, `_settled_entry`), the metadata scan and duplicate find
(`_collect_media_files`), the legacy auto-tag listing (`_collect_scan_files`) and the
delete review (`trash-folder`). The image monitor tests every folder of the new file's
absolute path, so it also ignores outputs written into a source that itself lies
inside such a folder. Tests: `tests/test_media_folders.py`,
delete review (`trash-folder`), and the image monitor, which tests the folders below the
watched folder holding the new file (a source named like a trash folder is still watched). Tests: `tests/test_media_folders.py`,
`tests/test_image_removal_gaps.py`.
- **Trash backends** (`utils/trash.py`, no dependency): Linux and BSD implement the
freedesktop Trash specification (home trash on the same mount, else the mount's
`.Trash/$uid` or `.Trash-$uid`), reserve the name with `O_EXCL` and move with
`renameat2(RENAME_NOREPLACE)`; without it, across devices, on a read-only mount or with
a trash inside an output root the move is refused (`no-trash`, `trash-in-root`). Windows
a trash inside an output root the move is refused (`no-trash`, `trash-in-root`). macOS
(#499, `utils/trash_macos.py`) calls `-[NSFileManager trashItemAtURL:resultingItemURL:error:]`
through `ctypes` (ten fixed `objc_msgSend` prototypes, nil checks before every send, one
autorelease pool per call, loaded on first use). It answers in the review dialog, without
writing: `network` (not a local volume), `no-trash` (read-only, or no trash folder
macOS would use), `cloud-sync` (iCloud Drive, `~/Library/CloudStorage`, or ubiquitous),
`trash-in-root` (the trash folder is inside an output root, compared by `(st_dev,
st_ino)` of every ancestor) and `no-permission` (folder not writable, an immutable or
append flag, a foreign file in a sticky folder). A fresh volume's `.Trashes/<uid>` is
predicted (`URLForDirectory` answers 3328/`ENOENT` until macOS makes it). A refused move
keeps the file and logs the `NSError`; a move macOS reports done but whose place cannot be
shown (no result URL, or one inside an output root) goes under `errors` as
`trash-unconfirmed`, keeps its records and stops the job. Windows
never uses the Recycle Bin (#500, maintainer ruling (a)): files larger than the bin's
capacity are deleted permanently and IFileOperation's `PreDeleteItem` guard cannot detect
it (native probe runs 40951037575, 40953347904), so every file answers `windows-recycle`
("Moving files to the Recycle Bin isn't available on Windows: files larger than the bin's
capacity would be deleted permanently."). macOS reports `no-trash` until #499 lands;
library-only removal works everywhere. Tests use `tests/fake_trash.py`; the browser test server installs it, so no
test touches a real trash.
capacity would be deleted permanently."). Library-only removal works everywhere. Tests use `tests/fake_trash.py`; the
browser test server installs it, so no test touches a real trash. The native proof,
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` in
portability.yml's `native-trash` job (`macos-latest` and `macos-15-intel`): the startup
volume, a name clash, fresh APFS and FAT32 disk images, an image used as an output root,
a read-only image and an unwritable folder (design and probe:
`docs/egm/499-macos-trash/`, local).
- **D162** (#498, maintainer ruling Q1 of #480; the concept has no screen for removed images, so this follows D152's
Show archived prompts): **Show removed images (N)** in the Images heading actions, before Import, a toggle
(`aria-pressed`) shown only while N > 0 or the view is on. N is `removed_total`, every removed image in the selected
source(s) whatever the filters (D152 counts the matching ones). The view (`GET /images/output?mode=workspace&
removed=1`, refused with `favorites`, `collection`, `category`, `tags` or `sort=rating`; a cursor never crosses
views) lists only files a removal mark hides, each card with a "Removed" `.chip` and without the star or Tag action;
the caption reads "N removed images · files on disk, hidden from Images", the empty view "No removed images." /
"No removed images match.". Entering clears Category and Tags; choosing a category or tags while it is on
(toolbar, Refine this view) ends the view. A card's image and Info both inspect (the viewer is
not opened: its Collections… and Delete… would write); the panel shows Return to library in Delete…'s place and
no Collections… or tag-filter chips. The bar reads "N selected", Return to library, Clear. The view is never saved
or restored: leaving the page, Browse or a saved view ends it, and a return that leaves N = 0 ends it too.
- **Return to library** (no review: nothing is lost or moved, as D152's Restore) posts `{items | filter}` to
`POST /prompt_manager/images/removed/return` (only this route takes `filter.removed`; every other bulk route
refuses it). 202 `{job, returned, not_removed}` (400 `no-images` when none is hidden now, `too-many-images` over
500; 409 `busy` beside a scan, restore or delete, nothing changed). The job is a `scan`, "Return images to the
library": its first step deletes, in one transaction, the marks that still hide their file (same size and
`mtime_ns`; the marks generation moves again after the commit), then it scans exactly those files with the scan's
pipeline and groups. Toasts: "Returning N images to the library…", then "Returned N images to the library; K
linked to their prompts. Tags, categories, collections and favorites do not come back." When it ends, the
sidebar's Needs review, Library and Images counts are read again (`JOB_END_COUNTS.scan`).
- What comes back: the file in Images; its prompt, links, pairs and Needs review items from its embedded metadata
(a file without usable metadata comes back unlinked); previews on demand. Not: image tags, auto-tag marks, own
category, collection memberships, favorite (only a safety-backup restore has them). If the server stops between
the mark delete and the scan, the files are back unlinked and a "new files" scan links them.
Tests: `tests/test_image_return.py`, `tests/js/workspace-images-return.test.js`,
`tests/browser/workspace-images-removed.spec.mjs`. A Python change: ComfyUI needs a restart.
### Prompt library cards and recorded deviations (#249 PR E)
@@ -2622,7 +2680,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
@@ -2759,14 +2817,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
@@ -2794,6 +2853,124 @@ failed backup, busy, unconfirmed, restore fence); `tests/browser/library-delete-
`tests/test_prompt_archive_schema.py`, `tests/test_bulk_library_delete.py`,
`tests/test_library_delete_routes.py`. A Python change: ComfyUI needs a restart.
### Deleting a prompt in a saved pair permanently (#482)
**D160** (beyond legacy, maintainer ruling 2026-10-29; follow-up to D152): when the Delete… selection
holds prompts in a saved pair that no other positive uses as its negative ("deletable"), the review
asks, in a D90 radio row "Prompts in a saved pair" after the grid: **Archive (hidden, restorable)**
(checked on every open, never remembered, so the review is D152's) or **Delete permanently, with
their saved pairs**. Choosing it rewrites the review in place (`openReviewDialog` `setRows`,
`setPrimary`):
- Delete permanently "D prompts: X not in a saved pair, Y in a saved pair" (or "Y prompts in a saved
pair"), Saved pairs removed "S saved pairs (V with a negative prompt, A archived)", Negatives kept
"K negative prompts are kept", Archive "R prompts used as another prompt's negative: hidden,
restorable", then D152's protected, already archived, no longer exist, Image links removed (images
of every deleted prompt) and Backup rows.
- Sentences: the pairs go with their titles, categories, ratings, notes, tags, run counts and image
links; their negative prompts stay; recorded runs stay in History and Activity by their text; D152's
irreversibility (per backup policy) and re-creation sentences, plus "A pair node run with the same
text saves a new pair."; and, when R > 0, why those are archived.
- Primary (`danger`): "Delete D prompts" / "Delete D and archive R prompts"; a short disk keeps it
unavailable (`aria-disabled`) with the disk sentence, while Archive still starts.
The preview adds `deletable` (a subset of `archive`, which keeps every archive id for D152 clients),
`review_digests` (sha256 of each deletable prompt's row and its pairs: negative, archived flag,
metadata, usage, tags, image-pair links), `paired_counts` and `paired_image_links`, and plans the
disk whenever anything could be deleted. The write takes optional `delete_paired_ids` with their
`paired_digests` (both or neither; ids must be reviewed archive ids). In one transaction, after the
archives, each chosen prompt whose digest is unchanged loses its image-pair links, then its own saved
pairs (pair tags cascade; review decisions keep their rows with `pair_id` cleared), then itself exactly
as D152 deletes. Any drift (pair added or edited, new link, pairs gone, now another prompt's negative)
archives it and counts it `changed`. A paired delete is backed up and disk-checked as a delete. The
toast adds "Removed S saved pairs."; "N changed since the review and was not deleted." replaces "…was
kept.". Negatives are never deleted: one no remaining pair uses is listed in the Library afterwards (as
after the legacy pair delete). Legacy still refuses paired prompts (409).
Tests: `tests/test_bulk_library_delete.py` `TestPairedDelete` (classes and counts, the 7896 empty
negative, the row diff against the legacy pair deletes then prompt delete, archive-only, a positive
with its own negative, a self-pair, each drift, validation); `tests/test_library_delete_routes.py`
`TestPairedDeleteRoutes` (preview, paired-only backup, a D152 body, validation, short disk); units
`tests/js/workspace-library-delete.test.js`, `tests/js/workspace-toolbar.test.js` (`setRows`,
`setPrimary`); browser `tests/browser/workspace-library-delete.spec.mjs` (the choice, its grid and
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
change: ComfyUI needs a restart.
### Delete… on 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
editor (Edit metadata, the concept's app.js 976 row "Create text revision", "Archive…", Cancel, Save) reads "Create
text revision", **"Archive variant…"** (the selected negative variant's archive, #235, which used the concept's
"Archive…" label) and **"Delete prompt…"** (danger), then Cancel and Save. "Delete prompt…" closes the editor and runs
D152/D160's Delete… review for that one prompt (`reviewPromptDelete`: the same preview, review with the saved-pair
choice, backup and write, fenced by the page's `library_epoch`). Cancel changes nothing and returns focus to Edit
metadata. A delete toasts and opens the Prompt library; an archive (or a drift) toasts and reads the page again, so an
archived prompt shows its Archived notice. The button is disabled while the editor saves or after a restore.
The concept comparison applies D161 in `mapConcept` (`tests/browser/library-editor-canonical-comparison.mjs`): the
concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete prompt…" with the same geometry and tokens.
Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the
hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart.
### Prompt page heading on one row (#524)
**D164** (beyond the concept, which never has four heading actions beside a long title): from 951px, the concept's
sidebar breakpoint, the prompt page's heading actions keep one row and the title takes the remaining width, clamped to
two lines with its full text in the DOM and in `title`. Below 951px the concept's wrapping and stacked heading is
unchanged. A short title lays out as the concept's, so the history concept comparison is unaffected. A deleted prompt's
page has no "Prompt history · …" description: it is emptied and hidden while the gone message shows.
### Delete review and prompt-page polish (#512)
Found in the live test of #505/#507/#509. **D163** (beyond the concept, maintainer ruling 2026-10-29: delete was hard to
find on a prompt's page): the prompt page heading ends with a **"⋯" More menu** button after Send to ComfyUI
(`more-menu.mjs`: `aria-haspopup="menu"`, `aria-expanded`; Enter, Space or ArrowDown opens it on its first item, ArrowUp
on its last; arrows, Home and End move; Escape closes it and returns focus to ⋯; Tab, an outside press or a choice
closes it). It holds one danger item, "Delete prompt…", which opens the same review as Edit metadata's Delete prompt…
(D161, kept) and returns focus to ⋯. The menu uses the concept's button, surface, border and toast shadow, opens
right-aligned under ⋯, and opens rightwards where the heading wraps it to the left edge (390px). It is held with Edit
metadata while the page's review or write runs, and disabled on a gone prompt's page.
Also:
- The Delete… review leaves out rows with nothing in them ("Delete permanently: 0 …", "Archive: 0 …"), close-only
reviews included.
- A review opened from a prompt's page is titled "Delete this prompt" or "Archive this prompt" (following the choice),
and its close-only sentence reads "This prompt cannot be deleted or archived."
- An archived prompt that is another prompt's negative is named so: "1 prompt used as another prompt's negative:
hidden, restorable" (or "N prompts in a saved pair (M used as another prompt's negative)"), with the sentence that its
variant stays.
- A prompt page whose prompt no longer exists (404, after a delete or from a stale link) reads "This prompt no longer
exists. It may have been deleted." with "Open the Prompt library", no Retry; Edit metadata and ⋯ are disabled.
- Item 5 of #512 does not fail on the code after #505: the archived shelf's button reads "← Prompt library", and under a
remembered search the heading's count equals what the archived shelf lists. A regression test pins both.
Tests: `tests/js/workspace-library-delete.test.js` (#512) and `tests/browser/workspace-library-delete.spec.mjs` (#512
cases: the menu by mouse, keyboard and at 390px, the gone page, the titles, the count under a search). JS only: no
restart.
### No 1–5 prompt rating in the workspace (#447)
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
+27 -1
View File
@@ -43,6 +43,7 @@ from .image_categories import DELETE_SQL as DELETE_CATEGORY_SQL
from .image_removal_schema import (
META_TABLE,
TABLE,
bump_generation,
clear_marks,
has_marks_table,
mark_of,
@@ -319,4 +320,29 @@ class ImageRemovalOperations:
if not locators:
return 0
with self._pair_transaction() as conn:
return clear_marks(conn, locators)
removed = clear_marks(conn, locators)
# #498: again after the commit, so a reader that cached the marks
# between the in-transaction bump and the commit reloads them.
bump_generation()
return removed
def clear_hiding_marks(self, files) -> set:
"""Delete the marks that still hide their file (#498, Return to library).
*files* are ``(locator, path)`` pairs. In one ``BEGIN IMMEDIATE``
transaction each mark is read again and deleted only while the file at
*path* still has its size and ``mtime_ns``. Returns the locators whose
marks went.
"""
cleared = set()
files = list(files)
if not files:
return cleared
with self._pair_transaction() as conn:
for locator, path in files:
mark = mark_of(conn, locator)
if mark is not None and mark_hides({locator: mark}, locator, path):
cleared.add(locator)
clear_marks(conn, sorted(cleared))
bump_generation()
return cleared
+5
View File
@@ -155,6 +155,11 @@ def _bump() -> None:
_generation += 1
def bump_generation() -> None:
"""Move the generation after a commit (a cached read made before it reloads)."""
_bump()
def has_marks_table(conn: sqlite3.Connection) -> bool:
return (
conn.execute(
+198 -14
View File
@@ -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,34 @@ _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")
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}$")
def _batches(ids: list) -> Iterable[list]:
@@ -76,26 +105,36 @@ 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:
found = {}
on_archived_shelf = _check_shelf(shelf)
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:
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"
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 +176,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)
@@ -144,23 +259,30 @@ 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,
**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 +313,8 @@ 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.
@@ -201,7 +325,17 @@ 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``.
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(
@@ -209,6 +343,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):
@@ -216,18 +360,38 @@ 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 = [], 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)
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:
self._repair_hash(conn, pid)
@@ -240,15 +404,35 @@ 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,
"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,
"pairs_removed": pairs_removed,
}
def restore_library_prompts(
+3
View File
@@ -78,6 +78,7 @@ from .lora_integration import LoraIntegrationMixin
from .lora_import_routes import LoraImportRoutesMixin
from .image_categories import ImageCategoryRoutesMixin
from .image_delete import ImageDeleteMixin
from .image_return import ImageReturnMixin
from .source_rescope import SourceRescopeMixin
try:
@@ -349,6 +350,7 @@ class PromptManagerAPI(
LoraImportRoutesMixin,
ImageCategoryRoutesMixin,
ImageDeleteMixin,
ImageReturnMixin,
SourceRescopeMixin,
):
"""REST API handler for PromptManager operations and web interface.
@@ -728,6 +730,7 @@ class PromptManagerAPI(
self._register_lora_import_routes(routes)
self._register_image_category_routes(routes)
self._register_image_delete_routes(routes)
self._register_image_return_routes(routes)
self._register_rescope_routes(routes)
# Register gzip compression middleware (once)
+34 -12
View File
@@ -241,7 +241,12 @@ SCAN_FINAL_STATES = {
"error": "failed",
}
# The scope as the scan log's first line names it (the review dialog's words).
SCAN_SCOPE_LOG = {"new": "new and unresolved files", "all": "all files"}
SCAN_SCOPE_LOG = {
"new": "new and unresolved files",
"all": "all files",
"returned": "images returned to the library",
}
RETURN_JOB_TITLE = "Return images to the library" # #498
MIN_BATCHES_FOR_ESTIMATE = 2 # "about X min left" waits for this many batches
# Log line prefix per outcome group (spec: + added, = known, ? review, ! error,
# - no usable metadata).
@@ -1381,13 +1386,17 @@ class AdminRoutesMixin:
}
)
def _start_scan_job(self, files=DEFAULT_SCAN_SCOPE, roots=None, output_dirs=None):
def _start_scan_job(
self, files=DEFAULT_SCAN_SCOPE, roots=None, output_dirs=None, returned=None
):
"""Register the scan job synchronously; its runner starts on the loop.
``files`` is the scope (``SCAN_SCOPES``). ``roots`` selects sources by
their index in ``output_dirs`` (the effective sources the caller
resolved); None scans every source, resolved when the job starts.
Raises JobBusy while another record-changing job runs.
Raises JobBusy while another record-changing job runs. ``returned``
(#498, ``[(path, locator)]``) makes it "Return images to the library":
those files' removal marks go first, then only they are scanned.
"""
if files not in SCAN_SCOPES:
raise ValueError(f"Unknown scan scope: {files!r}")
@@ -1395,20 +1404,26 @@ class AdminRoutesMixin:
output_dirs is None or not roots or max(roots) >= len(output_dirs)
):
raise ValueError("A scoped scan needs the sources its roots index")
scope = {"files": files, "roots": None if roots is None else list(roots)}
if returned is not None:
scope = {"files": "returned", "roots": None, "images": len(returned)}
job = self._jobs.start(
SCAN_JOB_KIND,
SCAN_JOB_TITLE,
SCAN_JOB_TITLE if returned is None else RETURN_JOB_TITLE,
record_changing=True,
scope={"files": files, "roots": None if roots is None else list(roots)},
runner=functools.partial(self._run_scan_job, output_dirs=output_dirs),
scope=scope,
runner=functools.partial(
self._run_scan_job, output_dirs=output_dirs, returned=returned
),
groups=SCAN_GROUP_NAMES,
)
self._scan_job = job
return job
async def _run_scan_job(self, job, output_dirs=None):
async def _run_scan_job(self, job, output_dirs=None, returned=None):
final = None
async for payload in self._scan_images_events(job, output_dirs):
extra = {} if returned is None else {"returned": returned}
async for payload in self._scan_images_events(job, output_dirs, **extra):
job.publish(payload)
final = payload.get("type")
state = SCAN_FINAL_STATES.get(final, "finished")
@@ -1446,7 +1461,7 @@ class AdminRoutesMixin:
)
return response
async def _scan_images_events(self, job, output_dirs=None):
async def _scan_images_events(self, job, output_dirs=None, returned=None):
"""Yield progress payloads while scanning the output directories.
Every effective source is walked, or only those ``job.scope["roots"]``
@@ -1484,9 +1499,16 @@ class AdminRoutesMixin:
0, f"Scanning {len(walked)} directory(ies) for media files..."
)
media_files = await self._run_in_executor(
self._collect_output_media_sync, walked
)
if returned is None:
media_files = await self._run_in_executor(
self._collect_output_media_sync, walked
)
else:
# #498: only the files whose marks this job deleted.
media_files = await self._run_in_executor(
self._clear_returned_marks, returned
)
job.log_line(f"Returned {_plural(len(media_files), 'image')}")
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
job.update(
total=len(media_files),
+7 -5
View File
@@ -232,15 +232,17 @@ class ImageCategoryRoutesMixin:
return None, _coded(400, "too-many-images", TOO_MANY)
return tuple(selection.targets), None
async def _category_request(self, request, fields: frozenset):
async def _category_request(self, request, fields: frozenset, data=None):
"""``(data, targets, roots, None)`` or ``(None, None, None, error)``.
The images are ``items`` (1 to 500 targets) or ``filter`` (all images
an Images listing filter admits), besides *fields*.
an Images listing filter admits), besides *fields*. *data* is the body
when the caller has already read it (a body is read once).
"""
data, error = await _json_body_or_error(request)
if error is not None:
return None, None, None, error
if data is None:
data, error = await _json_body_or_error(request)
if error is not None:
return None, None, None, error
names = set(data) - fields
if set(data) & fields != fields or names not in ({"items"}, {"filter"}):
return (
+35 -6
View File
@@ -65,6 +65,9 @@ TITLES = {
"trash": "Delete images (move to the trash)",
}
MAX_DETAIL_ROWS = 50
# The system moved the file but its place in a trash could not be shown (#499).
TRASH_UNCONFIRMED = "trash-unconfirmed"
RECORDS_FAILED_CODE = "records-failed"
# Reason codes (design §4.4) and the sentence each shows.
REASONS = {
@@ -83,6 +86,17 @@ REASONS = {
trash.TRASH_IN_ROOT: "This drive's trash is inside an output folder.",
trash.LOCKED: "The file is open in another program. Close it and try again.",
trash.FAILED: "The file could not be moved to the trash.",
trash.NO_PERMISSION: (
"PromptManager isn't allowed to move this file (no permission, or it is "
"locked in Finder)."
),
trash.CLOUD_SYNC: (
"Files in iCloud Drive or a cloud-synced folder can't be moved to the "
"trash here."
),
TRASH_UNCONFIRMED: (
"Moved by macOS, but its place in the trash could not be confirmed."
),
trash.WINDOWS_RECYCLE: (
"Moving files to the Recycle Bin isn't available on Windows: files larger "
"than the bin's capacity would be deleted permanently."
@@ -327,7 +341,7 @@ class ImageDeleteMixin:
job.update(total=len(targets), status_line=TITLES[mode])
roots = [str(root) for root in self._get_all_output_dirs()]
done_group = "trashed" if mode == "trash" else "removed"
changed, failed = 0, False
changed, failed = 0, None
for done, target in enumerate(targets, start=1):
if failed:
self._record_delete(job, "not_changed", target, "not-attempted")
@@ -336,7 +350,8 @@ class ImageDeleteMixin:
self._delete_one_image, db, target, mode, roots
)
if group == "errors":
failed = True
failed = code or RECORDS_FAILED_CODE
text = REASONS[code] if code else RECORDS_FAILED
job.record(
"errors",
{
@@ -344,9 +359,9 @@ class ImageDeleteMixin:
"root": target["root_label"],
"root_index": target["root_index"],
"root_scope": target["root_scope"],
"detail": RECORDS_FAILED,
"detail": text,
},
f"! {target['root_label']}/{target['rel']} · {RECORDS_FAILED}",
f"! {target['root_label']}/{target['rel']} · {text}",
)
elif group == done_group:
changed += 1
@@ -365,8 +380,10 @@ class ImageDeleteMixin:
job.log_line(line)
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
if failed:
job.scope["failure"] = "records-failed"
job.publish({"type": "error", "code": "records-failed", "message": line})
# "records-failed" names every stop at an errors row (the page's
# "Stopped after N of M" toast); the row says which error it was.
job.scope["failure"] = RECORDS_FAILED_CODE
job.publish({"type": "error", "code": failed, "message": line})
job.finish("failed")
@staticmethod
@@ -405,7 +422,19 @@ class ImageDeleteMixin:
label = trash.move_to_trash(target["abs"], roots)
except trash.TrashRefused as refused:
code = refused.code if refused.code in REASONS else trash.FAILED
self.logger.info(
"Not moved to the trash (%s): %s %s",
code,
target["rel"],
refused.detail,
)
return "not_changed", code, None
except trash.TrashUnconfirmed as unconfirmed:
# The file left its folder: keep its records, stop the job.
self.logger.warning(
"Trash move not confirmed: %s %s", target["rel"], unconfirmed.detail
)
return "errors", TRASH_UNCONFIRMED, None
mark = None
else:
mark = (target["size"], target["mtime_ns"])
+148
View File
@@ -0,0 +1,148 @@
"""Return removed images to the library (#498).
``POST /prompt_manager/images/removed/return {items | filter}`` names files a
removal mark hides (#480, "Remove from the library only"): ``items`` are 1 to
500 listing identities, ``filter`` the removed view's listing query
(``removed: "1"`` plus the view's other parameters, at most 500 images). Only
this route takes ``removed``: every other bulk route refuses it, because an
explicit write there would show a file again without re-reading it.
The route answers 202 ``{job, returned, not_removed}`` after starting a
``scan`` job titled "Return images to the library" (admission ``records``:
409 ``busy`` beside a scan, restore or delete, nothing changed). The job
first deletes the marks that still hide their file (same size and
``mtime_ns``), in one transaction, then scans exactly those files with the
scan's own pipeline: prompts, links, pairs and Needs review items come back
from the files' embedded metadata. Image tags, auto-tag marks, the own
category, collection memberships and the favorite were deleted by the remove
and do not come back. No file is moved, written or deleted.
"""
import os
from pathlib import Path
from aiohttp import web
from .image_categories import TOO_MANY, _coded
from .images import (
ListingSourceChanged,
_favorite_relative_key,
_json_body_or_error,
parse_listing_filter,
)
from .image_tag_jobs import MAX_JOB_TARGETS
from .jobs import JobBusy
from .job_routes import busy_response
try:
from ...database.image_removal import mark_hides
except ImportError:
from database.image_removal import mark_hides
NONE_REMOVED = "None of the selected images are removed from the library."
SOURCE_CHANGED = "The image sources changed. Reload Images and try again."
def _bad(message):
return web.json_response({"success": False, "error": message}, status=400)
class ImageReturnMixin:
"""Return to library (#498): clear the marks, then scan those files."""
def _register_image_return_routes(self, routes):
# A fixed path beside the dynamic /images/{image_id} routes.
@routes.post("/prompt_manager/images/removed/return")
async def return_removed_images_route(request):
return await self.return_removed_images(request)
async def return_removed_images(self, request):
data, error = await _json_body_or_error(request)
if error is not None:
return error
if set(data) == {"filter"}:
targets, roots, error = await self._removed_filter_targets(data["filter"])
else:
_data, targets, roots, error = await self._category_request(
request, frozenset(), data=data
)
if error is not None:
return error
files = {}
for index in dict.fromkeys(t[0] for t in targets):
files[index] = await self._get_gallery_files(roots[index])
returned, not_removed = await self._run_in_executor(
self._hidden_targets, roots, targets, files
)
if not returned:
return _coded(400, "no-images", NONE_REMOVED)
try:
job = self._start_scan_job(
files="all", output_dirs=list(roots), returned=returned
)
except JobBusy as busy:
return busy_response(busy)
return web.json_response(
{
"success": True,
"job": job.summary(),
"returned": len(returned),
"not_removed": not_removed,
},
status=202,
)
async def _removed_filter_targets(self, raw):
"""``(targets, roots, None)`` for the removed view's filter, else an error."""
if not isinstance(raw, dict) or raw.get("removed") != "1":
return None, None, _bad('filter must hold removed: "1"')
rest = {key: value for key, value in raw.items() if key != "removed"}
try:
parse_listing_filter(rest)
selection = await self.resolve_listing_filter(
{**rest, "removed": "1"}, MAX_JOB_TARGETS
)
except ListingSourceChanged:
return None, None, _coded(409, "source-changed", SOURCE_CHANGED)
except ValueError as exc:
return None, None, _bad(str(exc))
if selection.truncated:
return None, None, _coded(400, "too-many-images", TOO_MANY)
if not selection.targets:
return None, None, _coded(400, "no-images", NONE_REMOVED)
return tuple(selection.targets), list(self._get_all_output_dirs()), None
def _hidden_targets(self, roots, targets, files):
"""``([(path, locator)], not_removed)``: the targets a mark hides now.
Each target is matched to its listed spelling (the walk's). One
physical file named twice (nested roots) is returned once; a target
that is not listed or not hidden counts in ``not_removed``.
"""
marks = self._removal_marks()
listings, seen, returned, not_removed = {}, set(), [], 0
for index, _scope, relative in targets:
root = roots[index]
if index not in listings:
listings[index] = self._favorite_listing(root, files[index])
spelled = listings[index].get(_favorite_relative_key(relative))
if spelled is None:
not_removed += 1
continue
path = os.path.join(str(root), *spelled.split("/"))
locator = os.path.normcase(os.path.realpath(path))
if locator in seen:
continue
if not mark_hides(marks, locator, path):
not_removed += 1
continue
seen.add(locator)
returned.append((Path(path), locator))
return returned, not_removed
def _clear_returned_marks(self, returned) -> list:
"""The paths whose marks this deleted (blocking; one transaction)."""
cleared = self.db.clear_hiding_marks(
[(locator, str(path)) for path, locator in returned]
)
return [path for path, locator in returned if locator in cleared]
+23 -3
View File
@@ -1160,7 +1160,10 @@ def _workspace_filters(query: Mapping) -> dict:
the linked positive, and ``favorites=1`` keeps only favorites (the
Favorites page, #249 D51). ``collection=<id>`` (1 to 9 digits) keeps only
that collection's members (#331); it wins over ``favorites`` (a page never
sets both). Raises ValueError for any unknown or oversized value.
sets both). ``removed=1`` lists only the files a removal mark hides (#498,
Show removed images); such a file has no records, so it never combines
with ``favorites``, ``collection``, ``category``, ``tags`` or the rating
sort. Raises ValueError for any unknown or oversized value.
"""
text = query.get("q", "").strip()
folded = text.casefold()
@@ -1175,6 +1178,12 @@ def _workspace_filters(query: Mapping) -> dict:
)
favorites = query.get("favorites", "0")
collection = query.get("collection", "")
removed = query.get("removed", "0")
if removed not in ("0", "1") or (
removed == "1"
and (favorites == "1" or collection or category or tags or order == "rating")
):
raise ValueError("Invalid gallery filters")
if (
favorites not in ("0", "1")
or (collection != "" and _COLLECTION_ID.fullmatch(collection) is None)
@@ -1202,6 +1211,7 @@ def _workspace_filters(query: Mapping) -> dict:
"tags": tags,
"favorites": favorites == "1" and not collection,
"collection": int(collection) if collection else None,
"removed": removed == "1",
}
@@ -2073,6 +2083,7 @@ class ImageRoutesMixin:
filters["tags"],
filters["favorites"],
filters["collection"],
filters["removed"],
]
).encode()
).hexdigest()
@@ -2102,6 +2113,11 @@ class ImageRoutesMixin:
self._select_workspace_page, listings, filters, after, limit, scope
)
)
# #498: Show removed images (N), every removed file in the selected
# source(s) whatever the filters (the hidden set is cached work).
removed_total = len(
await self._run_in_executor(self._hidden_entries, listings)
)
except CollectionMissing:
return collection_not_found()
except Exception:
@@ -2128,6 +2144,7 @@ class ImageRoutesMixin:
"limit": limit,
"has_more": has_more,
"next_cursor": next_cursor,
"removed_total": removed_total,
}
if positive_count is _COUNT_OMITTED:
del result["positive_count"]
@@ -2451,9 +2468,12 @@ class ImageRoutesMixin:
def key(entry):
return _workspace_key(entry, order)
# #480: files removed from the library stay on disk, hidden here.
# #480: files removed from the library stay on disk, hidden here; the
# removed view (#498) lists only them.
hidden = self._hidden_entries(listings)
if hidden:
if filters.get("removed"):
entries = [entry for entry in entries if entry.normalized in hidden]
elif hidden:
entries = [entry for entry in entries if entry.normalized not in hidden]
return (
entries,
+69 -11
View File
@@ -47,6 +47,10 @@ 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"})
# #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"
@@ -64,19 +68,52 @@ 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, *groups):
"""The JSON object with exactly ``keys``, plus all or none of each group."""
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
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:
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 == []:
@@ -97,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
@@ -109,9 +147,10 @@ class LibraryDeleteRoutesMixin:
db.classify_library_delete,
ids,
expected_epoch=data["expected_epoch"],
shelf=shelf,
)
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,
@@ -143,8 +182,16 @@ 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.
"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 +199,8 @@ class LibraryDeleteRoutesMixin:
async def delete_library_prompts(self, request):
try:
data = await _body(request, _DELETE_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:
@@ -163,6 +211,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 +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
self.db, data, archive_ids, delete_ids, paired, shelf
)
if response.status >= 400:
job.finish("failed")
@@ -181,18 +230,25 @@ 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, shelf="active"
):
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,
# #492: the same shelf, or archived ids would skip the backup.
shelf=shelf,
)
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 +270,8 @@ class LibraryDeleteRoutesMixin:
delete_ids,
expected_epoch=epoch,
candidates=candidates if candidates is not None else {},
delete_paired=paired,
shelf=shelf,
)
except LibraryCommandError as exc:
return _storage_changed(exc)
+26
View File
@@ -0,0 +1,26 @@
// #513: a worker records its browser's own log, so a browser that dies between two tests leaves its exit and stderr
// behind instead of only "Target page, context or browser has been closed" on the next test.
import { test, expect } from './fixtures.mjs';
import { isTargetGone } from '../../tools/vendor/browser-logs.mjs';
test('the worker records the launch of the browser its tests use', async ({ browser, browserLogs }) => {
expect(browser.isConnected()).toBe(true);
expect(browserLogs.launches().length).toBeGreaterThan(0);
});
test('a killed browser leaves its signal and stderr in the worker log', async ({ playwright, browserName, browserLogs }) => {
const before = browserLogs.launches();
const victim = await playwright[browserName].launch();
const pid = browserLogs.launches().find(item => !before.includes(item));
expect(pid, 'the new browser\'s launch was recorded').toBeGreaterThan(0);
const disconnected = new Promise(resolve => victim.once('disconnected', resolve));
// Playwright starts a browser in a process group of its own; WebKit's launched pid is a wrapper script.
process.kill(-pid, 'SIGKILL');
await disconnected;
const error = await victim.newContext().then(() => null, item => item);
expect(error, 'a dead browser refuses a new context').not.toBeNull();
expect(isTargetGone(error.message)).toBe(true);
// The pipe can close before the launcher has logged the exit.
await expect.poll(() => browserLogs.exits()).toContainEqual({ pid, exitCode: null, signal: 'SIGKILL' });
expect(browserLogs.tail()).toContain(`[pid=${pid}] <process did exit: exitCode=null, signal=SIGKILL>`);
});
+2 -1
View File
@@ -421,7 +421,8 @@ export function measureInventory(scope) {
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
actions:when(actions,()=>({...layout(actions),...edges(actions),
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
// D163: a closed menu's items are not heading actions.
buttons:[...actions.querySelectorAll('button')].filter(node=>!node.closest('[role="menu"]')).map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
}
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
+17 -1
View File
@@ -4,13 +4,29 @@
// points at that worker's server. A server that dies fails the running test
// with its stderr tail, and the next test in the worker gets a fresh server.
// A test that fails gets the stderr its server wrote during it attached as
// `server-stderr`.
// `server-stderr`. The worker also records its browser's own log (#513): a
// browser that exits abnormally prints one `pm-browser-exit|` line, and a test
// that fails on a closed or crashed target gets that log attached as
// `browser-stderr`.
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
import { attachBrowserLogs, createBrowserLogRecorder } from '../../tools/vendor/browser-logs.mjs';
const SERVER_FIXTURE_TIMEOUT_MS = 90_000;
export const test = base.extend({
browserLogs: [async ({}, use) => {
const recorder = createBrowserLogRecorder();
try {
await use(recorder);
} finally {
recorder.uninstall();
}
}, { scope: 'worker', auto: true }],
browserLogsOnFailure: [async ({ browserLogs }, use, testInfo) => {
await use();
await attachBrowserLogs(testInfo, browserLogs);
}, { auto: true }],
testServer: [async ({}, use) => {
const supervisor = createServerSupervisor({ start: () => startBrowserServer() });
try {
+7 -3
View File
@@ -26,7 +26,8 @@ const SECTIONS=['heading','history','noPageScroll'];
*/
export function mapHistoryConcept(reference) {
const {history}=reference.oracle;
return {...reference,history:{...history,
// D163 (#512): the heading as laid out with the More menu button (the oracle's heading).
return {...reference,heading:reference.oracle.heading,history:{...history,
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
summary:history.summary,
@@ -120,11 +121,14 @@ async function conceptAsRecorded(page) {
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived};
// D163 (#512): the heading ends with the "⋯" More menu button, drawn as the concept's Edit metadata button.
const edit=[...document.querySelectorAll('#main > .heading .heading-actions > button')].find(node=>node.textContent.trim()==='Edit metadata');
const moreButton=edit.cloneNode(false);moreButton.removeAttribute('data-action');moreButton.textContent='⋯';edit.parentElement.append(moreButton);
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived,moreButton};
},SHOW_ARCHIVED);
await settle(page);
return ()=>page.evaluate(()=>{
const {timeline,order,texts,archived}=window.__historyOracle;
const {timeline,order,texts,archived,moreButton}=window.__historyOracle;moreButton.remove();
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
for(const [span,text] of texts)span.textContent=text;
archived.remove();delete window.__historyOracle;
@@ -0,0 +1,65 @@
import {test,expect} from './fixtures.mjs';
import {seedConceptAt,openConcept,openProduction} from './concept-inventory.mjs';
// #498 (D162): the concept has no Show removed images screen, so production builds it from the concept's own parts:
// the heading action button (as the concept's Images heading button), the card's `.chip` (as a concept card chip) and
// the selection bar's buttons (as the concept bar's plain buttons). Each is compared with its concept counterpart.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.gallery-body > .image-grid > .media-card';
const KEYS=['color','backgroundColor','borderTopColor','borderTopWidth','fontSize','fontWeight','paddingTop','paddingLeft',
'borderTopLeftRadius','height'];
const CHIP_KEYS=['color','backgroundColor','borderTopColor','fontSize','fontWeight','paddingTop','paddingLeft',
'borderTopLeftRadius'];
/** Runs in the page: the computed style of the first node `selector` matches. */
function styleOf([selector,keys]) {
const node=document.querySelector(selector);
return node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
}
async function measure(page,selectors) {
const found={};
for (const [name,selector,keys] of selectors) found[name]=await page.evaluate(styleOf,[selector,keys]);
return found;
}
test.beforeEach(async({page,request})=>{await seedConceptAt(page,request);});
for (const theme of ['dark','light']) {
test(`Show removed images uses the concept's heading button, chip and bar buttons (${theme})`,async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openConcept(page,'images',theme);
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await page.mouse.move(0,0);
const reference=await measure(page,[
['heading','#main .heading-actions > button:not(.ca-primary)',KEYS],
['chip','#main .chip',CHIP_KEYS],
['bar','#main #bulkbar > .bulkbar > button:not(.danger)',KEYS],
]);
await openProduction(page,'images',theme);
await expect(page.locator(CARDS).first()).toBeVisible();
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Delete…',exact:true}).click();
const dialog=page.locator('dialog#modal[open]');
await dialog.getByRole('radio',{name:/^Remove from the library only/}).check();
await dialog.locator('.dialog-actions button.danger').click();
const toggle=page.locator('.heading-actions').getByRole('button',{name:/^Show removed images \(1\)$/});
await expect(toggle).toBeVisible();
await page.mouse.move(0,0);
const heading=await measure(page,[['heading','#workspace-main .heading-actions > button[aria-pressed]',KEYS]]);
await toggle.click();
await expect(page.locator(`${CARDS} .chip`,{hasText:'Removed'})).toBeVisible();
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await expect(page.locator('#workspace-main #bulkbar').getByRole('button',{name:'Return to library'})).toBeVisible();
await page.mouse.move(0,0);
const actual={...heading,...await measure(page,[
['chip',`${CARDS} .media-info .chip`,CHIP_KEYS],
['bar','#workspace-main #bulkbar > .bulkbar > button',KEYS],
])};
// Every part was found on both sides: two missing nodes must never compare equal.
expect(Object.values(reference).every(Boolean)).toBe(true);
expect(actual).toEqual(reference);
});
}
@@ -88,15 +88,16 @@ 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)'],
['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 '
@@ -26,9 +26,15 @@ export function inventoryOf(dialog) {
};
}
// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the
// concept's "Rating" select is not drawn.
// concept's "Rating" select is not drawn. D161 (#481): the concept's one danger "Archive…" becomes "Archive variant…" and
// is followed by the prompt's own "Delete prompt…", drawn as the same danger button.
function mapButton(item) {
if(item.text==='Save in prototype') return [{...item,text:'Save'}];
if(item.text==='Archive…') return [{...item,text:'Archive variant…'},{...item,text:'Delete prompt…'}];
return [item];
}
export function mapConcept(reference) {
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.flatMap(mapButton)}};
}
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
+1 -1
View File
@@ -24,7 +24,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
await expect(editor(page).getByRole('button',{name:'Save',exact:true})).toBeEnabled();
await editor(page).getByRole('button',{name:'Archive…',exact:true}).click();
await editor(page).getByRole('button',{name:'Archive variant…',exact:true}).click();
await expect(editor(page).getByRole('button',{name:'Archive',exact:true})).toBeEnabled();await shot('editor-archive');
let release;const held=new Promise(resolve=>{release=resolve;});
+9 -2
View File
@@ -5,6 +5,7 @@ import { test, expect } from './fixtures.mjs';
// page `#/collection/<id>` with its Edit (D137, concept render a1186–1189) and saved views on a collection (D140).
const workspace = '/prompt_manager/workspace';
const API = '/prompt_manager/collections';
const RESTORE_APPLY_URL = '/prompt_manager/restore/apply';
const main = page => page.locator('#workspace-main');
const heading = page => main(page).getByRole('heading', { level: 1 });
const breadcrumb = page => page.locator('#workspace-breadcrumb');
@@ -305,7 +306,12 @@ test('a finished restore reads the collections again', async ({ page, request })
const chooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Review restore…', exact: true }).click();
await (await chooser).setFiles({ name: 'prompts_backup_20261027_093000.db', mimeType: 'application/octet-stream', buffer: bytes });
// Wait for the restore job itself, as workspace-system-backups.spec.mjs does: under load a restore outlasts the
// 5 s expect default, and a restore still running after this test replaces the next tests' database (#520).
const applied = page.waitForResponse(response => response.url().endsWith(RESTORE_APPLY_URL));
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Restore', exact: true }).click();
const { job } = await (await applied).json();
await expect(await request.get(`/prompt_manager/jobs/${job.id}/events`)).toBeOK();
await expect(toast(page)).toHaveText(/^Database restore finished · /);
// The list was read again without a reload.
await expect(rows(page)).toHaveCount(2);
@@ -480,7 +486,7 @@ test('a rename that settles after its dialog closed leaves the next dialog open
});
test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => {
await seed(request);
const manifest = await seed(request);
await page.goto(`${workspace}#/images`);
await expect(heading(page)).toHaveText('Images');
await page.evaluate(() => { window.location.hash = '#/collection/999999'; });
@@ -499,7 +505,8 @@ test('a page that says a collection or view could not be opened takes focus afte
await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback());
await page.evaluate(() => { window.location.hash = '#/saved/5'; });
// An id the seed never made (its one view is manifest.saved_view), whatever ids earlier tests used (#520).
await page.evaluate(id => { window.location.hash = `#/saved/${id}`; }, manifest.saved_view.id + 1000);
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(heading(page)).toBeFocused();
});
@@ -0,0 +1,143 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConceptAt, openProduction } from './concept-inventory.mjs';
// #498 (D162): Images › Show removed images (N) lists the files "Remove from the library only" hid; Return to library
// (bar or inspector) clears their marks and scans them again. Nothing is deleted or moved.
test.use({ locale: 'en-US', timezoneId: 'UTC' });
const CARDS = '#workspace-main article[data-image-key]';
const bar = page => page.locator('#workspace-main #bulkbar');
const toast = page => page.locator('#workspace-toast');
const toggle = page => page.locator('.heading-actions').getByRole('button', { name: /^Show removed images/ });
const imagesBadge = page => page.locator('#primary-nav a[data-route="images"] .nav-count');
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConceptAt(page, request);
page.removedErrors = [];
page.on('pageerror', error => page.removedErrors.push(error.message));
page.on('dialog', dialog => { page.removedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.removedErrors).toEqual([]);
});
async function openImages(page) {
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, 'images', 'dark');
await expect(page.locator(CARDS).first()).toBeVisible();
return page.locator(CARDS).count();
}
async function check(page, index) {
const card = page.locator(CARDS).nth(index);
const key = await card.getAttribute('data-image-key');
await card.locator('.image-overlays input[type="checkbox"]').check();
return key;
}
/** Remove the first `count` cards from the library (library mode), then wait for the cards to leave. */
async function removeFromLibrary(page, count) {
const keys = [];
for (let index = 0; index < count; index += 1) keys.push(await check(page, index));
await bar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
const dialog = page.getByRole('dialog', { name: /^Delete \d+ images?$/ });
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
await dialog.locator('.dialog-actions button.danger').click();
await expect(toast(page)).toContainText(`Removed ${count} image${count === 1 ? '' : 's'} from the library.`);
for (const key of keys) await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(0);
return keys;
}
test('without removed images there is no toggle', async ({ page }) => {
await openImages(page);
await expect(page.locator('#result-count')).toContainText(/images?/);
await expect(toggle(page)).toHaveCount(0);
});
test('Show removed images lists the removed files; Return to library brings them back', async ({ page }) => {
const before = await openImages(page);
const keys = await removeFromLibrary(page, 2);
await expect(toggle(page)).toHaveText('Show removed images (2)');
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'false');
await toggle(page).click();
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator(CARDS)).toHaveCount(2);
await expect(page.locator('#result-count')).toHaveText('2 removed images · files on disk, hidden from Images');
for (const key of keys) {
const card = page.locator(`${CARDS}[data-image-key="${key}"]`);
await expect(card.locator('.chip', { hasText: 'Removed' })).toBeVisible();
// A removed file has no records: no star and no Tag action, which would write and show it again.
await expect(card.locator('.star-button')).toHaveCount(0);
await expect(card.locator('.image-card-actions button')).toHaveCount(1);
}
await check(page, 0);
await check(page, 1);
const actions = bar(page).locator('button');
await expect(actions).toHaveText(['Return to library', 'Clear']);
await bar(page).getByRole('button', { name: 'Return to library' }).click();
await expect(toast(page)).toContainText('Returned 2 images to the library');
await expect(toast(page)).toContainText('Tags, categories, collections and favorites do not come back.');
// The view emptied: it turns off and Images lists every file again.
await expect(toggle(page)).toHaveCount(0);
await expect(page.locator(CARDS)).toHaveCount(before);
for (const key of keys) await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(1);
await expect(bar(page)).toBeEmpty();
});
test('Return to library from the inspector', async ({ page }) => {
await openImages(page);
const [key] = await removeFromLibrary(page, 1);
await toggle(page).click();
const card = page.locator(`${CARDS}[data-image-key="${key}"]`);
await card.getByRole('button', { name: /^Inspect / }).click();
const panel = page.locator('#image-inspector');
await expect(panel.getByRole('button', { name: 'Return to library' })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Delete…' })).toHaveCount(0);
await expect(panel.locator('button[data-collections]')).toHaveCount(0);
await panel.getByRole('button', { name: 'Return to library' }).click();
await expect(toast(page)).toContainText('Returned 1 image to the library');
await expect(toggle(page)).toHaveCount(0);
await expect(page.locator(`${CARDS}[data-image-key="${key}"]`)).toHaveCount(1);
});
test('the sidebar Images count follows a remove and a return', async ({ page }) => {
await openImages(page);
await expect(imagesBadge(page)).toHaveText(/^\d[\d,]*$/);
const start = Number((await imagesBadge(page).textContent()).replace(/,/g, ''));
await removeFromLibrary(page, 1);
await expect(imagesBadge(page)).toHaveText((start - 1).toLocaleString('en-US'));
await toggle(page).click();
await check(page, 0);
await bar(page).getByRole('button', { name: 'Return to library' }).click();
await expect(toast(page)).toContainText('Returned 1 image to the library');
await expect(imagesBadge(page)).toHaveText(start.toLocaleString('en-US'));
});
test('the removed view is never saved and ends when the page is left', async ({ page }) => {
await openImages(page);
await removeFromLibrary(page, 1);
await toggle(page).click();
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'true');
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(page).toHaveURL(/#\/library/);
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator('#result-count')).not.toContainText('removed');
});
test('choosing a category in the removed view ends the view and lists the category', async ({ page }) => {
await openImages(page);
await removeFromLibrary(page, 1);
await toggle(page).click();
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
// A removed file has no category: the view ends instead of refusing the listing.
await expect(toggle(page)).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(page.locator('#result-count')).not.toContainText('removed');
});
+2 -1
View File
@@ -602,7 +602,8 @@ async function staleSourceRecovery(page, request, roots) {
const pages = [], inspections = [], options = [], mutations = [];
page.on('request', request => {
const url = new URL(request.url());
if (url.pathname.endsWith('/images/output')) pages.push(url);
// limit=1 is a sidebar count read (Overview re-reads it on a visit, #547), not a page of images.
if (url.pathname.endsWith('/images/output') && url.searchParams.get('limit') !== '1') pages.push(url);
if (url.pathname.includes('/images/inspect/')) inspections.push(url);
if (request.method() !== 'GET') mutations.push(url.pathname);
});
+399 -6
View File
@@ -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':'';
@@ -48,7 +50,8 @@ test('Delete… reviews the selection, deletes the unpaired, archives the paired
await expect(review(page)).toHaveAccessibleName('Delete or archive selected prompts');
const grid=review(page).locator('dl.meta-grid > div');
await expect(grid.locator('dt')).toHaveText(['Delete permanently','Archive','Protected (kept)','Image links removed','Backup']);
await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair','1 prompt in a saved pair: hidden, restorable',
// #512: Cat portrait is also another prompt's negative in the fixture, so the review says so.
await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair',"1 prompt used as another prompt's negative: hidden, restorable",
'1: Library extra 0000','0 images will no longer show these prompts (files stay on disk)','Create and verify before deleting']);
await expect(review(page).locator('p.small')).toHaveCount(3);
await expect(review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true})).toHaveClass(/danger/);
@@ -79,22 +82,26 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
await openReview(page);
await expect(review(page)).toHaveAccessibleName('Archive selected prompts');
// The archive reason is the saved pair, for one or many prompts (live note on #470): never negative variants.
await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText('2 prompts in a saved pair: hidden, restorable');
await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText("2 prompts in a saved pair (1 used as another prompt's negative): hidden, restorable");
await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click();
await expect(toast(page)).toHaveText('Archived 2 prompts.');
await expect(cards(page)).toHaveCount(before.length-2);
await expect(showArchived(page)).toHaveAttribute('aria-pressed','false');
// #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);
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);
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 +111,247 @@ 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);
});
// #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});
const editor=page=>page.locator('dialog.library-editor[open]');
async function deleteFromPage(page,id){
await page.goto(`/prompt_manager/workspace#/library/${id}`);
await editMetadata(page).click();
await expect(editor(page).locator('.dialog-actions button')).toHaveText(['Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
await expect(editor(page).getByRole('button',{name:'Delete prompt…',exact:true})).toHaveClass(/\bdanger\b/);
await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await expect(editor(page)).toHaveCount(0);await expect(review(page)).toBeVisible();
}
test('Delete prompt… on a prompt\'s page archives it by default, and deletes it permanently when chosen (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const previews=recordPosts(page,PREVIEW);
await deleteFromPage(page,dual.id);
expect(previews.map(body=>body.prompt_ids)).toEqual([[dual.id]]);
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('Archived 1 prompt.');
// The page stays and shows its Archived notice.
await expect(page.locator('.library-archived-notice h2')).toHaveText('Archived');
await expect(page.locator('#workspace-main h1')).toBeFocused();
await page.locator('.library-archived-notice').getByRole('button',{name:'Restore this prompt to the Prompt library'}).click();
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
// Delete permanently: back to the Prompt library, the prompt gone.
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /);
await expect(page).toHaveURL(/#\/library$/);
await expect(card(page,dual.id)).toHaveCount(0);
expect(byText(await items(request),'Dual role portrait')).toBeUndefined();
});
test('Cancel on the page\'s review changes nothing and returns focus to Edit metadata (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const writes=recordPosts(page,DELETE);
await deleteFromPage(page,dual.id);
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
expect(writes).toEqual([]);
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
});
test('the page\'s delete holds one review at a time, Escape returns focus, and an archived page\'s review is close-only (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const previews=recordPosts(page,PREVIEW);
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route(`**${PREVIEW}`,async item=>{await held;await item.fallback();});
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
// While the preview is held, Edit metadata is held too: no second review and no save can race the delete.
await expect(editMetadata(page)).toHaveAttribute('aria-disabled','true');
await editMetadata(page).click({force:true});await expect(editor(page)).toHaveCount(0);
release();
await expect(review(page)).toBeVisible();
expect(previews).toHaveLength(1);
expect(previews[0].expected_epoch).toMatch(/\S/);
await page.keyboard.press('Escape');
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
await page.unroute(`**${PREVIEW}`);
// Archived: its review has nothing to change and only closes.
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await expect(page.locator('.library-archived-notice')).toBeVisible();
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
// #512: the page's review names its one prompt.
await expect(review(page).locator('p.small').last()).toHaveText('This prompt cannot be deleted or archived.');
await expect(review(page).getByRole('button',{name:/^(Delete|Archive)/})).toHaveCount(0);
});
test('a failed review on the prompt\'s page says so, changes nothing and releases Edit metadata (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const writes=recordPosts(page,DELETE);
await page.route(`**${PREVIEW}`,item=>item.fulfill({status:500,contentType:'application/json',body:JSON.stringify({success:false,code:'review-failed',error:'x'})}),{times:1});
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await expect(toast(page)).toHaveText('The selected prompts could not be reviewed. Try again.');
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
expect(writes).toEqual([]);
await editMetadata(page).click();await expect(editor(page)).toBeVisible();
});
test('a delete answered after the page was left invalidates the shelf without a toast or a jump (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
let release;const held=new Promise(resolve=>{release=resolve;});
let answered;const done=new Promise(resolve=>{answered=resolve;});
await page.route(`**${DELETE}`,async item=>{await held;const response=await item.fetch();await item.fulfill({response});answered();});
await deleteFromPage(page,dual.id);
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await page.evaluate(()=>{window.location.hash='#/overview';});
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
// Every toast text shown from here on is recorded, so one shown and hidden again still counts.
await page.evaluate(()=>{window.seenToasts=[];new MutationObserver(()=>{for(const node of document.querySelectorAll('.toast'))
if(node.textContent.trim())window.seenToasts.push(node.textContent.trim());}).observe(document.body,{subtree:true,childList:true,characterData:true});});
release();await done;
// The answer lands on Overview: no navigation; the shelf reads again when opened; and no toast was ever shown.
await page.evaluate(()=>{window.location.hash='#/library';});
await expect(card(page,dual.id)).toHaveCount(0);
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
expect(await page.evaluate(()=>window.seenToasts)).toEqual([]);
});
test('a restore since the prompt\'s page loaded is refused and the page reads the Library again (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await armUnseenRestores(page);
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('The library was restored or moved since this page loaded. Nothing was deleted or archived.');
await expect(page.locator('#workspace-main h1')).toBeFocused();
expect((await items(request,'archived')).map(item=>item.id)).toEqual([]);
});
test('an archived prompt\'s page says so and restores it from its notice',async({page,request})=>{
const cat=byText(await items(request),'Cat portrait');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
@@ -213,3 +461,148 @@ test('a failed review keeps the selection, and a selection nothing can change op
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
expect(byText(await items(request),'Library extra 0000')).toBeDefined();
});
// #512 (live test of #505/#507/#509): the prompt page's "⋯" More menu (D163), a gone prompt's page, single-prompt titles.
const more=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'More actions for this prompt',exact:true});
const moreMenu=page=>page.getByRole('menu',{name:'More actions for this prompt'});
test('the prompt page\'s More menu opens Delete prompt…, by mouse and keyboard, and closes on Escape or outside (#512 D163)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
const heading=page.locator('#workspace-main .heading-actions > button, #workspace-main .heading-actions > .more-menu > button');
await expect(heading).toHaveText(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
await expect(more(page)).toHaveAttribute('aria-haspopup','menu');await expect(more(page)).toHaveAttribute('aria-expanded','false');
await expect(moreMenu(page)).toBeHidden();
// Keyboard: Enter opens on the first item; Escape closes and returns focus.
await more(page).focus();await page.keyboard.press('Enter');
await expect(more(page)).toHaveAttribute('aria-expanded','true');
const item=moreMenu(page).getByRole('menuitem',{name:'Delete prompt…',exact:true});
await expect(item).toBeFocused();await expect(item).toHaveClass(/\bdanger\b/);
await page.keyboard.press('ArrowDown');await expect(item).toBeFocused();
await page.keyboard.press('Escape');
await expect(moreMenu(page)).toBeHidden();await expect(more(page)).toBeFocused();await expect(more(page)).toHaveAttribute('aria-expanded','false');
// ArrowUp opens on the last item; ArrowUp, Home and End move within it (one item here); Tab closes it.
await page.keyboard.press('ArrowUp');await expect(item).toBeFocused();
for(const key of ['ArrowUp','Home','End']) {await page.keyboard.press(key);await expect(item).toBeFocused();}
await page.keyboard.press('Tab');await expect(moreMenu(page)).toBeHidden();await expect(more(page)).toHaveAttribute('aria-expanded','false');
// Space opens too; an outside press closes it.
await more(page).focus();await page.keyboard.press(' ');await expect(item).toBeFocused();
await page.locator('#workspace-main h1').click();await expect(moreMenu(page)).toBeHidden();
// Choosing it opens the page's review, titled for this prompt; Cancel returns focus to ⋯.
await more(page).click();await item.click();
await expect(moreMenu(page)).toBeHidden();
await expect(review(page)).toHaveAccessibleName('Archive this prompt');
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
await expect(review(page).locator('#modal-title')).toHaveText('Delete this prompt');
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await expect(review(page)).toHaveCount(0);await expect(more(page)).toBeFocused();
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
});
test('the More menu fits a 390px viewport and works by touch (#512 D163)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await page.setViewportSize({width:390,height:844});
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await more(page).click();
const item=moreMenu(page).getByRole('menuitem',{name:'Delete prompt…',exact:true});
await expect(item).toBeVisible();
const box=await moreMenu(page).boundingBox();
expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(390);
await item.click();
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('Archived 1 prompt.');
await expect(page.locator('.library-archived-notice')).toBeVisible();
});
test('a deleted prompt\'s page says it no longer exists, with the way back and no Retry (#512)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await deleteFromPage(page,dual.id);
await expect(review(page)).toHaveAccessibleName('Archive this prompt');
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(page).toHaveURL(/#\/library$/);
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await expect(page.locator('#workspace-main .library-status')).toHaveText('This prompt no longer exists. It may have been deleted.');
await expect(page.getByRole('button',{name:'Retry prompt history'})).toHaveCount(0);
await expect(editMetadata(page)).toBeDisabled();await expect(more(page)).toBeDisabled();
await page.getByRole('button',{name:'Open the Prompt library',exact:true}).click();
await expect(page).toHaveURL(/#\/library$/);
});
// #524 (live test of #522): a long title clamps beside the heading actions, which stay on one row at desktop widths.
const LONG_TITLE='masterpiece, high quality, very_awa, newest, absurdres, highres, depth_of_field, detailed eyes, realistic eyes, fake eyelashes, black eyes, white_nails, 1girl, solo, black bob_cut, upper body';
async function openLongTitle(page,request,width) {
const dual=byText(await items(request),'Dual role portrait');
await page.route(new RegExp(`/prompt_manager/library/${dual.id}\\?`),async route=>{
const response=await route.fetch(),json=await response.json();
await route.fulfill({response,json:{...json,positive:{...json.positive,title:LONG_TITLE}}});
});
await page.setViewportSize({width,height:900});
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await expect(page.locator('#workspace-main h1')).toHaveText(LONG_TITLE);
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=document.documentElement.clientWidth)).toBe(true);
return page.locator('#workspace-main .heading-actions > button, #workspace-main .heading-actions > .more-menu > button');
}
for(const width of [1440,951]) test(`a long title clamps and the heading actions stay on one row at ${width}px (#524)`,async({page,request})=>{
const actions=await openLongTitle(page,request,width);
await expect(actions).toHaveText(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
const tops=await actions.evaluateAll(nodes=>nodes.map(node=>Math.round(node.getBoundingClientRect().top)));
expect(new Set(tops).size).toBe(1);
const h1=page.locator('#workspace-main h1');
const shape=await h1.evaluate(node=>({scroll:node.scrollHeight,client:node.clientHeight,line:parseFloat(getComputedStyle(node).lineHeight),title:node.title}));
expect(shape.scroll).toBeGreaterThan(shape.client);
expect(shape.client).toBeLessThanOrEqual(Math.ceil(shape.line*2));
// The clamp hides pixels only: the whole title stays the text and shows on hover.
expect(shape.title).toBe(LONG_TITLE);
// The buttons do not shrink: no label is cut or broken onto a second line.
const broken=await actions.evaluateAll(nodes=>nodes.filter(node=>node.scrollWidth>node.clientWidth||node.getBoundingClientRect().height>44).length);
expect(broken).toBe(0);
const [h1Box,firstBox]=[await h1.boundingBox(),await actions.first().boundingBox()];
expect(h1Box.x+h1Box.width).toBeLessThanOrEqual(firstBox.x);
});
test('a long title at 390px keeps the concept\'s stacked heading, not clamped, inside the viewport (#524)',async({page,request})=>{
const actions=await openLongTitle(page,request,390);
const h1=await page.locator('#workspace-main h1').evaluate(node=>({scroll:node.scrollHeight,client:node.clientHeight}));
expect(h1.scroll).toBeLessThanOrEqual(h1.client);
const right=await actions.evaluateAll(nodes=>Math.max(...nodes.map(node=>node.getBoundingClientRect().right)));
expect(right).toBeLessThanOrEqual(390);
});
test('a deleted prompt\'s page has no history subtitle, and a page that loads shows it (#524)',async({page,request})=>{
const list=await items(request),dual=byText(list,'Dual role portrait'),dog=byText(list,'Dog landscape');
const description=page.locator('#workspace-main > .heading p.muted');
await deleteFromPage(page,dual.id);
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(page).toHaveURL(/#\/library$/);
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await expect(page.locator('#workspace-main .library-status')).toHaveText('This prompt no longer exists. It may have been deleted.');
await expect(description).toBeHidden();await expect(description).toHaveText('');
await page.goto(`/prompt_manager/workspace#/library/${dog.id}`);
await expect(description).toHaveText('Prompt history · one positive, one shared gallery');await expect(description).toBeVisible();
});
test('the archived shelf\'s heading count and its list agree under a remembered search (#512 item 5)',async({page,request})=>{
const all=await items(request);
const dual=byText(all,'Dual role portrait'),dog=byText(all,'Dog landscape');
await archiveByApi(request,[dual.id]);
await page.goto(route);
// A search that matches a shelf prompt but not the archived one.
await page.getByRole('searchbox').fill('landscape');
await expect(card(page,dog.id)).toHaveCount(1);await expect(card(page,dual.id)).toHaveCount(0);
const matching=(await(await request.get('/prompt_manager/library?limit=100&shelf=archived&text=landscape')).json()).items.length;
await expect(showArchived(page)).toHaveText(`Show archived prompts (${matching})`);
// Leave and come back: the remembered search is shown and still counts the same.
await page.goto('/prompt_manager/workspace#/overview');await page.goto(route);
await expect(page.getByRole('searchbox')).toHaveValue('landscape');
await expect(showArchived(page)).toHaveText(`Show archived prompts (${matching})`);
await showArchived(page).click();
await expect(backToLibrary(page)).toBeVisible();
if(matching) await expect(cards(page)).toHaveCount(matching);
else await expect(page.locator('.library-status')).toHaveText('No archived prompts match.');
await page.getByRole('searchbox').fill('');
await expect(card(page,dual.id)).toHaveCount(1);
await backToLibrary(page).click();
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
});
+37 -28
View File
@@ -8,7 +8,7 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c
// Dialog-level contracts mount the editor through a page import; the user flows below use the real entry actions.
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const workspace='/prompt_manager/workspace';
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive variant…','Delete prompt…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save.
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes'];
const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.';
@@ -27,13 +27,15 @@ async function mountEditor(page,theme) {
if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.evaluate(async()=>{
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
window.editorEvents={committed:[],reconciled:[]};
window.editorEvents={committed:[],reconciled:[],deletePrompt:[]};
try {
const {createLibraryEditor,showWorkspaceToast}=await import('/prompt_manager/js/workspace/library-editor.mjs');
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const scope=createScope();
const editor=createLibraryEditor({api:window.ApiClient.createApiClient(),parentScope:scope,
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result)});
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result),
// As the prompt's page wires it (#481); the review itself is covered in workspace-library-delete.spec.mjs.
onDeletePrompt:request=>window.editorEvents.deletePrompt.push(request.positiveId)});
window.editorHarness={scope,editor,showWorkspaceToast};
} catch(error) {window.editorHarness={missing:String(error)};}
});
@@ -52,7 +54,7 @@ const pairRead=async(request,id)=>(await(await request.get(`/prompt_manager/pair
test('edit recipe metadata shows the concept fields with the selected saved negative read-only',async({page})=>{
const f=page.fixture;await mountEditor(page);
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
await expect(dialog.getByRole('heading',{name:'Edit recipe metadata',exact:true})).toBeVisible();
expect(await dialog.evaluate(node=>node.matches(':modal'))).toBe(true);
await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog);
@@ -73,9 +75,14 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
await expect(dialog.locator('.demo-note')).toHaveCount(0);
expect(await actionNames(dialog)).toEqual(ACTIONS.metadata);
await expect(button(dialog,'Archive…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
await expect(button(dialog,'Archive variant…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
// D161 (#481): Delete prompt… closes the editor first and hands the prompt to the page's delete review.
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await expect(button(dialog,'Delete prompt…')).toHaveClass(/\bdanger\b/);await button(dialog,'Delete prompt…').click();
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(()=>window.editorEvents.deletePrompt)).toEqual([f.positive_id]);expect(page.writes).toHaveLength(0);
});
test('editor modes persist through reload with selected saved negative',async({page,request})=>{
@@ -144,13 +151,13 @@ test('editor modes persist through reload with selected saved negative',async({p
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.standalone_id,pairId:null});await ready(dialog);
const archive=button(dialog,'Archive…');await expect(archive).toBeDisabled();
const archive=button(dialog,'Archive variant…');await expect(archive).toBeDisabled();
await expect(archive).toHaveAccessibleDescription('Archive needs a selected saved negative variant.');
await expect(note(dialog)).toHaveText('Archive needs a selected saved negative variant.');
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive…').click();
await button(dialog,'Archive variant…').click();
await expect(dialog.getByRole('heading',{name:'Archive negative variant',exact:true})).toBeVisible();
await expect(button(dialog,'Close dialog')).toBeFocused();
const block=dialog.getByRole('region',{name:'Negative prompt',exact:true});
@@ -163,7 +170,7 @@ test('archive confirms the selected pair with fresh counts and writes only on co
expect((await pairRead(request,f.pair_id)).archived).toBe(false);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:true}}]);
expect((await pairRead(request,f.pair_id)).archived).toBe(true);
@@ -178,7 +185,7 @@ test('a failed archive keeps focus on the Archive confirmation inside the dialog
failed=true;return route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Cannot archive.","code":null}'});
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toContainText('Cannot archive.');expect(page.writes).toHaveLength(1);
await expect(button(dialog,'Archive')).toBeFocused();
@@ -192,7 +199,7 @@ test('an unconfirmed archive moves focus to Retry instead of dropping it out of
await route.continue();
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('Archive could not be confirmed. Retry sends the same archive once.');
await expect(button(dialog,'Archive')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused();
@@ -209,7 +216,7 @@ test('an archive conflict offers an explicit Reload that re-reads and never repl
conflicted=true;return route.fulfill({status:409,contentType:'application/json',body:'{"success":false,"error":"changed","code":"metadata-conflict"}'});
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('This negative variant changed since it was loaded. Reload it and review it before archiving.');
await expect(button(dialog,'Archive')).toBeDisabled();expect(page.writes).toHaveLength(1);
@@ -237,7 +244,7 @@ test('cancel and busy submit have honest write counts',async({page})=>{
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Notes').fill('busy save');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Saving… The dialog stays open until the server answers.');
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
await field(dialog,'Title').press('Enter');await page.keyboard.press('Escape');
await expect(dialog).toBeVisible();expect(page.writes).toHaveLength(1);
release();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(1);
@@ -319,15 +326,15 @@ test('edge states keep drafts, explain disabled archive and clean up on dispose'
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.archived_pair_id});await ready(dialog);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.archived_negative);
await expect(button(dialog,'Archive…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
await expect(button(dialog,'Archive…')).toHaveAccessibleDescription('This negative variant is already archived.');
await expect(button(dialog,'Archive variant…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
await expect(button(dialog,'Archive variant…')).toHaveAccessibleDescription('This negative variant is already archived.');
await button(dialog,'Cancel').click();
let failed=true;
await page.route(`**/prompt_manager/pairs/${f.empty_pair_id}`,route=>{if(!failed&&route.request().method()==='GET'){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'});}return route.continue();});
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.empty_pair_id});await ready(dialog);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue('');
failed=false;await button(dialog,'Archive…').click();
failed=false;await button(dialog,'Archive variant…').click();
await expect(note(dialog)).toHaveText('Linked images and recorded runs could not be read. Nothing was archived.');
await expect(button(dialog,'Archive')).toBeDisabled();
await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
@@ -347,12 +354,12 @@ test('the archive confirmation does not inherit a failed or invalid Edit save me
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Notes').fill('Rejected notes');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Not saved: Notes are too long. Your entries are kept.');
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Title').fill(' ');await button(dialog,'Save').click();await expect(note(dialog)).toHaveText('Title is required');
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
expect(page.writes).toHaveLength(1);
});
@@ -493,9 +500,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/);
@@ -532,8 +540,9 @@ test('new from Images cancel preserves browse',async({page})=>{
test('history heading follows the concept and sends the selected saved pair',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
const actions=headingActions(page).locator('button');
expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
const actions=headingActions(page).locator('button:not([role="menuitem"])');
// D163 (#512): the More menu button follows Send to ComfyUI.
expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
@@ -641,14 +650,14 @@ test('restored storage after a confirmed save or before an archive offers an exp
const saved=page.waitForResponse(item=>item.request().method()==='PATCH'&&new URL(item.url()).pathname===`/prompt_manager/library/${f.positive_id}`);
await button(dialog,'Save').click();await saved;
await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.');
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);
await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1);
await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await expect(await restore()).toBeOK();await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was archived.');
expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Archive']);expect(page.writes).toHaveLength(1);
@@ -665,10 +674,10 @@ test('restored storage before an Edit or Add negative save offers only the Libra
await expect(await restore()).toBeOK();
await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`);
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']);
await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled();
// Revise and archive would re-read the old numeric IDs, which may now name another prompt.
for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled();
await expect(button(dialog,'Reload Library')).toBeFocused();
expect(page.writes).toHaveLength(1);// the refused save
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
@@ -735,7 +744,7 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
const before=await evidence();expect(before.counts).toEqual({images:1,runs:2});
await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
@@ -878,7 +887,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
await toggle().click();await expect(toggle()).toHaveAttribute('aria-pressed','false');await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0);
await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
+1 -1
View File
@@ -176,7 +176,7 @@ test('failed library reads retry while late search responses cannot overwrite ne
// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added.
test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));});
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&url.searchParams.get('limit')!=='1')sorts.push(url.searchParams.get('sort'));});// limit=1 is a sidebar count read, not the shelf
await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});});
await expect(cards(page)).toHaveCount(3);
+27
View File
@@ -221,6 +221,33 @@ test('tag image from review saves tags and the item remains pending', async ({ p
await expect(dialog).toHaveCount(0);
});
test('#515: tags typed before the image observation answers are saved', async ({ page, request }) => {
await seed(request, 1);
await open(page);
// Hold the dialog's observation so the typing lands while it is pending (a slow host or WebKit in CI).
let release;
const held = new Promise(resolve => { release = resolve; });
const matcher = '**/prompt_manager/image-annotations/observe';
let reachedObserve;
const reached = new Promise(resolve => { reachedObserve = resolve; });
await page.route(matcher, async route => { reachedObserve(); await held; await route.continue(); });
await panel(page).getByRole('button', { name: 'Tag image', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Tag image', exact: true });
await reached;
const field = dialog.getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
await field.fill('dunes');
const save = dialog.getByRole('button', { name: 'Save manual tags', exact: true });
await expect(save).toHaveAttribute('aria-disabled', 'true');
const answered = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
release();
await answered;
await expect(save).not.toHaveAttribute('aria-disabled', 'true');
await expect(field).toHaveValue('dunes');
await save.click();
await expect(toast(page)).toHaveText('1 tags saved for review_0000.png. Prompt association still awaits review.');
await page.unroute(matcher);
});
test('an empty queue shows the empty notice only', async ({ page, request }) => {
await seed(request, 0);
await page.goto(review);
+59 -16
View File
@@ -60,6 +60,10 @@ test.afterEach(async ({ page, browserName }) => {
test('workspace starts with real persisted statistics without mutating data', async ({ page, request }) => {
const before = await (await request.get('/prompt_manager/stats')).json();
const total = async path => (await (await request.get(`/prompt_manager/${path}`)).json()).total;
const counts = [['Prompts', await total('library?limit=1')],
['Images', await total('images/output?mode=workspace&limit=1')],
['Tags', await total('vocabulary/tags?limit=1')]];
const mutations = [];
page.on('request', request => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
@@ -69,10 +73,11 @@ test('workspace starts with real persisted statistics without mutating data', as
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
for (const [label, key] of [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]) {
await expect(statistic(page, label)).toHaveText(String(before.stats[key]));
for (const [label, count] of counts) await expect(statistic(page, label)).toHaveText(String(count));
// #547: a card and the sidebar badge of the page it names show one number.
for (const [route, count] of [['library', counts[0][1]], ['images', counts[1][1]], ['tags', counts[2][1]]]) {
await expect(page.locator(`aside.sidebar nav [data-route="${route}"] .nav-count`)).toHaveText(String(count));
}
expect(before.stats.total_prompts).toBe(2);
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(before.stats);
expect(mutations).toEqual([]);
await expect(page.locator('iframe')).toHaveCount(0);
@@ -287,25 +292,65 @@ test('unavailable host palette falls back to the system theme and tracks system
});
test('overview reports loading, offers retry after failure and renders real empty statistics', async ({ page }) => {
const sources = ['**/prompt_manager/library?limit=1', '**/prompt_manager/images/output?mode=workspace&limit=1',
'**/prompt_manager/vocabulary/tags?limit=1'];
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/stats', async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
for (const source of sources) {
await page.route(source, async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
}
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await page.unroute('**/prompt_manager/stats');
await page.route('**/prompt_manager/stats', route => route.fulfill({
json: { success: true, stats: { total_prompts: 0, total_images: 0, total_tags: 0 } },
}));
for (const source of sources) {
await page.unroute(source);
await page.route(source, route => route.fulfill({ json: { success: true, total: 0, items: [], images: [], tags: [] } }));
}
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
for (const label of ['Prompts', 'Images', 'Tags']) {
await expect(statistic(page, label)).toHaveText('0');
}
await expect(page.locator('#workspace-main .empty-state')).toBeVisible();
});
test('a slow or failed Images read leaves the other Overview cards and retries only itself', async ({ page }) => {
const images = '**/prompt_manager/images/output?mode=workspace&limit=1';
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route(images, async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Prompts')).toHaveText('2');
await expect(statistic(page, 'Images')).toHaveText('–');
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await expect(statistic(page, 'Prompts')).toHaveText('2');
await page.unroute(images);
let libraryReads = 0;
page.on('request', request => { if (new URL(request.url()).pathname === '/prompt_manager/library') libraryReads++; });
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
expect(libraryReads).toBe(0);
});
test('opening Overview reads each count once beside the sidebar', async ({ page }) => {
let reads = 0;
page.on('request', request => {
const url = new URL(request.url());
if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('favorites') === null) reads++;
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Images')).toHaveText(/^\d+$/);
expect(reads).toBe(1);
});
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
@@ -324,11 +369,9 @@ test('leaving a loading overview prevents its delayed result from replacing the
let completed;
const pending = new Promise(resolve => { release = resolve; });
const finished = new Promise(resolve => { completed = resolve; });
await page.route('**/prompt_manager/stats', async route => {
await page.route('**/prompt_manager/library?limit=1', async route => {
await pending;
await route.fulfill({ json: {
success: true, stats: { total_prompts: 999, total_images: 888, total_tags: 777 },
} }).catch(() => {});
await route.fulfill({ json: { success: true, total: 999, items: [] } }).catch(() => {});
completed();
});
await page.goto(`${workspace}#/overview`);
@@ -351,7 +394,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
let reads = 0;
page.on('request', request => {
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
if (new URL(request.url()).pathname === '/prompt_manager/vocabulary/tags') reads++;
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Prompts')).toHaveText('2');
+2 -2
View File
@@ -8,7 +8,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
text: `Workspace host smoke ${testInfo.project.name}`, tags: ['workspace-host'],
} });
expect(saved.ok()).toBe(true);
const stats = (await (await request.get('/prompt_manager/stats')).json()).stats;
const library = await (await request.get('/prompt_manager/library?limit=1')).json();
const workspace = await context.newPage();
const errors = [];
workspace.on('pageerror', error => errors.push(error.message));
@@ -16,7 +16,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
await workspace.goto('/prompt_manager/workspace#/overview');
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
.toHaveText(stats.total_prompts.toLocaleString());
.toHaveText(library.total.toLocaleString());
await workspace.getByRole('combobox', { name: 'Theme' }).selectOption('light');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
const imageResponse = workspace.waitForResponse(response => {
+1
View File
@@ -122,6 +122,7 @@
"POST /prompt_manager/images/generate-thumbnails",
"POST /prompt_manager/images/generate-thumbnails/progress",
"POST /prompt_manager/images/link",
"POST /prompt_manager/images/removed/return",
"POST /prompt_manager/images/rescope",
"POST /prompt_manager/images/reveal",
"POST /prompt_manager/jobs/autotag",
@@ -876,3 +876,63 @@ test('retry after a changed storage, file or expired capture observes again and
await settle();
assert.equal(c.state.manual, 'Minimal, warm_light');
});
test('#515: text typed while the first observation is pending is kept, after the saved tags', async () => {
const { createImageTaggingController } = await load();
const answers = [deferred(), deferred()];
let n = 0;
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answers[n++].promise }));
// An untagged file: the typed text is the whole draft and Save sends it.
const bare = createImageTaggingController({ api: h.api, target: TARGET, source: 'review' });
const first = bare.open();
bare.setManual('dunes');
answers[0].resolve(response(200, observed({ tags: [], tag_revision: 0 })));
await first;
await settle();
assert.equal(bare.state.phase, 'ready');
assert.equal(bare.state.manual, 'dunes');
assert.equal(bare.state.statusText, '', 'nothing changed elsewhere');
await bare.save();
await settle();
assert.deepEqual(h.of('PUT', TAGS).at(-1).body.tags, ['dunes']);
// A tagged file: its saved tags lead, so a Save never drops tags the user had not seen yet.
const tagged = createImageTaggingController({ api: h.api, target: TARGET });
const second = tagged.open();
tagged.setManual('dunes, minimal');
answers[1].resolve(response(200, observed()));
await second;
await settle();
assert.equal(tagged.state.manual, 'Minimal, warm_light, dunes');
assert.equal(tagged.state.statusText, '');
});
test('#515: a kept draft takes the text typed while the reopen observes', async () => {
const { c } = await opened({ [`PUT ${TAGS}`]: () => fail(409, 'file-changed', 'The file changed.') });
c.setManual('kept draft');
await c.save();
await settle();
const reopened = c.retry();
c.setManual('kept draft, later');
await reopened;
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.manual, 'kept draft, later');
});
test('#515: text typed before Retry after a failed first observation follows the saved tags', async () => {
const { createImageTaggingController } = await load();
let n = 0;
const h = harness(defaultRoutes({
[`POST ${OBSERVE}`]: () => (n++ === 0 ? fail(500, 'internal', 'boom') : response(200, observed())),
}));
const c = createImageTaggingController({ api: h.api, target: TARGET });
await c.open();
await settle();
assert.equal(c.state.phase, 'observe-error');
c.setManual('dunes');
await c.retry();
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.manual, 'Minimal, warm_light, dunes');
assert.equal(c.state.statusText, '');
});
+91
View File
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
// #498 (D162): Images › Show removed images › Return to library: the words, the request and the toasts.
const loaded = await import('../../web/js/workspace/images-return.mjs').catch(() => null);
const module = () => { assert.ok(loaded, 'images-return.mjs is not implemented'); return loaded; };
const pageScope = () => {
const controller = new AbortController();
return { signal: controller.signal, cleanup: () => {}, abort: () => controller.abort() };
};
const image = (path, root_index = 0) => ({ path, root_index, root_scope: 's0', filename: path });
test('the toggle, the caption and the empty view', () => {
const { toggleLabel, removedCaption, removedEmpty, RETURN_LABEL } = module();
assert.equal(toggleLabel(null), 'Show removed images');
assert.equal(toggleLabel(1234), 'Show removed images (1,234)');
assert.equal(removedCaption(1), '1 removed image · files on disk, hidden from Images');
assert.equal(removedCaption(3), '3 removed images · files on disk, hidden from Images');
assert.equal(removedEmpty(false), 'No removed images.');
assert.equal(removedEmpty(true), 'No removed images match.');
assert.equal(RETURN_LABEL, 'Return to library');
});
test('the finished toast says what came back and what did not', () => {
const { finishedToast, startToast } = module();
assert.equal(startToast(2), 'Returning 2 images to the library…');
assert.equal(finishedToast({ state: 'finished', counters: { added: 1 } }, 2),
'Returned 2 images to the library; 1 image linked to its prompt. Tags, categories, collections and favorites do not come back.');
assert.equal(finishedToast({ state: 'finished', counters: { added: 3 } }, 3),
'Returned 3 images to the library; 3 images linked to their prompts. Tags, categories, collections and favorites do not come back.');
assert.equal(finishedToast({ state: 'finished', counters: {} }, 1),
'Returned 1 image to the library. Tags, categories, collections and favorites do not come back.');
assert.match(finishedToast({ state: 'failed', status_line: 'Disk error' }, 1), /^Returning images stopped: Disk error\.$/);
assert.match(finishedToast({ state: 'cancelled' }, 1), /cancelled/);
});
test('refusals name the cause', () => {
const { returnFailure, NONE_REMOVED, TOO_MANY, SOURCE_CHANGED, RETURN_FAILED } = module();
assert.equal(returnFailure({ code: 'no-images' }), NONE_REMOVED);
assert.equal(returnFailure({ code: 'too-many-images' }), TOO_MANY);
assert.equal(returnFailure({ code: 'source-changed' }), SOURCE_CHANGED);
assert.equal(returnFailure({ code: 'busy', message: 'Scan for metadata is running.' }), 'Scan for metadata is running.');
assert.equal(returnFailure(new Error('x')), RETURN_FAILED);
});
test('over 500 refuses with the toast before any request', async () => {
const { returnImages, TOO_MANY } = module();
const toasts = [], posts = [];
const api = { post: async (...args) => { posts.push(args); return {}; } };
await returnImages({ api, scope: pageScope(), filter: { q: '' }, count: 501, notify: text => toasts.push(text) });
assert.deepEqual(toasts, [TOO_MANY]);
assert.deepEqual(posts, []);
});
test('items post their identities; a filter posts the removed view; the job is tracked and followed', async () => {
const { returnImages, RETURN_URL } = module();
const toasts = [], posts = [], ended = [];
let listener = null;
const job = { id: 'j1', title: 'Return images to the library', state: 'running', counters: {} };
const store = {
track: started => started,
find: () => job,
subscribe: fn => { listener = fn; return () => { listener = null; }; },
};
const api = { post: async (url, body) => { posts.push([url, body]); return { job, returned: 2, not_removed: 0 }; } };
const started = await returnImages({ api, scope: pageScope(), store, images: [image('a.png'), image('b.png', 1)],
notify: text => toasts.push(text), onReturned: done => ended.push(done) });
assert.equal(started, job);
assert.deepEqual(posts[0], [RETURN_URL, { items: [
{ root_index: 0, root_scope: 's0', path: 'a.png' }, { root_index: 1, root_scope: 's0', path: 'b.png' }] }]);
assert.deepEqual(toasts, ['Returning 2 images to the library…']);
job.state = 'finished'; job.counters = { added: 2 };
listener();
assert.deepEqual(ended, [job]);
assert.match(toasts[1], /^Returned 2 images to the library; 2 images linked/);
await returnImages({ api, scope: pageScope(), filter: { q: 'x' }, count: 2, notify: () => {} });
assert.deepEqual(posts[1][1], { filter: { q: 'x', removed: '1' } });
});
test('a refused return toasts, releases the trigger and changes nothing', async () => {
const { returnImages, NONE_REMOVED } = module();
const toasts = [];
const attributes = new Map();
const trigger = { getAttribute: name => attributes.get(name) ?? null, setAttribute: (name, value) => attributes.set(name, value),
removeAttribute: name => attributes.delete(name) };
const api = { post: async () => { throw Object.assign(new Error('no'), { code: 'no-images' }); } };
const result = await returnImages({ api, scope: pageScope(), images: [image('a.png')], trigger, notify: text => toasts.push(text) });
assert.equal(result, null);
assert.deepEqual(toasts, [NONE_REMOVED]);
assert.equal(trigger.getAttribute('aria-disabled'), null);
});
+184 -7
View File
@@ -42,8 +42,13 @@ test('the grid shows each row only when it applies, with protected titles in sel
['Image links removed', '3 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
// #512: a row with nothing in it is left out.
const archiveOnly = reviewOf(preview({archive: [2]}));
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Delete permanently', 'Archive']);
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Archive']);
const deleteOnly = reviewOf(preview({delete: [1]}));
assert.deepEqual(reviewGrid(deleteOnly).map(([term]) => term), ['Delete permanently', 'Image links removed', 'Backup']);
const keptOnly = reviewOf(preview({protected: [7]}));
assert.deepEqual(reviewGrid(keptOnly, id => names[id]), [['Protected (kept)', '1: Seven']]);
const off = reviewOf(preview({delete: [1], backup: 'off'}));
assert.equal(reviewGrid(off).at(-1)[1], 'Off: no safety backup (System › Backup & export › Recovery policy)');
});
@@ -64,7 +69,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.');
@@ -91,10 +97,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);
});
@@ -132,9 +138,10 @@ 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,
imageLinks: 2, backup: 'on', disk: null});
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', archiveOnly: []});
});
test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => {
@@ -148,3 +155,173 @@ 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.');
});
// #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.');
});
// #512 (live test of #505/#507/#509): the review names a single prompt, and a negative selected without its positive.
test('a review from a prompt\'s own page names that prompt', async () => {
const {reviewOf, reviewTitle, blockedReason} = await source;
const single = review => ({...review, single: true});
assert.equal(reviewTitle(single(reviewOf(preview({delete: [1]})))), 'Delete this prompt');
assert.equal(reviewTitle(single(reviewOf(preview({archive: [2]})))), 'Archive this prompt');
const kept = single(reviewOf(preview({protected: [3]})));
assert.equal(reviewTitle(kept), 'Delete this prompt');
assert.equal(blockedReason(kept), 'This prompt cannot be deleted or archived.');
assert.equal(reviewTitle(single(reviewOf(preview({archive: [2], deletable: [2], review_digests: {2: 'c'.repeat(64)}}),
'archived'))), 'Delete this prompt');
});
test('a negative used by another prompt is named as such when it is archived', async () => {
const {reviewOf, reviewGrid, reviewSentences} = await source;
const negative = reviewOf(preview({archive: [5], deletable: []}));
assert.deepEqual(reviewGrid(negative), [['Archive', "1 prompt used as another prompt's negative: hidden, restorable"]]);
assert.deepEqual(reviewSentences(negative), ["Prompts used as another prompt's negative are archived instead of deleted, so " +
"that prompt's variant stays. To bring them back, press Show archived prompts, select them and press Restore."]);
const mixed = reviewOf(preview({archive: [2, 5], deletable: [2], review_digests: {2: 'c'.repeat(64)}}));
assert.deepEqual(reviewGrid(mixed)[0], ['Archive', "2 prompts in a saved pair (1 used as another prompt's negative): hidden, restorable"]);
// A paired prompt whose review digest is missing is not called another prompt's negative (review M1).
const noDigest = reviewOf(preview({archive: [2], deletable: [2], review_digests: {}}));
assert.deepEqual(reviewGrid(noDigest), [['Archive', '1 prompt in a saved pair: hidden, restorable']]);
const sentences = reviewSentences(mixed);
assert.match(sentences[0], /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted/);
assert.match(sentences[1], /^Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant stays\.$/);
});
+23 -11
View File
@@ -105,17 +105,29 @@ test('a job of the Activity page is its own deep link', async () => {
dispose();
});
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
const { summarizeStats } = await source;
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
['Prompts', 0], ['Images', 12], ['Tags', 3],
]);
for (const stats of [undefined, null, {}, {total_prompts:0, total_images:-1, total_tags:0},
{total_prompts:'2', total_images:0, total_tags:0},
{total_prompts:Infinity, total_images:0, total_tags:0},
{total_prompts:0, total_images:1.5, total_tags:0}]) {
assert.throws(() => summarizeStats(stats), /statistics/i);
}
test('Overview cards show the count of the page they name', async () => {
const { summarizeOverviewCounts, OVERVIEW_COUNT_ROUTES } = await source;
assert.deepEqual(OVERVIEW_COUNT_ROUTES, ['library', 'images', 'tags']);
const counts = new Map([['library', 64], ['images', 122], ['tags', 306], ['favorites', 8]]);
assert.deepEqual(summarizeOverviewCounts(counts), {
cards: [['Prompts', 64], ['Images', 122], ['Tags', 306]], pending: false, failed: false, empty: false,
});
});
test('Overview tells loading, failed and really empty counts apart', async () => {
const { summarizeOverviewCounts } = await source;
const nothing = summarizeOverviewCounts(new Map());
assert.deepEqual([nothing.pending, nothing.failed, nothing.empty], [true, false, false]);
assert.deepEqual(nothing.cards, [['Prompts', null], ['Images', null], ['Tags', null]]);
// A slow Images read does not hold back the cards already read.
const partial = summarizeOverviewCounts(new Map([['library', 0], ['tags', 0]]));
assert.deepEqual(partial.cards, [['Prompts', 0], ['Images', null], ['Tags', 0]]);
assert.deepEqual([partial.pending, partial.failed, partial.empty], [true, false, false]);
const failed = summarizeOverviewCounts(new Map([['library', 2], ['images', null], ['tags', 3]]));
assert.deepEqual([failed.pending, failed.failed, failed.empty], [false, true, false]);
assert.deepEqual(failed.cards[1], ['Images', null]);
const empty = summarizeOverviewCounts(new Map([['library', 0], ['images', 0], ['tags', 0]]));
assert.deepEqual([empty.pending, empty.failed, empty.empty], [false, false, true]);
});
test('router normalizes unknown routes, avoids duplicate renders, and disposes its listener', async () => {
+36
View File
@@ -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;
+2
View File
@@ -3020,6 +3020,8 @@ class TestSharedListing(ImageAPITestCase):
"limit",
"has_more",
"next_cursor",
# #498: Show removed images (N).
"removed_total",
},
)
_, folders = await self._json("GET", "/prompt_manager/gallery/subfolders")
+363
View File
@@ -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,
@@ -135,12 +137,27 @@ 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"],
# #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"])
@@ -157,9 +174,11 @@ class TestClassifyAndApply(LibraryDeleteCase):
"deleted": 1,
"archived": 1,
"already_archived": 0,
"not_archived": 0,
"not_found": 0,
"changed": 0,
"image_links": 1,
"pairs_removed": 0,
},
)
self.assertEqual(prompt_ids(self.db), sorted([protected, positive, negative]))
@@ -524,3 +543,347 @@ 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"},
)
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"
)
+71 -4
View File
@@ -320,19 +320,86 @@ class TestFileCheck(ImageDeleteTestCase):
self.assertIn(code, image_delete.REASONS)
class TestMacOSOutcomes(ImageDeleteTestCase):
"""The codes and the unconfirmed outcome the macOS backend adds (#499)."""
async def test_permission_and_cloud_refusals_show_their_sentences(self):
self.write("a.png", b"aaa")
self.write("b.png", b"bbb")
self.fake.refuse[str(self.output_dir / "a.png")] = "no-permission"
self.fake.refuse[str(self.output_dir / "b.png")] = "cloud-sync"
plan = await self.review(["a.png", "b.png"])
self.assertEqual(plan["trash"], {"count": 0})
self.assertEqual(
plan["trash_refused"]["counts"], {"no-permission": 1, "cloud-sync": 1}
)
details = {row["detail"] for row in plan["trash_refused"]["rows"]}
self.assertEqual(
details,
{image_delete.REASONS["no-permission"], image_delete.REASONS["cloud-sync"]},
)
async def test_an_unconfirmed_move_keeps_records_and_stops_the_job(self):
from utils import trash
self.write("a.png", b"aaa", mtime=1_000_000)
self.write("b.png", b"bbb", mtime=2_000_000)
self.link("a.png")
original = self.fake.move
def move(path, roots):
if path.endswith("a.png"):
original(path, roots) # the file did leave its folder
raise trash.TrashUnconfirmed("resultingItemURL=None")
return original(path, roots)
self.fake.move = move
_plan, job = await self.delete(["a.png", "b.png"], "trash")
self.assertEqual(job.state, "failed")
self.assertEqual(job.scope.get("failure"), "records-failed")
self.assertEqual(
self.details(job, "errors"), [image_delete.REASONS["trash-unconfirmed"]]
)
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 1)
self.assertEqual(
self.details(job, "not_changed"), [image_delete.REASONS["not-attempted"]]
)
self.assertTrue((self.output_dir / "b.png").exists())
async def test_a_refusal_detail_is_logged(self):
from utils import trash
self.write("a.png", b"aaa")
def move(path, roots):
raise trash.TrashRefused("no-permission", "NSCocoaErrorDomain 513")
self.fake.move = move
with self.assertLogs("prompt_manager.api", level="INFO") as logs:
_plan, job = await self.delete(["a.png"], "trash")
self.assertEqual(
self.details(job, "not_changed"), [image_delete.REASONS["no-permission"]]
)
self.assertTrue(any("NSCocoaErrorDomain 513" in line for line in logs.output))
self.assertTrue((self.output_dir / "a.png").exists())
class TestNativeBackend(ImageDeleteTestCase):
"""The real backend of this OS: Windows and macOS have no trash yet (#480)."""
"""The real backend of this OS: Windows has no trash yet (#500).
Only library mode is applied, so even on macOS and Linux no file reaches
a real trash.
"""
async def test_library_only_works_and_the_trash_is_unavailable_here(self):
fake_trash.uninstall()
self.write("a.png", b"aaa")
self.link("a.png")
plan = await self.review(["a.png"])
native_trash = sys.platform not in ("win32", "darwin")
native_trash = sys.platform != "win32"
if not native_trash:
self.assertEqual(plan["trash"], {"count": 0})
code = "windows-recycle" if sys.platform == "win32" else "no-trash"
self.assertEqual(plan["trash_refused"]["counts"], {code: 1})
self.assertEqual(plan["trash_refused"]["counts"], {"windows-recycle": 1})
body = await self.post(APPLY, {"token": plan["token"], "mode": "library"}, 202)
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS)
+10
View File
@@ -239,6 +239,16 @@ class TestWalkersSkipTrashFolders(_GapCase):
monitor.is_image_file(os.path.join(base, folder, "files", "a.png"))
)
def test_the_image_monitor_watches_a_root_named_like_a_trash_folder(self):
monitor = ImageGenerationHandler.__new__(ImageGenerationHandler)
monitor.supported_extensions = (".png",)
root = os.path.join(str(self.output_dir), ".Trash-1000")
monitor.watched_roots = (root,)
self.assertTrue(monitor.is_image_file(os.path.join(root, "sub", "a.png")))
self.assertFalse(
monitor.is_image_file(os.path.join(root, "sub", ".Trashes", "a.png"))
)
async def test_the_workspace_listing(self):
self.seed()
self.assertEqual(await self.listed(), ["kept.png"])
+244
View File
@@ -0,0 +1,244 @@
"""Show removed images and Return to library (#498).
``GET /prompt_manager/images/output?mode=workspace&removed=1`` lists only the
files a removal mark hides; every workspace page carries ``removed_total``.
``POST /prompt_manager/images/removed/return {items | filter}`` starts a
``scan`` job titled "Return images to the library": it deletes the marks of
the files that are still hidden, then scans exactly those files, so their
prompt links come back from their metadata. Image tags, category,
collections and favorites do not come back.
"""
import asyncio
import hashlib
import os
import sqlite3
from unittest.mock import patch
try:
from tests.disk_waits import DISK_BOUND_SECONDS
from tests.test_api_admin import _write_png
from tests.test_image_delete_routes import LISTING, ImageDeleteTestCase
except ImportError: # discovered with tests/ as the top-level directory
from disk_waits import DISK_BOUND_SECONDS
from test_api_admin import _write_png
from test_image_delete_routes import LISTING, ImageDeleteTestCase
RETURN = "/prompt_manager/images/removed/return"
class _ReturnCase(ImageDeleteTestCase):
async def page(self, status=200, **query):
params = {"mode": "workspace", **query}
response = await self.client.get(LISTING, params=params)
self.assertEqual(response.status, status, await response.text())
return await response.json()
async def removed_paths(self):
page = await self.page(removed="1")
return [image["path"] for image in page["images"]]
async def give_back(self, body, status=202):
answer = await self.post(RETURN, body, status)
if status != 202:
return answer
job = self.api._jobs.get(answer["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS)
job.answer = answer
return job
def png(self, name, text):
path = self.output_dir / name
_write_png(path, text)
return path
def marks(self):
conn = sqlite3.connect(self.api.db.model.db_path)
try:
return conn.execute("SELECT COUNT(*) FROM image_removals").fetchone()[0]
finally:
conn.close()
class TestRemovedView(_ReturnCase):
async def test_the_view_lists_only_hidden_files_and_both_views_count_them(self):
self.write("a.png", b"aaa", mtime=1_000_000)
self.write("b.png", b"bbb", mtime=2_000_000)
self.write("c.png", b"ccc", mtime=3_000_000)
await self.delete(["a.png", "b.png"], "library")
shown = await self.page()
self.assertEqual([i["path"] for i in shown["images"]], ["c.png"])
self.assertEqual(shown["removed_total"], 2)
removed = await self.page(removed="1")
self.assertEqual(
sorted(i["path"] for i in removed["images"]), ["a.png", "b.png"]
)
self.assertEqual(removed["total"], 2)
self.assertEqual(removed["removed_total"], 2)
# A search applies inside the view.
self.assertEqual(
[i["path"] for i in (await self.page(removed="1", q="b."))["images"]],
["b.png"],
)
async def test_no_marks_count_zero(self):
self.write("a.png", b"aaa")
self.assertEqual((await self.page())["removed_total"], 0)
self.assertEqual((await self.page(removed="1"))["images"], [])
async def test_refused_combinations(self):
for query in (
{"removed": "2"},
{"removed": "1", "favorites": "1"},
{"removed": "1", "collection": "1"},
{"removed": "1", "category": "x"},
{"removed": "1", "tags": "x"},
{"removed": "1", "sort": "rating"},
):
with self.subTest(query=query):
await self.page(status=400, **query)
class TestReturn(_ReturnCase):
async def test_return_shows_the_file_and_rescans_its_links_but_not_its_tags(self):
path = self.png("linked.png", "a returned prompt")
prompt_id = self.save("a returned prompt")
self.api.db.link_image_to_prompt(str(prompt_id), str(path))
target = self.target("linked.png")
info = os.stat(path)
self.api.db.add_image_tags(
locator=os.path.normcase(os.path.realpath(path)),
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
size=info.st_size,
mtime_ns=info.st_mtime_ns,
expected_epoch=self.api.db.get_execution_epoch(),
tags=["kept-before"],
)
self.assertEqual(self.count("SELECT COUNT(*) FROM media_image_tags"), 1)
await self.delete(["linked.png"], "library")
self.assertEqual(await self.removed_paths(), ["linked.png"])
before = os.stat(path)
job = await self.give_back({"items": [target]})
self.assertEqual(job.state, "finished", job.summary())
self.assertEqual(self.marks(), 0)
self.assertEqual(await self.listed(), ["linked.png"])
self.assertEqual(await self.removed_paths(), [])
# The links come back from the file's metadata; the tags do not.
self.assertEqual(
self.count(
"SELECT COUNT(*) FROM generated_images WHERE prompt_id IS NOT NULL"
),
1,
)
self.assertEqual(self.count("SELECT COUNT(*) FROM media_image_tags"), 0)
after = os.stat(path)
self.assertEqual(
(before.st_size, before.st_mtime_ns), (after.st_size, after.st_mtime_ns)
)
self.assertEqual(self.fake.moved, [])
self.assertEqual(job.kind, "scan")
self.assertEqual(job.title, "Return images to the library")
self.assertEqual(job.answer["returned"], 1)
async def test_a_file_that_is_not_hidden_is_not_changed(self):
self.write("shown.png", b"shown")
self.write("hidden.png", b"hidden", mtime=1_000_000)
await self.delete(["hidden.png"], "library")
job = await self.give_back(
{"items": [self.target("shown.png"), self.target("hidden.png")]}
)
self.assertEqual(job.state, "finished", job.summary())
self.assertEqual(job.answer["returned"], 1)
self.assertEqual(job.answer["not_removed"], 1)
self.assertEqual(self.marks(), 0)
async def test_nothing_hidden_is_refused(self):
self.write("shown.png", b"shown")
body = await self.give_back({"items": [self.target("shown.png")]}, 400)
self.assertEqual(body["code"], "no-images")
async def test_return_after_a_cancelled_scan_links_the_file(self):
path = self.png("again.png", "linked again")
await self.delete(["again.png"], "library")
cancelled = self.api._start_scan_job()
self.api._jobs.cancel(cancelled.id)
await asyncio.wait_for(asyncio.shield(cancelled.task), DISK_BOUND_SECONDS)
self.assertTrue(cancelled.cancelled)
job = await self.give_back({"items": [self.target("again.png")]})
self.assertEqual(job.state, "finished", job.summary())
self.assertEqual(
self.count(
"SELECT COUNT(*) FROM generated_images "
"WHERE image_path LIKE '%again.png'"
),
1,
)
del path
async def test_other_bulk_routes_refuse_the_removed_filter(self):
self.write("hidden.png", b"hidden", mtime=1_000_000)
await self.delete(["hidden.png"], "library")
await self.post(
"/prompt_manager/images/delete/preview", {"filter": {"removed": "1"}}, 400
)
self.assertEqual(self.marks(), 1)
async def test_select_all_matching_in_the_view(self):
for n in range(3):
self.write(f"{n}.png", f"bytes {n}".encode(), mtime=1_000_000 + n)
await self.delete(["0.png", "1.png"], "library")
job = await self.give_back({"filter": {"removed": "1"}})
self.assertEqual(job.state, "finished", job.summary())
self.assertEqual(sorted(await self.listed()), ["0.png", "1.png", "2.png"])
async def test_busy_beside_a_scan_changes_nothing(self):
self.write("hidden.png", b"hidden", mtime=1_000_000)
await self.delete(["hidden.png"], "library")
gate = asyncio.Event()
async def held(job):
await gate.wait()
job.finish("finished")
running = self.api._jobs.start(
"scan", "Scan for metadata", record_changing=True, runner=held
)
try:
body = await self.give_back({"items": [self.target("hidden.png")]}, 409)
self.assertEqual(body["code"], "busy")
self.assertEqual(self.marks(), 1)
finally:
gate.set()
await asyncio.wait_for(asyncio.shield(running.task), DISK_BOUND_SECONDS)
async def test_body_shapes(self):
await self.post(RETURN, {}, 400)
await self.post(RETURN, {"items": []}, 400)
too_many = [self.target(f"{n}.png") for n in range(501)]
await self.post(RETURN, {"items": too_many}, 400)
class TestMarksCacheRace(_ReturnCase):
async def test_a_listing_read_during_the_clear_does_not_keep_the_file_hidden(self):
from database import image_removal, image_removal_schema
self.write("raced.png", b"raced", mtime=1_000_000)
await self.delete(["raced.png"], "library")
real_clear = image_removal_schema.clear_marks
stale = dict(self.api.db.removal_marks())
def clear_then_read(conn, locators):
removed = real_clear(conn, locators)
# Another thread's connection reads the marks before this
# transaction commits: it still sees them, under the new generation.
key = (
self.api._database_identity(self.api.db),
image_removal_schema.generation(),
)
self.api._marks_cache = (key, stale)
return removed
with patch.object(image_removal, "clear_marks", side_effect=clear_then_read):
job = await self.give_back({"items": [self.target("raced.png")]})
self.assertEqual(job.state, "finished", job.summary())
self.assertEqual(await self.listed(), ["raced.png"])
+186 -1
View File
@@ -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,186 @@ 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 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))}
+9
View File
@@ -490,16 +490,25 @@ class TestThumbnailJob(ThumbnailJobCase):
real_write = images_module._write_image_thumbnail
job_ids = []
written = []
# #504: the worker can write its first preview before the response
# carrying the job id is read. The first write waits until the test
# has stored the id, so the cancel always lands after exactly one write.
first_written, id_stored = threading.Event(), threading.Event()
self.addCleanup(id_stored.set)
def counting(src, dst, *args, **kwargs):
real_write(src, dst, *args, **kwargs)
written.append(dst)
if len(written) == 1:
first_written.set()
id_stored.wait(10)
self.api._jobs.cancel(job_ids[0])
with patch.object(images_module, "_write_image_thumbnail", counting):
_, body = await self._start()
await self._wait(first_written)
job_ids.append(body["job"]["id"])
id_stored.set()
job = await self._finished(body["job"]["id"])
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.status_line, "Cancelled · 1 preview written")
+7 -9
View File
@@ -277,12 +277,13 @@ class UnavailableBackendTests(unittest.TestCase):
self.assertEqual(caught.exception.code, trash.NO_TRASH)
self.assertTrue(os.path.exists(path))
def test_windows_and_macos_have_no_trash_yet(self):
for platform in ("win32", "darwin"):
with self.subTest(platform=platform):
self.assertIsInstance(
trash.default_backend(platform), trash.UnavailableBackend
)
def test_each_platform_gets_its_backend(self):
from utils import trash_macos
self.assertIsInstance(trash.default_backend("win32"), trash.UnavailableBackend)
self.assertIsInstance(
trash.default_backend("darwin"), trash_macos.MacTrashBackend
)
self.assertIsInstance(trash.default_backend("linux"), trash.FreedesktopBackend)
def test_windows_says_why_the_recycle_bin_is_not_used(self):
@@ -295,9 +296,6 @@ class UnavailableBackendTests(unittest.TestCase):
backend.move("C:/x/a.png", ["C:/x"])
self.assertEqual(caught.exception.code, trash.WINDOWS_RECYCLE)
self.assertIn(trash.WINDOWS_RECYCLE, trash.CODES)
self.assertEqual(
trash.default_backend("darwin").available("/x", ["/"]), trash.NO_TRASH
)
def test_module_functions_use_the_replaceable_backend(self):
fake = mock.Mock()
+360
View File
@@ -0,0 +1,360 @@
"""The macOS trash backend's decisions (#499), with a fake Foundation bridge.
No Objective-C is involved: the bridge is faked, ``uid``/``home``/``lstat``/
``access`` are injected, and the only real files are temp files used for the
"source still exists" and output-root identity checks. Runs on every OS.
"""
import os
import shutil
import stat
import sys
import tempfile
import unittest
from types import SimpleNamespace
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils import trash, trash_macos # noqa: E402
from utils.trash_macos import Facts, MacTrashBackend, NSErr # noqa: E402
LOCAL = Facts(local=True, read_only=False, root_fs=True, volume="/", ubiquitous=False)
COCOA = "NSCocoaErrorDomain"
NO_TRASH_DIR = NSErr(COCOA, 3328, "NSPOSIXErrorDomain", 2)
class FakeBridge:
"""The §6.3 bridge with canned answers; ``trash`` moves nothing by default."""
def __init__(self, facts=LOCAL, trash_dir=("/Users/me/.Trash", None), result=None):
self.facts_value = facts
self.trash_dir_value = trash_dir
self.result = result or (True, None, None)
self.calls = []
def facts(self, path):
self.calls.append(("facts", path))
return self.facts_value
def trash_dir(self, path):
self.calls.append(("trash_dir", path))
return self.trash_dir_value
def trash(self, path):
self.calls.append(("trash", path))
result = self.result
return result(path) if callable(result) else result
def _stat(mode=stat.S_IFREG | 0o644, uid=501, flags=0):
return SimpleNamespace(st_mode=mode, st_uid=uid, st_flags=flags)
class MacBackendTestCase(unittest.TestCase):
def setUp(self):
# realpath: the backend resolves paths (/var -> /private/var on macOS, 8.3
# short names on Windows), so the lstat fakes must use the same spelling.
self.tmp = os.path.realpath(tempfile.mkdtemp(prefix="pm-mac-trash-"))
self.addCleanup(shutil.rmtree, self.tmp, True)
self.home = os.path.join(self.tmp, "home")
self.root = os.path.join(self.tmp, "output")
os.makedirs(os.path.join(self.home, ".Trash"))
os.makedirs(self.root)
self.file = os.path.join(self.root, "a.png")
with open(self.file, "wb") as handle:
handle.write(b"png")
self.home_trash = os.path.join(self.home, ".Trash")
self.stats = {}
self.access_ok = True
def _lstat(self, path):
if path in self.stats:
value = self.stats[path]
if isinstance(value, Exception):
raise value
return value
return os.lstat(path)
def backend(self, **bridge_args):
bridge_args.setdefault("trash_dir", (self.home_trash, None))
self.bridge = FakeBridge(**bridge_args)
return MacTrashBackend(
bridge=self.bridge,
uid=501,
home=self.home,
lstat=self._lstat,
access=lambda path, mode: self.access_ok,
)
def moved_to(self, destination):
"""A ``trash`` result that really renames the file to *destination*."""
def run(path):
os.makedirs(os.path.dirname(destination), exist_ok=True)
os.rename(path, destination)
return True, destination, None
return run
def assert_refused(self, backend, code):
self.assertEqual(backend.available(self.file, [self.root]), code)
with self.assertRaises(trash.TrashRefused) as caught:
backend.move(self.file, [self.root])
self.assertEqual(caught.exception.code, code)
self.assertNotIn("trash", [name for name, _ in self.bridge.calls])
self.assertTrue(os.path.exists(self.file))
return caught.exception
class AvailableOrderTests(MacBackendTestCase):
def test_a_writable_local_file_is_available(self):
self.assertIsNone(self.backend().available(self.file, [self.root]))
def test_network_volume_is_refused_without_trying(self):
facts = Facts(False, False, False, "/Volumes/nas", False)
self.assert_refused(self.backend(facts=facts), trash.NETWORK)
def test_unknown_locality_counts_as_network(self):
facts = Facts(None, False, True, "/", False)
self.assert_refused(self.backend(facts=facts), trash.NETWORK)
def test_read_only_volume_or_unknown_is_no_trash(self):
for read_only in (True, None):
with self.subTest(read_only=read_only):
facts = Facts(True, read_only, False, "/Volumes/ro", False)
self.assert_refused(self.backend(facts=facts), trash.NO_TRASH)
def test_ubiquitous_item_is_cloud_sync(self):
facts = Facts(True, False, True, "/", True)
self.assert_refused(self.backend(facts=facts), trash.CLOUD_SYNC)
def test_icloud_and_file_provider_folders_are_cloud_sync(self):
for parts in (("Library", "Mobile Documents"), ("Library", "CloudStorage")):
with self.subTest(parts=parts):
folder = os.path.join(self.home, *parts, "Box")
os.makedirs(folder)
path = os.path.join(folder, "b.png")
with open(path, "wb") as handle:
handle.write(b"x")
backend = self.backend()
self.assertEqual(backend.available(path, [folder]), trash.CLOUD_SYNC)
with self.assertRaises(trash.TrashRefused):
backend.move(path, [folder])
self.assertTrue(os.path.exists(path))
def test_found_trash_folder_inside_an_output_root_is_trash_in_root(self):
self.assertEqual(
self.backend().available(self.file, [self.home]), trash.TRASH_IN_ROOT
)
def test_trash_dir_error_other_than_missing_is_no_trash(self):
backend = self.backend(
trash_dir=(None, NSErr(COCOA, 3328, "NSPOSIXErrorDomain", 30))
)
self.assert_refused(backend, trash.NO_TRASH)
def test_missing_trash_on_the_startup_volume_is_no_trash(self):
self.assert_refused(
self.backend(trash_dir=(None, NO_TRASH_DIR)), trash.NO_TRASH
)
class FreshVolumeTests(MacBackendTestCase):
def setUp(self):
super().setUp()
self.volume = os.path.join(self.tmp, "vol")
self.root = os.path.join(self.volume, "output")
os.makedirs(self.root)
self.file = os.path.join(self.root, "a.png")
with open(self.file, "wb") as handle:
handle.write(b"png")
self.facts = Facts(True, False, False, self.volume, False)
def test_missing_volume_trash_is_predicted(self):
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
self.assertIsNone(backend.available(self.file, [self.root]))
def test_prediction_refused_when_trashes_is_a_link(self):
os.symlink(self.tmp, os.path.join(self.volume, ".Trashes"))
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
self.assert_refused(backend, trash.NO_TRASH)
def test_predicted_trash_inside_the_volume_root_is_trash_in_root(self):
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
self.assert_refused_root = backend.available(self.file, [self.volume])
self.assertEqual(self.assert_refused_root, trash.TRASH_IN_ROOT)
def test_move_to_a_predicted_trash_returns_the_volume_label(self):
destination = os.path.join(self.volume, ".Trashes", "501", "a.png")
backend = self.backend(
facts=self.facts,
trash_dir=(None, NO_TRASH_DIR),
result=self.moved_to(destination),
)
self.assertEqual(
backend.move(self.file, [self.root]), ".Trashes/501 on its drive"
)
self.assertFalse(os.path.exists(self.file))
class PermissionTests(MacBackendTestCase):
def test_parent_not_writable_is_no_permission(self):
self.access_ok = False
self.assert_refused(self.backend(), trash.NO_PERMISSION)
def test_immutable_or_append_flags_on_file_or_parent(self):
for flag in ("UF_IMMUTABLE", "SF_IMMUTABLE", "UF_APPEND", "SF_APPEND"):
for target in (self.file, self.root):
with self.subTest(flag=flag, target=target):
mode = (
stat.S_IFDIR | 0o755
if target == self.root
else stat.S_IFREG | 0o644
)
self.stats = {target: _stat(mode=mode, flags=getattr(stat, flag))}
self.assert_refused(self.backend(), trash.NO_PERMISSION)
def test_sticky_parent_and_foreign_owner(self):
self.stats = {
self.root: _stat(mode=stat.S_IFDIR | 0o1777),
self.file: _stat(uid=502),
}
self.assert_refused(self.backend(), trash.NO_PERMISSION)
def test_sticky_parent_with_own_file_is_allowed(self):
self.stats = {self.root: _stat(mode=stat.S_IFDIR | 0o1777), self.file: _stat()}
self.assertIsNone(self.backend().available(self.file, [self.root]))
class MoveOutcomeTests(MacBackendTestCase):
def test_success_into_the_home_trash(self):
destination = os.path.join(self.home_trash, "a.png")
backend = self.backend(result=self.moved_to(destination))
self.assertEqual(backend.move(self.file, [self.root]), trash.HOME_TRASH_LABEL)
self.assertTrue(os.path.exists(destination))
def test_cocoa_errors_map_to_codes_with_detail(self):
cases = {
3328: trash.NO_TRASH,
642: trash.NO_TRASH,
513: trash.NO_PERMISSION,
257: trash.NO_PERMISSION,
4: trash.FAILED,
640: trash.FAILED,
}
for code, expected in cases.items():
with self.subTest(code=code):
error = NSErr(COCOA, code, "NSOSStatusErrorDomain", -5000)
backend = self.backend(result=(False, None, error))
with self.assertRaises(trash.TrashRefused) as caught:
backend.move(self.file, [self.root])
self.assertEqual(caught.exception.code, expected)
self.assertEqual(
caught.exception.detail,
f"NSCocoaErrorDomain {code} (NSOSStatusErrorDomain -5000)",
)
self.assertTrue(os.path.exists(self.file))
def test_other_domain_or_missing_error_is_failed(self):
for error in (NSErr("NSPOSIXErrorDomain", 13), None):
with self.subTest(error=error):
backend = self.backend(result=(False, None, error))
with self.assertRaises(trash.TrashRefused) as caught:
backend.move(self.file, [self.root])
self.assertEqual(caught.exception.code, trash.FAILED)
def test_yes_with_the_source_still_present_is_failed(self):
backend = self.backend(
result=(True, os.path.join(self.home_trash, "a.png"), None)
)
with self.assertRaises(trash.TrashRefused) as caught:
backend.move(self.file, [self.root])
self.assertEqual(caught.exception.code, trash.FAILED)
self.assertTrue(os.path.exists(self.file))
def test_yes_with_no_result_path_is_unconfirmed(self):
def vanished(path):
os.rename(path, os.path.join(self.tmp, "elsewhere.png"))
return True, None, None
backend = self.backend(result=vanished)
with self.assertRaises(trash.TrashUnconfirmed):
backend.move(self.file, [self.root])
def test_yes_into_an_output_root_is_unconfirmed(self):
backend = self.backend(
result=self.moved_to(os.path.join(self.root, "x", "a.png"))
)
with self.assertRaises(trash.TrashUnconfirmed):
backend.move(self.file, [self.root])
def test_yes_into_an_unplanned_folder_is_trashed_and_logged(self):
other = os.path.join(self.tmp, "other-trash", "a.png")
backend = self.backend(result=self.moved_to(other))
with self.assertLogs("prompt_manager.trash", level="WARNING") as logs:
label = backend.move(self.file, [self.root])
self.assertEqual(label, os.path.realpath(os.path.dirname(other)))
self.assertIn(other, "\n".join(logs.output))
class IdentityTests(MacBackendTestCase):
@unittest.skipUnless(hasattr(os, "symlink") and os.name == "posix", "symlinks")
def test_root_given_through_a_symlink_still_contains_its_trash(self):
alias = os.path.join(self.tmp, "home-alias")
os.symlink(self.home, alias)
self.assertEqual(
self.backend().available(self.file, [alias]), trash.TRASH_IN_ROOT
)
def test_missing_roots_are_ignored(self):
missing = os.path.join(self.tmp, "nope")
self.assertIsNone(self.backend().available(self.file, [missing, self.root]))
class LoadingTests(unittest.TestCase):
def test_default_backend_for_darwin_does_not_load_the_bridge(self):
with mock.patch.object(trash_macos, "load_bridge") as loader:
backend = trash.default_backend("darwin")
self.assertIsInstance(backend, MacTrashBackend)
loader.assert_not_called()
def test_failed_load_answers_no_trash_and_is_cached_and_logged_once(self):
backend = MacTrashBackend(uid=501)
with (
mock.patch.object(
trash_macos, "load_bridge", side_effect=OSError("no Foundation")
) as loader,
self.assertLogs("prompt_manager.trash", level="WARNING") as logs,
):
self.assertEqual(backend.available("/x/a.png", ["/x"]), trash.NO_TRASH)
with self.assertRaises(trash.TrashRefused) as caught:
backend.move("/x/a.png", ["/x"])
self.assertEqual(caught.exception.code, trash.NO_TRASH)
self.assertEqual(loader.call_count, 1)
self.assertEqual(len(logs.output), 1)
def test_load_bridge_off_macos_raises(self):
if sys.platform == "darwin":
self.skipTest("the real bridge loads on macOS (native test covers it)")
with self.assertRaises(OSError):
trash_macos.load_bridge()
class ErrorTextTests(unittest.TestCase):
def test_detail_without_underlying(self):
self.assertEqual(
trash_macos.error_detail(NSErr(COCOA, 4)), "NSCocoaErrorDomain 4"
)
self.assertEqual(trash_macos.error_detail(None), "no NSError")
def test_refusal_keeps_detail(self):
refused = trash.TrashRefused(trash.FAILED, "why")
self.assertEqual(
(refused.code, refused.detail, str(refused)), ("failed", "why", "why")
)
self.assertEqual(trash.TrashRefused(trash.FAILED).detail, "")
if __name__ == "__main__":
unittest.main()
+238
View File
@@ -0,0 +1,238 @@
"""Native proof of the macOS trash backend (#499 design §10.2).
Runs only on macOS with ``PM_NATIVE_TRASH=macos`` (the ``native-trash`` job in
portability.yml), because it puts files into the real Trash of the user
running it. With the variable set, a bridge that cannot load fails the test
rather than skipping it. Files moved into a trash folder are left there: the
runner is disposable and this test never deletes from a trash folder.
"""
import os
import subprocess
import sys
import tempfile
import time
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils import trash # noqa: E402
NATIVE = sys.platform == "darwin" and os.environ.get("PM_NATIVE_TRASH") == "macos"
HDIUTIL_SECONDS = 60
# Measured: macos-15-intel kept a fresh image busy after a trash (run 40954618922).
DETACH_WAIT_SECONDS = 30
class SpyBridge:
"""The real bridge, recording every ``trash`` result."""
def __init__(self, real):
self.real = real
self.results = []
def facts(self, path):
return self.real.facts(path)
def trash_dir(self, path):
return self.real.trash_dir(path)
def trash(self, path):
result = self.real.trash(path)
self.results.append(result)
return result
def _hdiutil(*args):
return subprocess.run(
["hdiutil", *args], capture_output=True, text=True, timeout=HDIUTIL_SECONDS
)
def _our_open_files(mount):
"""Files this process holds open under *mount* (``lsof -p``): the handle proof."""
listed = subprocess.run(
["lsof", "-Fn", "-p", str(os.getpid())],
capture_output=True,
text=True,
timeout=HDIUTIL_SECONDS,
)
prefix = os.path.realpath(mount) + os.sep
return [
line[1:]
for line in listed.stdout.splitlines()
if line.startswith("n") and os.path.realpath(line[1:]).startswith(prefix)
]
def _detach(mount):
"""Detach *mount*; None on success, else why (after a forced detach).
This process must hold nothing under it. A volume macOS has just written a
trash folder to can stay busy for a moment (Spotlight, fseventsd), so the
plain detach is retried until the system releases it, within a bound.
"""
ours = _our_open_files(mount)
if ours:
_hdiutil("detach", mount, "-force")
return f"this test still held files open under {mount}: {ours}"
deadline = time.monotonic() + DETACH_WAIT_SECONDS
while True:
if not os.path.ismount(mount):
return None # a "busy" detach can still finish after it returns
done = _hdiutil("detach", mount)
if done.returncode == 0:
return None
if time.monotonic() > deadline:
holders = subprocess.run(
["lsof", "+f", "--", mount],
capture_output=True,
text=True,
timeout=HDIUTIL_SECONDS,
).stdout
_hdiutil("detach", mount, "-force")
return f"detach {mount}: {done.stderr.strip()}; held by: {holders!r}"
time.sleep(1) # poll the volume's release (a state), never a fixed wait
@unittest.skipUnless(NATIVE, "macOS native trash proof (PM_NATIVE_TRASH=macos)")
class MacTrashNativeTests(unittest.TestCase):
def setUp(self):
from utils import trash_macos
self.trash_macos = trash_macos
self.spy = SpyBridge(trash_macos.load_bridge()) # fails, never skips
self.backend = trash_macos.MacTrashBackend(bridge=self.spy)
self._tmp = tempfile.TemporaryDirectory(prefix="pm-native-trash-")
self.tmp = os.path.realpath(self._tmp.name)
self._mounts = []
self._modes = []
def tearDown(self):
failures = []
try:
for path, mode in reversed(self._modes):
os.chmod(path, mode)
for mount in reversed(self._mounts):
problem = _detach(mount)
if problem:
failures.append(problem)
finally:
self._tmp.cleanup()
self.assertEqual(failures, [])
def _file(self, folder, name="a.png", content=None):
os.makedirs(folder, exist_ok=True)
path = os.path.join(folder, name)
with open(path, "wb") as handle:
handle.write(content or os.urandom(64))
return path
def _image(self, name, fs="APFS"):
image = os.path.join(self.tmp, name + ".dmg")
mount = os.path.join(self.tmp, name + "-mnt")
os.makedirs(mount)
made = _hdiutil("create", "-size", "40m", "-fs", fs, "-volname", name, image)
self.assertEqual(made.returncode, 0, made.stderr)
attached = _hdiutil("attach", image, "-nobrowse", "-mountpoint", mount)
self.assertEqual(attached.returncode, 0, attached.stderr)
self._mounts.append(mount)
return image, mount
def _assert_trashed(self, source, content, info, folder):
self.assertFalse(os.path.lexists(source))
ok, resulting, error = self.spy.results[-1]
self.assertTrue(ok, error)
self.assertEqual(os.path.realpath(os.path.dirname(resulting)), folder)
moved = os.lstat(resulting)
self.assertEqual((moved.st_dev, moved.st_ino), (info.st_dev, info.st_ino))
with open(resulting, "rb") as handle:
self.assertEqual(handle.read(), content)
return resulting
def _assert_kept(self, path, content):
with open(path, "rb") as handle:
self.assertEqual(handle.read(), content)
def _assert_refused(self, path, roots, code):
with open(path, "rb") as handle:
content = handle.read()
self.assertEqual(self.backend.available(path, roots), code)
with self.assertRaises(trash.TrashRefused) as caught:
self.backend.move(path, roots)
self.assertEqual(caught.exception.code, code)
self._assert_kept(path, content)
def test_n1_startup_volume_goes_to_the_home_trash(self):
root = os.path.join(self.tmp, "output")
source = self._file(root)
with open(source, "rb") as handle:
content = handle.read()
info = os.lstat(source)
self.assertIsNone(self.backend.available(source, [root]))
self.assertEqual(self.backend.move(source, [root]), trash.HOME_TRASH_LABEL)
home_trash = os.path.realpath(os.path.expanduser("~/.Trash"))
self._assert_trashed(source, content, info, home_trash)
def test_n2_a_name_clash_never_replaces(self):
first_root = os.path.join(self.tmp, "one")
second_root = os.path.join(self.tmp, "two")
first = self._file(first_root, "same.png", b"first")
second = self._file(second_root, "same.png", b"second")
self.backend.move(first, [first_root])
self.backend.move(second, [second_root])
(_, one, _), (_, two, _) = self.spy.results
self.assertNotEqual(one, two)
self._assert_kept(one, b"first")
self._assert_kept(two, b"second")
def test_n3_fresh_external_volume_uses_its_own_trash(self):
for fs in ("APFS", "MS-DOS FAT32"):
with self.subTest(fs=fs):
_image, mount = self._image("fresh" + fs.split()[0], fs)
root = os.path.join(mount, "output")
source = self._file(root)
with open(source, "rb") as handle:
content = handle.read()
info = os.lstat(source)
self.assertFalse(os.path.lexists(os.path.join(mount, ".Trashes")))
self.assertIsNone(self.backend.available(source, [root]))
label = self.backend.move(source, [root])
uid = str(os.getuid())
self.assertEqual(label, f".Trashes/{uid} on its drive")
folder = os.path.realpath(os.path.join(mount, ".Trashes", uid))
self._assert_trashed(source, content, info, folder)
def test_n4_volume_root_as_output_root_is_trash_in_root(self):
_image, mount = self._image("rooted")
source = self._file(os.path.join(mount, "output"))
self._assert_refused(source, [mount], trash.TRASH_IN_ROOT)
self.assertEqual(self.spy.results, [])
def test_n5_read_only_volume_is_no_trash(self):
image, mount = self._image("readonly")
source = self._file(mount, "ro.png", b"read-only bytes")
self.assertIsNone(_detach(mount))
self._mounts.remove(mount)
attached = _hdiutil(
"attach", image, "-nobrowse", "-readonly", "-mountpoint", mount
)
self.assertEqual(attached.returncode, 0, attached.stderr)
self._mounts.append(mount)
self._assert_refused(source, [os.path.join(mount, "output")], trash.NO_TRASH)
def test_n6_unwritable_folder_is_no_permission_and_macos_agrees(self):
root = os.path.join(self.tmp, "locked")
source = self._file(root, "p.png", b"kept")
self._modes.append((root, 0o755))
os.chmod(root, 0o555)
self._assert_refused(source, [self.tmp], trash.NO_PERMISSION)
self.assertEqual(self.spy.results, [])
ok, _resulting, error = self.spy.real.trash(source)
self.assertFalse(ok)
self.assertEqual((error.domain, error.code), ("NSCocoaErrorDomain", 513))
self._assert_kept(source, b"kept")
if __name__ == "__main__":
unittest.main()
+127
View File
@@ -0,0 +1,127 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { attachBrowserLogs, BROWSER_EXIT_PREFIX, createBrowserLogRecorder, isAbnormalExit, isTargetGone, parseExitLine,
playwrightDebug } from '../../tools/vendor/browser-logs.mjs';
const debug = playwrightDebug();
/** Runs `body` with DEBUG set to `namespaces` and restores the caller's setting afterwards. */
function withDebug(namespaces, body) {
const saved = debug.disable();
const savedLog = debug.log;
if (namespaces) debug.enable(namespaces);
try {
return body();
} finally {
debug.log = savedLog;
debug.disable();
if (saved) debug.enable(saved);
}
}
const outputDir = mkdtempSync(join(tmpdir(), 'pm-browser-logs-'));
test.after(() => rmSync(outputDir, { recursive: true, force: true }));
function testInfo({ status = 'failed', errors = [] } = {}) {
const attachments = [];
const dir = mkdtempSync(join(outputDir, 'test-'));
return { status, expectedStatus: 'passed', errors, attachments, outputPath: name => join(dir, name),
attach: async (name, options) => { attachments.push({ name, ...options }); } };
}
test('parses the exit line Playwright logs for a browser process', () => {
assert.deepEqual(parseExitLine('[pid=7124] <process did exit: exitCode=null, signal=SIGKILL> +21ms'),
{ pid: 7124, exitCode: null, signal: 'SIGKILL' });
assert.deepEqual(parseExitLine('[pid=9] <process did exit: exitCode=0, signal=null>'), { pid: 9, exitCode: 0, signal: null });
assert.deepEqual(parseExitLine('[pid=9] <process did exit: exitCode=11, signal=null>'), { pid: 9, exitCode: 11, signal: null });
assert.equal(parseExitLine('[pid=9][err] JavaScript warning'), null);
assert.equal(isAbnormalExit({ pid: 9, exitCode: 0, signal: null }), false);
assert.equal(isAbnormalExit({ pid: 9, exitCode: null, signal: 'SIGKILL' }), true);
assert.equal(isAbnormalExit({ pid: 9, exitCode: 139, signal: null }), true);
});
test('recognises the errors of a page, context or browser that went away', () => {
for (const message of ['Error: browser.newContext: Target page, context or browser has been closed',
'Protocol error (Page.navigate): Target closed', 'page.goto: Page crashed', 'Target crashed']) {
assert.equal(isTargetGone(message), true, message);
}
for (const message of ['expect(locator).toBeVisible() failed', undefined, 'Test timeout of 30000ms exceeded.']) {
assert.equal(isTargetGone(message), false, String(message));
}
});
test('records the browser channel into a bounded tail without printing it', () => withDebug('', () => {
const printed = [];
debug.log = (...args) => printed.push(args.join(' '));
const written = [];
const recorder = createBrowserLogRecorder({ debug, limit: 3, write: text => written.push(text) });
const browser = debug('pw:browser');
browser('<launched> pid=5');
for (const n of [1, 2, 3, 4]) browser(`[pid=5][err] line ${n}`);
assert.deepEqual(printed, [], 'the browser channel stays quiet when DEBUG did not ask for it');
assert.match(recorder.tail(), /line 2[\s\S]*line 3[\s\S]*line 4/);
assert.doesNotMatch(recorder.tail(), /line 1/);
assert.deepEqual(written, []);
assert.deepEqual(recorder.launches(), [5], 'launches outlive the bounded tail');
browser('[pid=5] <process did exit: exitCode=null, signal=SIGKILL>');
assert.deepEqual(recorder.exits(), [{ pid: 5, exitCode: null, signal: 'SIGKILL' }]);
assert.deepEqual(written, [`${BROWSER_EXIT_PREFIX}browser pid=5 exited: exitCode=null, signal=SIGKILL\n`]);
browser('[pid=6] <process did exit: exitCode=0, signal=null>');
assert.equal(written.length, 1, 'a clean exit prints nothing');
recorder.uninstall();
assert.equal(debug.enabled('pw:browser'), false, 'uninstall restores the caller\'s DEBUG');
}));
test('keeps printing the channels the user enabled, the browser one included', () => withDebug('pw:api,pw:browser', () => {
const printed = [];
debug.log = function (...args) { printed.push(`${this.namespace} ${args.join(' ')}`); };
const recorder = createBrowserLogRecorder({ debug, write: () => {} });
debug('pw:api')('call');
debug('pw:browser')('[pid=1][out] hello');
debug('pw:protocol')('not enabled');
assert.equal(printed.length, 2);
assert.match(printed[0], /^pw:api /);
assert.match(printed[1], /^pw:browser /);
assert.doesNotMatch(recorder.tail(), /call/);
recorder.uninstall();
assert.equal(debug.enabled('pw:api'), true);
assert.equal(debug.enabled('pw:browser'), true);
}));
test('attaches the tail and the exit only to a test whose target went away', async () => withDebug('', async () => {
debug.log = () => {};
const recorder = createBrowserLogRecorder({ debug, write: () => {} });
const browser = debug('pw:browser');
browser('\u001b[30m[pid=8][err] Crash Annotation GraphicsCriticalError\u001b[0m');
browser('[pid=8] <process did exit: exitCode=null, signal=SIGKILL>');
const passed = testInfo({ status: 'passed' });
assert.equal(await attachBrowserLogs(passed, recorder), false);
const assertion = testInfo({ errors: [{ message: 'expect(locator).toBeVisible() failed' }] });
assert.equal(await attachBrowserLogs(assertion, recorder), false);
assert.deepEqual(assertion.attachments, []);
const closed = testInfo({ errors: [{ message: 'Error: browser.newContext: Target page, context or browser has been closed' }] });
assert.equal(await attachBrowserLogs(closed, recorder), true);
assert.equal(closed.attachments.length, 1);
const [{ name, path, contentType }] = closed.attachments;
assert.equal(name, 'browser-stderr');
assert.equal(contentType, 'text/plain');
const body = readFileSync(path, 'utf8');
assert.match(body, /^browser pid=8 exited: exitCode=null, signal=SIGKILL\n/);
assert.match(body, /\[pid=8\]\[err\] Crash Annotation GraphicsCriticalError/);
assert.doesNotMatch(body, /\u001b/, 'the attached log has no terminal colours');
recorder.uninstall();
}));
test('says so when the worker logged no abnormal exit', async () => withDebug('', async () => {
debug.log = () => {};
const recorder = createBrowserLogRecorder({ debug, write: () => {} });
const closed = testInfo({ errors: [{ message: 'x', stack: 'Error: Target closed\n at y' }] });
assert.equal(await attachBrowserLogs(closed, recorder), true);
assert.match(readFileSync(closed.attachments[0].path, 'utf8'), /^no abnormal browser exit was logged in this worker\n/);
recorder.uninstall();
}));
+14
View File
@@ -353,6 +353,20 @@ test('Windows and macOS run 3.12-3.14 on main, nightly and on demand, never on a
assert.doesNotMatch(text, /cancel-in-progress: true|cancel-in-progress: \$/, 'no portability run is cancelled');
});
// #499: the native trash proof runs on both macOS CPUs, apart from the suite so a red suite never skips it.
test('portability runs the native trash proof on arm64 and Intel macOS', () => {
const body = jobs(readWorkflow('portability.yml')).get('native-trash');
assert.ok(body, 'portability.yml has a native-trash job');
assert.deepEqual(matrixList(body, 'os'), ['macos-latest', 'macos-15-intel']);
assert.match(body, /^ {6}fail-fast: false$/m);
assert.doesNotMatch(body, /^ {4}needs:/m, 'it does not wait for the suite job');
assert.match(body, /^ {10}PM_NATIVE_TRASH: macos$/m);
assert.match(body, /^ {8}run: python -m unittest tests\.test_trash_macos_native tests\.test_trash_macos -v$/m);
const jobMinutes = Number(/^ {4}timeout-minutes: (\d+)$/m.exec(body)[1]);
const stepMinutes = Number(/^ {8}timeout-minutes: (\d+)$/m.exec(body)?.[1]);
assert.ok(stepMinutes > 0 && jobMinutes - stepMinutes >= 2, 'the proof step stops before the job limit');
});
// #493: the portability limit follows the rule in its comment: ceil(1.5 x the slowest of three
// green main runs) + 3 minutes, never under 5, with those runs and times written above jobs:.
// The times are whole-job wall times. That the runs were green and on main is the author's word.
+91
View File
@@ -0,0 +1,91 @@
// What a browser printed before it died (#513). Playwright writes a browser's stderr, its launch and its exit
// (`<process did exit: exitCode=…, signal=…>`) only to its `pw:browser` debug channel, which is off unless DEBUG is
// set, so a browser that dies between two tests leaves the next test only "Target page, context or browser has been
// closed". A worker records that channel into a bounded tail, prints one line when a browser exits abnormally and
// attaches the tail to a test that fails on a closed or crashed target. Nothing is retried or hidden.
import { createRequire } from 'node:module';
import { writeFile } from 'node:fs/promises';
import { format, stripVTControlCharacters } from 'node:util';
const BROWSER_NAMESPACE = 'pw:browser';
export const BROWSER_LOG_LIMIT = 300;
export const BROWSER_EXIT_PREFIX = 'pm-browser-exit| ';
const LAUNCH_LINE = /<launched> pid=(\d+)/;
const EXIT_LINE = /\[pid=(\d+)\] <process did exit: exitCode=([^,]+), signal=([^>]+)>/;
// The client's TargetClosedError, a protocol error of a closed or crashed target, and a crashed page.
const TARGET_GONE = /Target page, context or browser has been closed|Target (?:closed|crashed)|Browser closed|browser has disconnected|Page crashed/i;
/** The `debug` module Playwright logs through (the instance this worker's tests launch browsers with). */
export function playwrightDebug() {
return createRequire(import.meta.url)('playwright-core/lib/utilsBundle').debug;
}
/** The pid, exit code and signal of a `process did exit` line, or null. */
export function parseExitLine(line) {
const match = EXIT_LINE.exec(line);
if (!match) return null;
const value = text => (text === 'null' ? null : text);
const code = value(match[2]);
return { pid: Number(match[1]), exitCode: code === null ? null : Number(code), signal: value(match[3]) };
}
export const isAbnormalExit = exit => exit !== null && (exit.exitCode !== 0 || exit.signal !== null);
/** True for an error message that says the page, context or browser under the test went away. */
export const isTargetGone = message => TARGET_GONE.test(String(message ?? ''));
/**
* Records `pw:browser` into a tail of at most `limit` lines. Other debug channels keep printing as before; the
* browser channel prints only when the user's own DEBUG asked for it. `write` gets one line per abnormal exit.
*/
export function createBrowserLogRecorder({ debug = playwrightDebug(), limit = BROWSER_LOG_LIMIT,
write = text => process.stderr.write(text) } = {}) {
const lines = [];
const launches = [];
const exits = [];
const userWantsBrowser = debug.enabled(BROWSER_NAMESPACE);
const previous = debug.disable();
const original = debug.log;
debug.log = function record(...args) {
if (this?.namespace !== BROWSER_NAMESPACE || userWantsBrowser) {
(original ?? (() => {})).apply(this, args);
}
if (this?.namespace !== BROWSER_NAMESPACE) return;
const line = stripVTControlCharacters(format(...args));
lines.push(line);
if (lines.length > limit) lines.splice(0, lines.length - limit);
const launched = LAUNCH_LINE.exec(line);
if (launched) launches.push(Number(launched[1]));
const exit = parseExitLine(line);
if (!exit) return;
exits.push(exit);
if (isAbnormalExit(exit)) {
write(`${BROWSER_EXIT_PREFIX}browser pid=${exit.pid} exited: exitCode=${exit.exitCode}, signal=${exit.signal}\n`);
}
};
debug.enable([previous, BROWSER_NAMESPACE].filter(Boolean).join(','));
return {
tail: () => lines.join('\n'),
launches: () => [...launches],
exits: () => [...exits],
uninstall() {
debug.log = original;
debug.disable();
if (previous) debug.enable(previous);
},
};
}
/** Attaches the browser log tail to a test that failed because its page, context or browser went away. */
export async function attachBrowserLogs(testInfo, recorder) {
if (testInfo.status === testInfo.expectedStatus) return false;
if (!testInfo.errors.some(error => isTargetGone(error.message) || isTargetGone(error.stack))) return false;
const exits = recorder.exits().filter(isAbnormalExit)
.map(exit => `browser pid=${exit.pid} exited: exitCode=${exit.exitCode}, signal=${exit.signal}`);
const summary = exits.length ? exits.join('\n') : 'no abnormal browser exit was logged in this worker';
// A file, so the log travels with the uploaded test-results (the list reporter shortens a long body).
const path = testInfo.outputPath('browser-stderr.txt');
await writeFile(path, `${summary}\n--- last ${BROWSER_LOG_LIMIT} pw:browser lines ---\n${recorder.tail()}\n`);
await testInfo.attach('browser-stderr', { path, contentType: 'text/plain' });
return true;
}
+12
View File
@@ -287,6 +287,12 @@
"functions": 80,
"statements": 80
},
"workspace/images-return.mjs": {
"lines": 80,
"branches": 70,
"functions": 80,
"statements": 80
},
"workspace/images-export.mjs": {
"lines": 98,
"branches": 98,
@@ -485,6 +491,12 @@
"functions": 98,
"statements": 98
},
"workspace/more-menu.mjs": {
"lines": 80,
"branches": 70,
"functions": 80,
"statements": 80
},
"workspace/plural.mjs": {
"lines": 98,
"branches": 98,
+24 -2
View File
@@ -147,6 +147,10 @@ class ImageGenerationHandler(FileSystemEventHandler):
# Settle check: the file size must be unchanged across two reads
self.settle_interval = SETTLE_INTERVAL_SECONDS
# The folders this handler is scheduled on (#501): the folder skip only
# tests the folders below them, so a source named like a trash folder
# is still watched.
self.watched_roots = ()
self.settle_attempts = SETTLE_ATTEMPTS
# Images scheduled but not yet processed: path -> (prompt snapshot, due time).
@@ -433,11 +437,28 @@ class ImageGenerationHandler(FileSystemEventHandler):
"""
# Previews are derivatives, and a trash folder inside an output
# folder holds thrown-away files (#501): neither is a new output.
folders = filepath.replace("\\", "/").split("/")[:-1]
if in_skipped_folder(folders):
if in_skipped_folder(self._folders_below_root(filepath)):
return False
return filepath.lower().endswith(self.supported_extensions)
def _folders_below_root(self, filepath: str) -> list:
"""The folder names of *filepath* below the longest watched root.
Without a watched root holding it (a handler used on its own), every
folder of the path counts.
"""
text = os.path.abspath(filepath)
holding = [
root
for root in getattr(self, "watched_roots", ())
if os.path.normcase(text).startswith(
os.path.normcase(os.path.join(root, ""))
)
]
if holding:
text = text[len(os.path.join(max(holding, key=len), "")) :]
return text.replace("\\", "/").split("/")[:-1]
def process_new_image(self, image_path: str, prompt_snapshot=None):
"""Process a newly created image file for gallery integration.
@@ -1238,6 +1259,7 @@ class ImageMonitor:
handler.review_recorder = self.review_recorder
handler.thumbnail_queue = self.thumbnail_queue
handler.output_listener = self.output_listener
handler.watched_roots = tuple(os.path.abspath(d) for d in valid_directories)
observer = Observer()
scheduled = []
for output_dir in valid_directories:
+41 -7
View File
@@ -14,9 +14,11 @@ same mount, else the file's mount's ``.Trash/$uid`` (only under a real, sticky
``.Trash``) or ``.Trash-$uid``. Names are reserved by creating the
``.trashinfo`` with ``O_EXCL`` and the file is moved with
``renameat2(RENAME_NOREPLACE)``; without that call the move is refused.
Windows never uses the Recycle Bin (#500: files over its capacity are deleted
permanently, undetectably), so it answers ``windows-recycle``; macOS has no
backend yet (#499). Both use :class:`UnavailableBackend`.
macOS uses :class:`utils.trash_macos.MacTrashBackend` (#499): NSFileManager's
``trashItemAtURL`` through ctypes, after refusing what it cannot show to be
recoverable. Windows never uses the Recycle Bin (#500: files over its capacity
are deleted permanently, undetectably), so it answers ``windows-recycle``
through :class:`UnavailableBackend`.
``BACKEND`` is the one replaceable seam; tests install a fake.
"""
@@ -37,10 +39,22 @@ TRASH_IN_ROOT = "trash-in-root"
PATH_TOO_LONG = "path-too-long"
LOCKED = "locked"
FAILED = "failed"
NO_PERMISSION = "no-permission"
CLOUD_SYNC = "cloud-sync"
# Windows never uses the Recycle Bin (#500): it can delete permanently.
WINDOWS_RECYCLE = "windows-recycle"
CODES = frozenset(
{NO_TRASH, NETWORK, TRASH_IN_ROOT, PATH_TOO_LONG, LOCKED, FAILED, WINDOWS_RECYCLE}
{
NO_TRASH,
NETWORK,
TRASH_IN_ROOT,
PATH_TOO_LONG,
LOCKED,
FAILED,
NO_PERMISSION,
CLOUD_SYNC,
WINDOWS_RECYCLE,
}
)
HOME_TRASH_LABEL = "home trash"
@@ -66,6 +80,19 @@ class TrashRefused(Exception):
def __init__(self, code: str, detail: str = ""):
super().__init__(detail or code)
self.code = code
self.detail = detail
class TrashUnconfirmed(Exception):
"""The system reported the file moved, but not to a place shown to be a trash.
The file is no longer where it was, so this is not a refusal: the caller
keeps the file's records and stops (#499 design §7.4).
"""
def __init__(self, detail: str):
super().__init__(detail)
self.detail = detail
def parse_mountinfo(text: str) -> list[str]:
@@ -353,14 +380,21 @@ class FreedesktopBackend:
def default_backend(platform: str = sys.platform):
"""The backend for *platform*; Windows and macOS have none yet (#480)."""
"""The backend for *platform* (#480, #499, #500).
macOS gets :class:`utils.trash_macos.MacTrashBackend`, which loads its
Foundation bridge only on first use (#499). Windows never uses the Recycle
Bin (#500).
"""
if platform == "darwin":
from . import trash_macos
return trash_macos.MacTrashBackend()
if platform == "win32" or platform.startswith("cygwin"):
# #500, ruling (a): Windows deletes files larger than the Recycle Bin's
# capacity permanently, and IFileOperation's PreDeleteItem guard cannot
# tell (native probe runs 40951037575, 40953347904).
return UnavailableBackend(WINDOWS_RECYCLE)
if platform == "darwin":
return UnavailableBackend()
return FreedesktopBackend()
+489
View File
@@ -0,0 +1,489 @@
"""The macOS Trash backend (#499): NSFileManager through ctypes, or refuse.
``-[NSFileManager trashItemAtURL:resultingItemURL:error:]`` moves a file into
its volume's trash folder (``~/.Trash`` on the startup volume,
``<volume>/.Trashes/<uid>`` elsewhere) or fails with an ``NSError``; it renames
name clashes itself and never replaces a file. Before calling it,
:class:`MacTrashBackend` refuses what it cannot show to be recoverable:
network volumes, read-only volumes, iCloud Drive and File Provider sync
folders, a trash folder inside an output root, and files macOS would refuse
for permission. Nothing here unlinks, copies or overwrites a file.
The :class:`Bridge` is the only code that touches the Objective-C runtime. It
sends ten fixed messages, each through ``objc_msgSend`` cast to its exact
prototype (required on arm64), checks every object for nil before a send (a
nil can raise an Objective-C exception, which would abort the process), and
runs each call inside its own autorelease pool. It is loaded lazily, on the
first ``available``/``move``; when it cannot load, every file answers
``no-trash`` as before. The facts behind these rules were measured on macOS
14, 15 (x86_64) and 26 (docs/egm/499-macos-trash/design.md §4).
"""
from __future__ import annotations
import ctypes
import logging
import os
import stat
import sys
import threading
from dataclasses import dataclass
from . import trash
logger = logging.getLogger("prompt_manager.trash")
COCOA = "NSCocoaErrorDomain"
POSIX = "NSPOSIXErrorDomain"
FEATURE_UNSUPPORTED = 3328
_ENOENT = 2
# Top-level Cocoa codes of a refused move (design §7.5); anything else is "failed".
_MOVE_CODES = {
FEATURE_UNSUPPORTED: trash.NO_TRASH,
642: trash.NO_TRASH, # NSFileWriteVolumeReadOnlyError
513: trash.NO_PERMISSION, # NSFileWriteNoPermissionError
257: trash.NO_PERMISSION, # NSFileReadNoPermissionError
}
_LOCK_FLAGS = stat.UF_IMMUTABLE | stat.SF_IMMUTABLE | stat.UF_APPEND | stat.SF_APPEND
_CLOUD_FOLDERS = (("Library", "Mobile Documents"), ("Library", "CloudStorage"))
@dataclass(frozen=True)
class NSErr:
domain: str
code: int
under_domain: str | None = None
under_code: int | None = None
@dataclass(frozen=True)
class Facts:
"""Resource values of a file's URL; ``None`` means the lookup failed or was nil."""
local: bool | None
read_only: bool | None
root_fs: bool | None
volume: str | None
ubiquitous: bool | None
def error_detail(error: NSErr | None) -> str:
"""``"<domain> <code> (<under_domain> <under_code>)"`` for the log."""
if error is None:
return "no NSError"
text = f"{error.domain} {error.code}"
if error.under_domain is not None:
text += f" ({error.under_domain} {error.under_code})"
return text
def refusal_code(error: NSErr | None) -> str:
if error is None or error.domain != COCOA:
return trash.FAILED
return _MOVE_CODES.get(error.code, trash.FAILED)
def _within(path: str, folder: str) -> bool:
path = os.path.normcase(path)
folder = os.path.normcase(folder)
trimmed = folder.rstrip("\\/") or folder
return path == trimmed or path.startswith(trimmed.rstrip(os.sep) + os.sep)
def _identity(path: str):
info = os.stat(path)
return info.st_dev, info.st_ino
def inside_any_root(path: str, roots) -> bool:
"""Whether *path* (existing or not) lies in an output root, by file identity.
Every existing folder from *path* up to the top is compared with the
roots' ``(st_dev, st_ino)``, so case, NFC/NFD and symlinked spellings of a
root all match.
"""
identities = set()
for root in roots:
try:
identities.add(_identity(str(root)))
except OSError:
continue
current = os.path.abspath(path)
while True:
try:
if _identity(current) in identities:
return True
except OSError:
pass
parent = os.path.dirname(current)
if parent == current:
return False
current = parent
def _same_folder(left: str, right: str) -> bool:
try:
return _identity(left) == _identity(right)
except OSError:
return os.path.realpath(left) == os.path.realpath(right)
class MacTrashBackend:
"""``available``/``move`` for macOS; the bridge and file checks are seams."""
def __init__(
self, bridge=None, uid=None, home=None, lstat=os.lstat, access=os.access
):
self._bridge = bridge
self._load_failed = False
self._lock = threading.Lock()
if uid is None:
uid = os.getuid() if hasattr(os, "getuid") else -1
self._uid = uid
self._home = home
self._lstat = lstat
self._access = access
def _get_bridge(self):
if self._bridge is None and not self._load_failed:
with self._lock:
if self._bridge is None and not self._load_failed:
try:
self._bridge = load_bridge()
except (OSError, AttributeError, ValueError) as error:
self._load_failed = True
logger.warning("The macOS trash is unavailable: %s", error)
if self._bridge is None:
raise trash.TrashRefused(trash.NO_TRASH, "Foundation bridge not loaded")
return self._bridge
def _home_dir(self) -> str:
return os.path.realpath(self._home or os.path.expanduser("~"))
def _plan(self, path: str, roots):
"""``(trash folder, facts)`` for *path*; raises TrashRefused (design §7.2)."""
bridge = self._get_bridge()
real = os.path.realpath(path)
facts = bridge.facts(real)
if facts.local is not True:
raise trash.TrashRefused(trash.NETWORK)
if facts.read_only is not False:
raise trash.TrashRefused(trash.NO_TRASH, "read-only volume")
home = self._home_dir()
if facts.ubiquitous is True or any(
_within(real, os.path.join(home, *parts)) for parts in _CLOUD_FOLDERS
):
raise trash.TrashRefused(trash.CLOUD_SYNC)
folder, error = bridge.trash_dir(real)
if folder is None:
folder = self._predict(facts, error)
if inside_any_root(folder, roots):
raise trash.TrashRefused(trash.TRASH_IN_ROOT)
self._check_permission(real)
return folder, facts
def _predict(self, facts: Facts, error: NSErr | None) -> str:
"""``<volume>/.Trashes/<uid>`` for a volume whose trash is not made yet."""
missing = (
error is not None
and error.domain == COCOA
and error.code == FEATURE_UNSUPPORTED
and error.under_domain == POSIX
and error.under_code == _ENOENT
)
if not missing or facts.root_fs is not False or not facts.volume:
raise trash.TrashRefused(trash.NO_TRASH, error_detail(error))
trashes = os.path.join(facts.volume, ".Trashes")
try:
info = self._lstat(trashes)
except FileNotFoundError:
info = None
except OSError as failure:
raise trash.TrashRefused(trash.NO_TRASH, str(failure)) from failure
if info is not None and not stat.S_ISDIR(info.st_mode):
raise trash.TrashRefused(trash.NO_TRASH, ".Trashes is not a folder")
return os.path.join(trashes, str(self._uid))
def _check_permission(self, real: str) -> None:
parent = os.path.dirname(real)
if not self._access(parent, os.W_OK | os.X_OK):
raise trash.TrashRefused(trash.NO_PERMISSION, "folder not writable")
try:
file_info = self._lstat(real)
parent_info = self._lstat(parent)
except OSError as failure:
raise trash.TrashRefused(trash.FAILED, str(failure)) from failure
for info in (file_info, parent_info):
if getattr(info, "st_flags", 0) & _LOCK_FLAGS:
raise trash.TrashRefused(trash.NO_PERMISSION, "immutable flag")
if parent_info.st_mode & stat.S_ISVTX and file_info.st_uid != self._uid:
raise trash.TrashRefused(trash.NO_PERMISSION, "sticky folder, foreign file")
def _label(self, folder: str, facts: Facts) -> str:
if _same_folder(folder, os.path.join(self._home_dir(), ".Trash")):
return trash.HOME_TRASH_LABEL
real = os.path.realpath(folder)
volume = os.path.realpath(facts.volume) if facts.volume else None
if volume and volume != os.path.realpath(os.sep) and _within(real, volume):
relative = os.path.relpath(real, volume).replace(os.sep, "/")
return f"{relative} on its drive"
return real
def available(self, path: str, roots) -> str | None:
"""None when *path* can be trashed, else the refusal code (no writes)."""
try:
self._plan(path, roots)
except trash.TrashRefused as refused:
return refused.code
except OSError:
return trash.NO_TRASH
return None
def move(self, path: str, roots) -> str:
"""Move *path* into its trash folder and return the label (design §7.4)."""
try:
planned, facts = self._plan(path, roots)
except OSError as failure:
raise trash.TrashRefused(trash.NO_TRASH, str(failure)) from failure
real = os.path.realpath(path)
ok, resulting, error = self._get_bridge().trash(real)
if not ok:
raise trash.TrashRefused(refusal_code(error), error_detail(error))
if os.path.lexists(real):
raise trash.TrashRefused(trash.FAILED, "reported moved, still in place")
if not resulting or inside_any_root(os.path.dirname(resulting), roots):
raise trash.TrashUnconfirmed(f"{real} -> {resulting!r}")
folder = os.path.dirname(resulting)
if not _same_folder(folder, planned):
logger.warning(
"macOS trashed %s to %s, not the planned %s", real, resulting, planned
)
return self._label(folder, facts)
# ── the Foundation bridge ────────────────────────────────────────────────
_NS_TRASH_DIRECTORY = 102
_NS_USER_DOMAIN_MASK = 1
_LIBOBJC = "/usr/lib/libobjc.A.dylib"
_FOUNDATION = "/System/Library/Frameworks/Foundation.framework/Foundation"
_KEYS = (
"NSURLVolumeIsLocalKey",
"NSURLVolumeIsReadOnlyKey",
"NSURLVolumeIsRootFileSystemKey",
"NSURLVolumeURLKey",
"NSURLIsUbiquitousItemKey",
"NSUnderlyingErrorKey",
)
_REQUIRED_SELECTORS = (
("NSURL", "fileURLWithFileSystemRepresentation:isDirectory:relativeToURL:"),
("NSURL", "getResourceValue:forKey:error:"),
("NSFileManager", "URLForDirectory:inDomain:appropriateForURL:create:error:"),
("NSFileManager", "trashItemAtURL:resultingItemURL:error:"),
)
class _Nil(Exception):
"""An Objective-C object was nil where a send needs one."""
class Bridge: # pragma: no cover - macOS only; the native-trash job runs it
"""Foundation through ctypes: ``facts``, ``trash_dir`` and ``trash`` (design §6)."""
def __init__(self):
if sys.platform != "darwin":
raise OSError("the Foundation bridge exists only on macOS")
objc = ctypes.CDLL(_LIBOBJC)
foundation = ctypes.CDLL(_FOUNDATION)
void_p, char_p, byte = ctypes.c_void_p, ctypes.c_char_p, ctypes.c_byte
objc.objc_getClass.argtypes = [char_p]
objc.objc_getClass.restype = void_p
objc.sel_registerName.argtypes = [char_p]
objc.sel_registerName.restype = void_p
objc.objc_autoreleasePoolPush.argtypes = []
objc.objc_autoreleasePoolPush.restype = void_p
objc.objc_autoreleasePoolPop.argtypes = [void_p]
objc.objc_autoreleasePoolPop.restype = None
self._objc = objc
address = ctypes.cast(objc.objc_msgSend, void_p).value
def send(restype, *argtypes):
return ctypes.CFUNCTYPE(restype, void_p, void_p, *argtypes)(address)
out = ctypes.POINTER(void_p)
self._send_id = send(void_p)
self._send_responds = send(byte, void_p)
self._send_url = send(void_p, char_p, byte, void_p)
self._send_resource = send(byte, out, void_p, out)
self._send_bool = send(byte)
self._send_dir = send(void_p, ctypes.c_ulong, ctypes.c_ulong, void_p, byte, out)
self._send_trash = send(byte, void_p, out, out)
self._send_cstr = send(char_p)
self._send_long = send(ctypes.c_long)
self._send_object = send(void_p, void_p)
self._keys = {}
for name in _KEYS:
value = void_p.in_dll(foundation, name).value
if not value:
raise ValueError(f"Foundation constant {name} is nil")
self._keys[name] = value
for class_name, selector in _REQUIRED_SELECTORS:
if not self._send_responds(
self._class(class_name),
self._sel("respondsToSelector:"),
self._sel(selector),
) and not self._instances_respond(class_name, selector):
raise ValueError(f"{class_name} does not respond to {selector}")
# helpers (call inside a pool) ------------------------------------------------
def _class(self, name: str):
value = self._objc.objc_getClass(name.encode())
if not value:
raise ValueError(f"no Objective-C class {name}")
return value
def _sel(self, name: str):
return self._objc.sel_registerName(name.encode())
def _instances_respond(self, class_name: str, selector: str) -> bool:
return (
self._send_responds(
self._class(class_name),
self._sel("instancesRespondToSelector:"),
self._sel(selector),
)
!= 0
)
@staticmethod
def _need(value):
if not value:
raise _Nil()
return value
def _manager(self):
return self._need(
self._send_id(self._class("NSFileManager"), self._sel("defaultManager"))
)
def _url(self, path: str):
return self._need(
self._send_url(
self._class("NSURL"),
self._sel(
"fileURLWithFileSystemRepresentation:isDirectory:relativeToURL:"
),
os.fsencode(path),
0,
None,
)
)
def _path(self, url) -> str | None:
if not url:
return None
raw = self._send_cstr(url, self._sel("fileSystemRepresentation"))
return os.fsdecode(raw) if raw else None
def _text(self, obj) -> str | None:
if not obj:
return None
raw = self._send_cstr(obj, self._sel("UTF8String"))
return raw.decode("utf-8", "replace") if raw else None
def _error(self, error) -> NSErr | None:
if not error:
return None
domain = self._text(self._send_id(error, self._sel("domain"))) or "?"
code = self._send_long(error, self._sel("code"))
under_domain = under_code = None
info = self._send_id(error, self._sel("userInfo"))
if info:
under = self._send_object(
info, self._sel("objectForKey:"), self._keys["NSUnderlyingErrorKey"]
)
if under:
under_domain = self._text(self._send_id(under, self._sel("domain")))
under_code = self._send_long(under, self._sel("code"))
return NSErr(domain, code, under_domain, under_code)
def _resource(self, url, key_name: str):
value, error = ctypes.c_void_p(), ctypes.c_void_p()
ok = self._send_resource(
url,
self._sel("getResourceValue:forKey:error:"),
ctypes.byref(value),
self._keys[key_name],
ctypes.byref(error),
)
return value.value if ok != 0 and value.value else None
def _flag(self, url, key_name: str) -> bool | None:
value = self._resource(url, key_name)
if value is None:
return None
return self._send_bool(value, self._sel("boolValue")) != 0
def _pooled(self, work):
pool = self._objc.objc_autoreleasePoolPush()
try:
return work()
except _Nil as nil:
raise trash.TrashRefused(trash.NO_TRASH, "nil Objective-C object") from nil
finally:
self._objc.objc_autoreleasePoolPop(pool)
# the bridge API (design §6.3) ----------------------------------------------
def facts(self, path: str) -> Facts:
def work():
url = self._url(path)
return Facts(
local=self._flag(url, "NSURLVolumeIsLocalKey"),
read_only=self._flag(url, "NSURLVolumeIsReadOnlyKey"),
root_fs=self._flag(url, "NSURLVolumeIsRootFileSystemKey"),
volume=self._path(self._resource(url, "NSURLVolumeURLKey")),
ubiquitous=self._flag(url, "NSURLIsUbiquitousItemKey"),
)
return self._pooled(work)
def trash_dir(self, path: str):
def work():
error = ctypes.c_void_p()
folder = self._send_dir(
self._manager(),
self._sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
_NS_TRASH_DIRECTORY,
_NS_USER_DOMAIN_MASK,
self._url(path),
0,
ctypes.byref(error),
)
if folder:
return self._path(folder), None
return None, self._error(error.value)
return self._pooled(work)
def trash(self, path: str):
def work():
resulting, error = ctypes.c_void_p(), ctypes.c_void_p()
ok = self._send_trash(
self._manager(),
self._sel("trashItemAtURL:resultingItemURL:error:"),
self._url(path),
ctypes.byref(resulting),
ctypes.byref(error),
)
if ok != 0:
return True, self._path(resulting.value), None
return False, None, self._error(error.value)
return self._pooled(work)
def load_bridge() -> Bridge:
"""The real Foundation bridge; raises OSError/ValueError where it cannot load."""
return Bridge()
+3 -2
View File
@@ -129,8 +129,9 @@ export const KINDS = Object.freeze({
scan: Object.freeze({
...GENERIC,
pillVerb: 'Scanning',
startToast: () => 'Scan started.',
finishToast: namedFinish(() => 'Scan', addedOf, scanLine),
// #498: "Return images to the library" is a scan of the returned files; its toasts carry its own title.
startToast: job => (job.scope?.files === 'returned' ? `${job.title} started.` : 'Scan started.'),
finishToast: namedFinish(job => (job.scope?.files === 'returned' ? job.title : 'Scan'), addedOf, scanLine),
line: scanLine,
kept: addedOf,
tiles: tiles([['added', 'Added to Library', 'added'], ['known', 'Already known', ''],
+8 -1
View File
@@ -234,7 +234,7 @@ export const DEFERRED_TEXT = 'Metadata loads when you ask for it.';
* collections checklist; `onCollectionsChanged({added, removed})` hears the confirmed changes.
*/
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback,
collections = null, onCollectionsChanged = null, onDelete = null}) {
collections = null, onCollectionsChanged = null, onDelete = null, onReturn = null}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
@@ -342,6 +342,13 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
// #480: Delete… for a listed Images file only, last in the actions.
const removable = onDelete ? revealTarget(record.identity) : null;
if (removable) footer.append(deleteButton(removable));
// #498 (D162): a removed file (Show removed images) offers Return to library in Delete…'s place.
const returnable = onReturn ? revealTarget(record.identity) : null;
if (returnable) {
const button = createButton('Return to library');
contentScope.listen(button, 'click', () => onReturn(returnable, button));
footer.append(button);
}
if (!full && footer.childNodes.length) container.append(footer);
}
/**
+5 -3
View File
@@ -53,7 +53,7 @@ export function cardMedia(image, lazy = false) {
* cannot be served, and `showInfo: false` hides the title and file line (D97). A `linked_only` image (LoRA media in
* LoraManager's folders, #355) has no Tag action.
*/
export function createImageCard(image, { checked = null, favoriteBusy = false, media = true, showInfo = true } = {}) {
export function createImageCard(image, { checked = null, favoriteBusy = false, media = true, showInfo = true, removed = false } = {}) {
const card = el('article', undefined, 'media-card');
card.dataset.imageKey = imageKey(image); card.setAttribute('role', 'listitem');
card.style.setProperty('--media-ratio', mediaRatio(image));
@@ -71,7 +71,7 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m
label.append(box);
overlays.append(label);
}
if (typeof image.favorite === 'boolean') {
if (typeof image.favorite === 'boolean' && !removed) {
const star = createButton('', 'star-button');
star.setAttribute('aria-label', `Favorite ${image.filename}`);
paintStar(star, image.favorite, favoriteBusy);
@@ -82,6 +82,8 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m
const text = el('div');
const title = el('h3', cardTitle(image)); if (image.path) title.title = image.path;
text.append(title, el('p', cardSubtitle(image)));
// #498 (D162): a card in Show removed images carries the concept's `.chip`.
if (removed) text.append(el('span', 'Removed', 'chip'));
// D97: `showInfo` false (System › Appearance) hides the title and file line; Info and Tag stay.
if (!showInfo) text.hidden = true;
const actions = el('div', undefined, 'image-card-actions');
@@ -90,7 +92,7 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m
inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`);
actions.append(inspectButton);
// #355: LoRA-linked media lives in LoraManager's folders; tags are kept for output files only.
if (image.linked_only !== true) actions.append(createTagButton(image));
if (image.linked_only !== true && !removed) actions.append(createTagButton(image));
info.append(text, actions);
card.append(art, info);
return card;
+13 -4
View File
@@ -317,6 +317,9 @@ export function createImageTaggingController({api, target, source = 'images', on
// A save in flight: closing hands the capture release to its completion.
let saving = false;
let observedOnce = false;
// #515: the user typed since the last observation started (or before the first one answered), so the answer
// keeps that text.
let typed = false;
function set(patch) {
if (closed) return;
@@ -345,7 +348,8 @@ export function createImageTaggingController({api, target, source = 'images', on
async function open({keepDraft = false} = {}) {
if (closed) return;
const serial = ++openSerial;
const draft = keepDraft ? state.manual : null;
// A reopen after an observation starts from its answer; text typed before any observation is kept (#515).
if (observedOnce) typed = false;
const seenRevision = state.tagRevision;
stopGenerating();
releaseCapture();
@@ -385,15 +389,19 @@ export function createImageTaggingController({api, target, source = 'images', on
const savedModel = !chosen && !modelPicked && read.model ? {model: read.model} : {};
observedOnce = true;
const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture;
// A kept draft stays as typed unless another writer changed the set meanwhile: then the set leads it (I2).
const changed = draft !== null && value.tag_revision !== seenRevision;
// The draft is read now, so text typed while observing is kept (#515). A kept draft stays as typed unless
// another writer changed the set meanwhile: then the set leads it (I2). Text typed before the user saw any
// saved tags follows them, so a Save never drops tags it never showed.
const draft = keepDraft || typed ? state.manual : null;
const changed = keepDraft && value.tag_revision !== seenRevision;
const unseen = draft !== null && !keepDraft;
const reason = generate.supported ? '' : (generate.reason || '');
set({
phase: 'ready',
image: {filename: value.filename, title: value.title, width, height, mediaType, previewUrl,
dimensions: width && height ? `${width} × ${height}` : null},
savedTags: value.tags.slice(), tagRevision: value.tag_revision,
manual: draft === null ? value.tags.join(', ') : changed ? mergeCurrent(value.tags, draft) : draft,
manual: draft === null ? value.tags.join(', ') : changed || unseen ? mergeCurrent(value.tags, draft) : draft,
draftRevision: state.draftRevision + 1,
...savedModel, options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1,
generateSupported: generate.supported, generateReason: generate.reason,
@@ -402,6 +410,7 @@ export function createImageTaggingController({api, target, source = 'images', on
}
function setManual(text) {
typed = true;
set({manual: typeof text === 'string' ? text : ''});
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Images › Show removed images › Return to library (#498, D162).
*
* A removed image is a file "Remove from the library only" (#480) hid: it stays on disk without records. Return to
* library posts the checked images (or "all N matching" in the removed view) to `POST /images/removed/return`, which
* starts a scan job, "Return images to the library": the marks go, then those files are scanned, so their prompt
* links come back from their metadata. Tags, categories, collections and favorites do not come back. Nothing is
* deleted or moved, so there is no review (as the Library's Restore, D152).
*/
import { followJobEnd } from './images-tagging-bulk.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { countOf } from './plural.mjs';
export const RETURN_URL = '/prompt_manager/images/removed/return';
export const MAX_RETURN = 500;
export const TOO_MANY = 'Select at most 500 images.';
export const RETURN_LABEL = 'Return to library';
export const SHOW_REMOVED = 'Show removed images';
export const NOT_BACK = 'Tags, categories, collections and favorites do not come back.';
export const RETURN_FAILED = 'The images could not be returned. Nothing was changed.';
export const NONE_REMOVED = 'None of the selected images are removed from the library.';
export const SOURCE_CHANGED = 'The sources changed. Refresh the images and try again.';
/** The heading toggle's text: "Show removed images (N)", without the count while unknown. */
export function toggleLabel(total) {
return Number.isSafeInteger(total) && total >= 0 ? `${SHOW_REMOVED} (${total.toLocaleString('en-US')})` : SHOW_REMOVED;
}
/** The removed view's caption: "N removed images · files on disk, hidden from Images". */
export function removedCaption(total) {
return `${countOf(total, 'removed image')} · files on disk, hidden from Images`;
}
/** The removed view's empty text. */
export function removedEmpty(filtered) {
return filtered ? 'No removed images match.' : 'No removed images.';
}
export function startToast(count) {
return `Returning ${countOf(count, 'image')} to the library…`;
}
/** The toast when the return job ends: how many came back and how many the scan linked. */
export function finishedToast(job, returned) {
if (job?.state === 'failed') return `Returning images stopped: ${job.status_line || 'see Activity'}.`;
if (job?.state === 'cancelled') return 'Returning images was cancelled. The images already returned stay in the library.';
const linked = Number(job?.counters?.added ?? 0) || 0;
const back = `Returned ${countOf(returned, 'image')} to the library`;
const links = linked ? `; ${countOf(linked, 'image')} linked to ${linked === 1 ? 'its prompt' : 'their prompts'}` : '';
return `${back}${links}. ${NOT_BACK}`;
}
export function returnFailure(error) {
if (error?.code === 'no-images') return NONE_REMOVED;
if (error?.code === 'too-many-images') return TOO_MANY;
if (error?.code === 'source-changed') return SOURCE_CHANGED;
if (error?.code === 'busy' && error.message) return error.message;
return RETURN_FAILED;
}
/**
* Return `images` (the checked ones, or the inspector's one) or `filter` ("all N matching" in the removed view, `count`
* of them). `trigger` is aria-disabled until the server answers; a second press is ignored. `onReturned(job)` runs
* when the job ends (at once without an Activity store).
*/
export async function returnImages({api, scope, store, images = [], filter = null, count = images.length, trigger = null,
onReturned = () => {}, notify = showWorkspaceToast}) {
if (count > MAX_RETURN) { notify(TOO_MANY); return null; }
if (!filter && !images.length) return null;
if (trigger?.getAttribute('aria-disabled') === 'true') return null;
trigger?.setAttribute('aria-disabled', 'true');
let answer;
try {
const body = filter ? {filter: {...filter, removed: '1'}}
: {items: images.map(({root_index, root_scope, path}) => ({root_index, root_scope, path}))};
answer = await api.post(RETURN_URL, body, {signal: scope.signal});
} catch (error) {
if (!scope.signal.aborted) notify(returnFailure(error));
return null;
} finally {
trigger?.removeAttribute('aria-disabled');
}
if (scope.signal.aborted) return null;
const returned = Number(answer?.returned ?? count) || 0;
notify(startToast(returned));
let job;
try {
job = store ? store.track(answer.job) : answer.job;
} catch {
store?.load?.();
onReturned(null);
return null;
}
if (store) followJobEnd({store, scope, id: job.id, onEnd: ended => { notify(finishedToast(ended, returned)); onReturned(ended); }});
else onReturned(job);
return job;
}
+21 -1
View File
@@ -51,7 +51,8 @@ function el(tag, text, className) {
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null }) {
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null, removedView = null,
onReturn = null }) {
let checked = initial;
let exporting = false, categorizing = false;
// D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox.
@@ -103,10 +104,29 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
bulk.append(deleteButton);
}
bulk.append(clearButton);
// #498 (D162): in Show removed images the bar holds only Return to library and Clear; every other action writes
// records, which would show a removed file again without reading it.
const normalActions = [...bulk.children].filter(node => node !== count && node !== clearButton);
const returnButton = onReturn ? createButton('Return to library') : null;
if (returnButton) {
scope.listen(returnButton, 'click', () => {
const target = matchingTarget();
onReturn(checkedImages(), target, returnButton);
});
}
let shownRemoved = false;
function paintMode() {
const removed = Boolean(returnButton && removedView?.());
if (removed === shownRemoved) return;
shownRemoved = removed;
for (const node of [...normalActions, returnButton]) node?.remove();
clearButton.before(...(removed ? [returnButton] : normalActions));
}
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
if (!checked.size && !allMatching) { touchMode = false; bulk.remove(); return; }
paintMode();
const listing = getListing();
const label = allMatching ? matchingLabel(listing.total) : selectionLabel(checked.size);
if (count.textContent !== label) count.textContent = label;
+24 -7
View File
@@ -62,6 +62,14 @@ function queryFor(filters, batchSize) {
const media = filters.media ?? 'all';
if (!['all', 'image', 'video'].includes(media)) throw new TypeError('Invalid media filter');
if (media !== 'all') query.set('media', media);
// #498 (D162): Show removed images lists only the files a removal mark hides; never with a category, tags,
// favorites or a collection (the server refuses them: such a file has no records).
const removed = filters.removed ?? false;
if (typeof removed !== 'boolean') throw new TypeError('Invalid removed filter');
if (removed && (filters.category || (filters.tags ?? []).length || filters.favorites || (filters.collection ?? '') !== '')) {
throw new TypeError('Invalid removed filter');
}
if (removed) query.set('removed', '1');
const sort = filters.sort ?? 'newest';
if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter');
query.set('sort', sort);
@@ -107,6 +115,9 @@ function validatePage(page, cursor) {
}
}
/** A page's `removed_total` (#498), or null from an older server. */
const removedOf = page => (isCount(page?.removed_total) ? page.removed_total : null);
/** `state` with the item named by `key` patched (both frozen); the same state when no item has that key. */
export function withItem(state, key, patch) {
if (!patch || typeof patch !== 'object' || 'path' in patch || 'root_index' in patch) {
@@ -128,8 +139,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
throw new TypeError('Invalid image batch size');
}
// positiveCount: distinct linked positives over the whole matching set, or null while unknown (PR B contract).
// removedTotal (#498): the removed images in the selected source(s), from the first page; null while unknown.
let state = Object.freeze({ items: Object.freeze([]), total: 0, cursor: null,
hasMore: true, loading: false, error: null, loaded: 0, positiveCount: null });
hasMore: true, loading: false, error: null, loaded: 0, positiveCount: null, removedTotal: null });
let query = queryFor({}, batchSize);
let controller = null;
let disposed = false;
@@ -149,7 +161,8 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
const items = Object.isFrozen(saved.items) && saved.items.every(Object.isFrozen) ? saved.items :
Object.freeze(saved.items.map(item => Object.freeze({ ...item })));
state = Object.freeze({ items, total: saved.total, cursor: saved.cursor, hasMore: saved.hasMore,
loading: false, error: null, loaded: items.length, positiveCount: saved.positiveCount ?? null });
loading: false, error: null, loaded: items.length, positiveCount: saved.positiveCount ?? null,
removedTotal: isCount(saved.removedTotal) ? saved.removedTotal : null });
}
function publish(update) {
@@ -188,8 +201,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
// first page's count, and a first page without one leaves it unknown.
const positiveCount = isCount(page.positive_count) ? page.positive_count : cursor === null ? null : state.positiveCount;
controller = null;
const removedTotal = cursor === null ? removedOf(page) : state.removedTotal;
publish({ items, total: page.total, cursor: page.next_cursor, hasMore: page.has_more,
loading: false, error: null, loaded: items.length, positiveCount });
loading: false, error: null, loaded: items.length, positiveCount, removedTotal });
return incoming.length;
} catch (error) {
if (!disposed && request === controller) {
@@ -224,7 +238,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
const request = refreshing = new AbortController();
const target = Math.max(state.loaded, 1);
const items = [], keys = new Set();
let cursor = null, total = 0, hasMore = true, positiveCount = null;
let cursor = null, total = 0, hasMore = true, positiveCount = null, removedTotal = null;
try {
while (items.length < target && hasMore) {
const parameters = new URLSearchParams(query);
@@ -239,7 +253,10 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
keys.add(key);
items.push(Object.freeze({ ...item }));
}
if (cursor === null) positiveCount = isCount(page.positive_count) ? page.positive_count : null;
if (cursor === null) {
positiveCount = isCount(page.positive_count) ? page.positive_count : null;
removedTotal = removedOf(page);
}
({total, next_cursor: cursor, has_more: hasMore} = page);
}
} catch (error) {
@@ -251,7 +268,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
refreshing = null;
seen = keys;
state = Object.freeze({ ...state, items: Object.freeze(items), total, cursor, hasMore, loading: false, error: null,
loaded: items.length, positiveCount });
loaded: items.length, positiveCount, removedTotal });
onChange(state, {replaced: true});
return items.length;
}
@@ -267,7 +284,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
query = nextQuery;
seen = new Set();
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
loading: false, error: null, loaded: 0, positiveCount: null });
loading: false, error: null, loaded: 0, positiveCount: null, removedTotal: state.removedTotal });
return load ? loadMore() : 0;
}
+59 -8
View File
@@ -10,6 +10,7 @@ import { promptRoles } from './prompt-blocks.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { createMediaViewer } from './media-viewer.mjs';
import { openDeleteImages } from './images-delete.mjs';
import { removedCaption, removedEmpty, returnImages, toggleLabel } from './images-return.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
import { mountToolbar } from './toolbar.mjs';
@@ -177,6 +178,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// A collection and favorites are never both the filter (DC10).
favorites: !openCollection && (favorites || saved?.favorites === true), collection: openCollection };
filters.tags = [...filters.tags];
// #498 (D162): Show removed images belongs to this visit only: never restored, saved or handed on.
filters.removed = false;
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== '');
@@ -186,20 +189,28 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
// Images return state (#249 D51, and M2 for saved views).
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, defaults: startDefaults, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
if (!embedded && !filters.favorites && !filters.collection && !saved && !filters.removed) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, defaults: startDefaults, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
scope.cleanup(() => main.classList.remove('images-main'));
// The concept's Images heading (app.js renderImages): description, then Import before the shell's New prompt.
const heading = embedded ? null : pageHeading(main);
let importButton = null, importDialog = null;
let importButton = null, importDialog = null, showRemoved = null;
if (heading) {
heading.description.textContent = 'Your generations, their prompts, and the next idea.';
importButton = createButton('');
importButton.append(createWorkspaceIcon('upload'), el('span', 'Import'));
heading.actions.prepend(importButton);
scope.cleanup(() => importButton.remove());
// #498 (D162): Show removed images (N), before Import, only on the Images page and only while there are any.
if (imagesPage) {
// Attached only while shown, so a library without removals keeps the concept's heading exactly.
showRemoved = createButton(toggleLabel(null));
showRemoved.setAttribute('aria-pressed', 'false');
scope.cleanup(() => showRemoved.remove());
scope.listen(showRemoved, 'click', () => setRemovedView(!filters.removed));
}
scope.listen(importButton, 'click', () => {
importDialog = openImportDialog({scope, trigger:importButton, rescanBusy:rescanning,
onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan,
@@ -301,6 +312,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
onDeleted:deleted,
// #498: the removed view's bar holds only Return to library and Clear.
removedView:() => filters.removed,
onReturn:(images, extra, trigger) => returnSome({images, ...extra, trigger}),
// The Set category field suggests the toolbar's categories (#332 MC13).
getCategories:() => categoryNames,
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
@@ -339,10 +353,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
selection = imageKey(image);
selectedImage = image;
for (const video of details.querySelectorAll('video')) releaseVideo(video);
// #498: a removed file's panel offers Return to library and nothing that writes (Collections…, Delete…, tag chips).
const removedView = filters.removed;
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection,
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy,
onDelete:embedded ? null : (target, button) => deleteOne(target, button)});
onTagFilter:embedded || removedView ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection,
collections:removedView ? null : inspectorCollections, onCollectionsChanged:collectionsChangedBy,
onDelete:embedded || removedView ? null : (target, button) => deleteOne(target, button),
onReturn:removedView ? (target, button) => returnSome({images:[target], trigger:button}) : null});
detailInspector.setState(inspection.state);
inspection.select(image);
for (const [key, card] of rendered) markSelected(card, key === selection);
@@ -360,7 +377,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const key = imageKey(image);
const card = createImageCard(image, {checked:bar ? bar.isChecked(key) : null, favoriteBusy:favoriteBusy(key),
// D97: "Show image details on cards" off hides the title and file line; Info and Tag stay.
showInfo:preferences?.showImageInfo !== false});
showInfo:preferences?.showImageInfo !== false,
// #498: a removed file has no records; its star and Tag would write and show it again.
removed:filters.removed});
markSelected(card, key === selection);
return card;
}
@@ -378,6 +397,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const image = state.items.find(item => imageKey(item) === card.dataset.imageKey);
if (!image) return;
const action = event.target.closest('.image-card-actions button');
// #498: in the removed view a card only inspects (the viewer's writes would show the file again).
if (filters.removed) { if (event.target.closest('.image-open, .image-card-actions button')) inspect(image); return; }
if (event.target.closest('.star-button')) toggleFavorite(api, image);
else if (event.target.closest('.image-open')) previewImage(image);
else if (action && !openCardTagging({api, scope, image, action, embedded, onSourceChanged:recovery.recover,
@@ -553,7 +574,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
state = next;
bar?.listingChanged();
if (!embedded) {
setText(count, countLine(next));
setText(count, filters.removed ? removedCaption(next.total) : countLine(next));
paintRemovedToggle(next);
setText(captionDay, captionDate(next.items, filters.sort));
if (captionDay.hidden !== !captionDay.textContent) captionDay.hidden = !captionDay.textContent;
}
@@ -576,7 +598,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); });
}
if (showsEmpty) {
setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES);
setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText
: filters.removed ? removedEmpty(Boolean(filters.q || filters.subfolder || filters.media !== 'all')) : NO_MATCHES);
empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0);
}
if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
@@ -656,6 +679,32 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
loadCategories();
reset();
}
/** #498 (D162): enter or leave Show removed images; entering clears the Category and Tags it never combines with. */
function setRemovedView(on) {
if (filters.removed === on) return;
filters.removed = on;
if (on && (filters.category || filters.tags.length)) {
filters.category = ''; filters.tags = [];
toolbar?.setValue({q:filters.q, category:'', sort:filters.sort});
toolbar?.setActiveFilters(activeRefinements(filters));
}
reset();
}
function paintRemovedToggle(next) {
if (!showRemoved) return;
const total = next.removedTotal;
// A return that leaves nothing removed ends the view: Images lists every file again.
if (filters.removed && total === 0 && !next.loading) { queueMicrotask(() => setRemovedView(false)); }
setText(showRemoved, toggleLabel(total));
setAttr(showRemoved, 'aria-pressed', String(filters.removed));
const shown = filters.removed || total > 0;
if (!shown) showRemoved.remove();
else if (!showRemoved.isConnected) heading.actions.prepend(showRemoved);
}
/** #498: Return to library for the checked images, "all N matching" (`filter`) or the inspector's one. */
function returnSome({images = [], filter = null, count = images.length, trigger = null}) {
return returnImages({api, scope, store:activity, images, filter, count, trigger, onReturned:() => { if (!scope.signal.aborted) reset(); }});
}
/** #480: Delete… for one file, from the inspector or the viewer (`after` closes the viewer first). */
function deleteOne(target, trigger, after = null) {
openDeleteImages({api, scope, store:activity, images:[target], trigger, onDeleted:() => { after?.(); deleted(); }});
@@ -673,6 +722,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (savedScopePending && !discoverySettled) { updateScope(); return; }
settleSavedScope();
updateScope();
// #498: a removed file has no category or tags, so choosing one (toolbar, Refine, a tag chip) ends the view.
if (filters.removed && (filters.category || filters.tags.length)) filters.removed = false;
stream.reset(filters, {load:!recovering});
}
/** A saved view's Source and Folder apply only while the discovered sources still list them (#249 D16). */
+37 -21
View File
@@ -1,6 +1,6 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
import { routes, mountRouter, summarizeOverviewCounts, OVERVIEW_COUNT_ROUTES, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS,
JOB_END_COUNTS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
@@ -149,9 +149,13 @@ function mountWorkspace() {
countRequests.get(id)?.abort();
const request = new AbortController();
countRequests.set(id, request);
// A failed count reads as loading again while it is retried; a known one stays until replaced.
if (knownCounts.get(id) === null) { knownCounts.delete(id); notifyCounts(); }
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
loadNavCounts(api, request.signal, (_, count) => {
if (!request.signal.aborted) showCount(id, count);
if (request.signal.aborted) return;
countRequests.delete(id);
showCount(id, count);
}, [id]);
}
}
@@ -159,7 +163,15 @@ function mountWorkspace() {
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
link?.querySelector('.nav-count')?.remove();
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
knownCounts.set(id, count);
notifyCounts();
}
/** #547: Overview shows these same counts, so a card and its badge cannot differ, and no read runs twice. */
const knownCounts = new Map();
const countListeners = new Set();
function notifyCounts() { for (const listener of countListeners) listener(); }
/** Re-read the given counts unless a read is already in flight. */
function freshenCounts(ids) { refreshCounts(ids.filter(id => !countRequests.has(id))); }
/** A count a page has just read itself: it replaces the badge, and an older read in flight no longer lands. */
function setCount(id, count) {
countRequests.get(id)?.abort();
@@ -494,34 +506,38 @@ function mountWorkspace() {
}
tools.append(cards);
main.append(tools);
async function loadStats() {
const status = element('p', 'Loading library statistics…');
status.setAttribute('role', 'status');
stats.replaceChildren(status);
try {
const result = await api.get('/prompt_manager/stats', { signal: view.signal });
if (view.signal.aborted) return;
const values = summarizeStats(result.stats);
function showStats() {
const { cards, pending, failed, empty } = summarizeOverviewCounts(knownCounts);
const parts = [];
if (cards.some(([, count]) => count !== null)) {
const list = element('dl', undefined, 'stat-grid');
for (const [label, count] of values) {
for (const [label, count] of cards) {
const stat = element('div', undefined, 'stat');
stat.append(element('dt', label), element('dd', count.toLocaleString()));
stat.append(element('dt', label), element('dd', count === null ? '–' : count.toLocaleString()));
list.append(stat);
}
stats.replaceChildren(list);
if (values.every(([, count]) => count === 0)) {
stats.append(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
}
} catch (error) {
if (view.signal.aborted) return;
parts.push(list);
}
if (pending) {
const status = element('p', 'Loading library statistics…');
status.setAttribute('role', 'status');
parts.push(status);
} else if (failed) {
const alert = element('p', 'Library statistics could not be loaded. Your saved data has not changed.');
alert.setAttribute('role', 'alert');
const retry = createButton('Retry');
view.listen(retry, 'click', loadStats);
stats.replaceChildren(alert, retry);
view.listen(retry, 'click', () => refreshCounts(OVERVIEW_COUNT_ROUTES.filter(id => knownCounts.get(id) === null)));
parts.push(alert, retry);
} else if (empty) {
parts.push(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
}
stats.replaceChildren(...parts);
}
loadStats();
countListeners.add(showStats);
view.cleanup(() => countListeners.delete(showStats));
showStats();
// A visit reads them again, as a generation since the last read changes the sidebar's badges too.
freshenCounts(OVERVIEW_COUNT_ROUTES);
}
scope.cleanup(mountRouter(window, render));
scope.listen(window, 'pagehide', () => scope.dispose());
+240 -40
View File
@@ -25,6 +25,8 @@ 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.',
noneSingle: 'This prompt cannot be deleted or archived.',
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.',
@@ -34,21 +36,67 @@ 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) {
/**
* 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), ...(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) {
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',
// #512: archive ids the server says cannot be deleted (another prompt's negative); none from an older answer.
// The server's own list, not the digest-checked one: a deletable prompt with a bad digest is still paired.
archiveOnly: Array.isArray(preview?.deletable)
? [...archive].filter(id => !ids(preview.deletable).includes(id)) : [],
};
}
/** #482: whether the review asks what to do with prompts in a saved pair. */
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' };
}
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;
// #512: a review opened from a prompt's own page names that prompt.
if (review.single) return deletes || !archive.length ? 'Delete this prompt' : 'Archive this prompt';
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';
}
@@ -70,57 +118,122 @@ 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) {
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`],
];
if (permanent(review)) return permanentGrid(review, titleOf);
// #512: rows with nothing in them are left out.
const rows = [];
if (review.delete.length) rows.push(['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`]);
if (review.archive.length) rows.push(['Archive', archiveValue(review)]);
keptRows(rows, review, titleOf);
if (review.delete.length) deleteRows(rows, review, review.imageLinks);
return rows;
}
/** The archive reason is the saved pair (a pair's positive or negative); #512 names another prompt's negative. */
function archiveValue(review) {
const total = review.archive.length, negatives = review.archiveOnly.length;
if (negatives === total) return `${countOf(total, 'prompt')} used as another prompt's negative: hidden, restorable`;
const detail = negatives ? ` (${negatives.toLocaleString('en-US')} used as another prompt's negative)` : '';
return `${countOf(total, 'prompt')} in a saved pair${detail}: hidden, restorable`;
}
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.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')]);
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', 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;
}
/** The review's sentences, in order, each when it applies. */
export function reviewSentences(review) {
const sentences = [];
if (review.archive.length) {
if (permanent(review)) return permanentSentences(review);
const sentences = [], negatives = review.archiveOnly.length;
if (review.archive.length > negatives) {
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) {
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.');
// #512: a negative selected without its positive stays the other prompt's variant.
if (negatives) {
sentences.push("Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant " +
`stays.${negatives === review.archive.length ? ' To bring them back, press Show archived prompts, select them and press Restore.' : ''}`);
}
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 { 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;
}
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;
// #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 (!review.delete.length && !review.archive.length) return COPY.none;
if (review.delete.length && review.disk && review.disk.sufficient === false) {
const none = review.single ? COPY.noneSingle : null;
if (onArchivedShelf(review) && !review.delete.length && !review.deletable.length) return none ?? COPY.noneArchived;
if (!review.delete.length && !review.archive.length) return none ?? COPY.none;
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 +246,75 @@ 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 },
// #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));
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 };
// #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;
}
function failureOutcome(failure, copy) {
@@ -172,14 +339,18 @@ 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);
// #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'} 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).`);
@@ -205,3 +376,32 @@ export function previewOutcome(failure) {
if (failure?.code === 'busy') return failureOutcome(failure, {});
return { toast: COPY.reviewFailed, committed: false, reload: false };
}
/**
* #481: review and run a delete of `ids` outside the selection bar (a prompt's own page): the same preview, review
* (#482's choice included), backup and write. `onSettled(outcome, result, failure)` runs once the write has answered
* (or the preview failed: `result` null); a dismissed review calls `onDismiss()`. The toast is the caller's.
*/
export async function reviewPromptDelete({ api, scope, trigger, ids: promptIds, epoch, titleOf, onSettled, onDismiss = () => {} }) {
const body = { prompt_ids: promptIds, expected_epoch: epoch };
let preview = null, failure = null;
try {
preview = await api.post(PREVIEW_URL, body, { signal: scope.signal });
} catch (error) {
failure = error;
}
if (scope.signal.aborted) return;
if (failure !== null || !preview?.success) { onSettled(previewOutcome(failure), null, failure); return; }
// #512: the review names this one prompt.
const review = { ...reviewOf(preview), epoch, single: true };
openDeleteReview({ scope, trigger, review, titleOf, onDismiss,
onStart: async chosen => {
let result = null, error = null;
try {
result = await api.post(DELETE_URL, deleteBody(chosen));
} catch (caught) {
error = caught;
}
onSettled(deleteOutcome(result, error, chosen), result?.success ? result : null, error);
} });
}
+14 -3
View File
@@ -122,7 +122,7 @@ function datalist(id, values) {
* exit when prompt storage was restored; the dialog closes first.
*/
export function createLibraryEditor({api, parentScope, onCommitted = () => {}, onReconciled = () => {},
onRefreshRequired = () => window.location.reload()}) {
onRefreshRequired = () => window.location.reload(), onDeletePrompt = null}) {
let session = null, disposed = false;
const dialog = el('dialog', undefined, 'library-editor');
dialog.setAttribute('aria-labelledby', 'library-editor-title');
@@ -222,7 +222,10 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
} else form.append(el('p', NEGATIVE_HELP, 'small'));
const line = statusLine(), recovery = new Map();
const actions = el('div', undefined, 'dialog-actions');
const revise = createButton('Create text revision'), archive = createButton('Archive…', 'danger');
// D161 (#481): the variant's archive is named for it, beside the prompt's own Delete prompt… (the concept's one
// "Archive…" in this row archives the prompt, app.js 976).
const revise = createButton('Create text revision'), archive = createButton('Archive variant…', 'danger');
const removePrompt = createButton('Delete prompt…', 'danger');
const cancel = createButton('Cancel'), save = createButton('Save', 'ca-primary');
save.type = 'submit';
form.append(line.note, actions);
@@ -237,6 +240,12 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
cancel.addEventListener('click', () => finish(), {signal: views.signal});
revise.addEventListener('click', () => open({mode: 'revision', positiveId, pairId, trigger: session.trigger}), {signal: views.signal});
archive.addEventListener('click', () => archiveView(), {signal: views.signal});
// #481: the prompt's own delete review (#443/#482) replaces this dialog; focus returns to its trigger.
removePrompt.addEventListener('click', () => {
const {trigger} = session;
finish();
onDeletePrompt?.({positiveId, trigger});
}, {signal: views.signal});
if (!negativeOnly) loadVocabulary(tags, views.signal);
let categoryKey = null, refocus = null;
@@ -256,6 +265,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
cancel.disabled = close.disabled = saving;
// After a restore the numeric IDs may name another prompt; only the Library reload exit stays.
revise.disabled = saving || restoredStorage(state);
removePrompt.disabled = saving || restoredStorage(state);
const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null;
archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived;
const status = statusOf(state, false, mode);
@@ -263,7 +273,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
if (!status && pairProblem) archive.setAttribute('aria-describedby', line.note.id);
else archive.removeAttribute('aria-describedby');
const retry = recoveryButton(recovery, status?.action, views.signal);
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]);
const prompt = readOnly && onDeletePrompt ? [removePrompt] : [];
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive, ...prompt] : []), cancel, save]);
refocus = restoreFocus(refocus, state, [retry, save]);
if (state.phase === 'complete') finish();
}
+10 -6
View File
@@ -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,13 +289,14 @@ 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,
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() {
+69 -11
View File
@@ -17,9 +17,13 @@ import {libraryFilters,libraryViewState} from './saved-views.mjs';
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
import {mountLibrarySelection} from './library-selection.mjs';
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome} from './library-delete.mjs';
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome,reviewPromptDelete} from './library-delete.mjs';
import {createMoreMenu} from './more-menu.mjs';
let shelfVisit=null;
// #512: what a prompt page says when its prompt no longer exists (404).
const PROMPT_GONE='This prompt no longer exists. It may have been deleted.';
const HISTORY_DESCRIPTION='Prompt history · one positive, one shared gallery';
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
let writeGeneration=0, focusHeadingOnMount=false, savedParentId=null;
// The pair one reconciled save names, taken once by the history it opens (the concept selects it after a save, app.js 986).
@@ -59,8 +63,9 @@ function headingActions(main,fallback) {
}
/** The shell heading's description (concept `p.muted`); a view mounted without one shows it at the top of `fallback`. */
function headingDescription(main,fallback,text) {
const description=pageHeading(main)?.description;
if(description) description.textContent=text;else fallback.prepend(el('p',text,'muted'));
let description=pageHeading(main)?.description;
if(description) description.textContent=text;else {description=el('p',text,'muted');fallback.prepend(description);}
return description;
}
/** A confirmed receipt makes cached shelf items, cursor and measured heights stale; filters and anchor survive. */
@@ -194,12 +199,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 +416,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.';
@@ -481,10 +490,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history);
const body=el('div');history.append(body);
// Concept heading (app.js a591–597): ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
headingDescription(main,history,'Prompt history · one positive, one shared gallery');
const description=headingDescription(main,history,HISTORY_DESCRIPTION);
// #524 D164: at desktop widths a long title clamps beside the actions.
pageHeading(main)?.heading.classList.add('prompt-page-heading');
const actions=headingActions(main,history);
const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send');
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send);
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;
// D163 (#512, maintainer 2026-10-29): a "⋯" More menu after Send to ComfyUI holds Delete prompt…, so delete is
// findable on the page; Edit metadata keeps its own Delete prompt… (D161).
const moreMenu=createMoreMenu({scope,label:'More actions for this prompt',items:[{label:'Delete prompt…',danger:true,onSelect:()=>deletePrompt(moreMenu.button)}]});
actions.append(back,edit,send,moreMenu.element);
let blocks,galleryScope,detail,contentScope,updateBranches,updateParent,current=null;
scope.cleanup(()=>{blocks?.dispose();galleryScope?.dispose();contentScope?.dispose();});
// Every visit opens on the first negative, as the concept's navigate() does (app.js 1149: state.variant = 0);
@@ -492,9 +507,39 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
let pendingSelection=takeSavedSelection(positiveId);
if(selectedPairs.get(positiveId)?.id!==pendingSelection) selectedPairs.delete(positiveId);
const loader=createLibraryDetail({api,onChange:render});scope.cleanup(()=>loader.dispose());
const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView});
const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView,
onDeletePrompt:()=>deletePrompt()});
/** #481: this prompt through the Library's Delete… review; a delete returns to the shelf, an archive shows its notice. */
let deleting=false;
// While the review or its write runs, Edit metadata is held (as the selection bar holds its actions), so no save
// can race the delete; aria-disabled keeps it focusable for the return.
function holdEdit(held) {
deleting=held;
for(const button of [edit,moreMenu.button]) {
if(held) button.setAttribute('aria-disabled','true');else button.removeAttribute('aria-disabled');
}
}
function deletePrompt(trigger=edit) {
const shown=detail;if(deleting || !shown?.positive) return;
// One review at a time, as the selection bar holds itself (a second press during the preview starts nothing).
holdEdit(true);
reviewPromptDelete({api,scope,trigger,ids:[shown.positive.id],epoch:shown.library_epoch,
titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(trigger.isConnected) trigger.focus({preventScroll:true});},
onSettled:(outcome,result,failure)=>{
holdEdit(false);
if(outcome.committed) invalidateLibraryAfterWrite({kind:'bulk-delete',receipt:result ?? {phase:'unconfirmed'}});
// A left page still invalidates what it cached, but says nothing (as the bar's write).
if(scope.signal.aborted) return;
showWorkspaceToast(outcome.toast);
// Restored storage: the whole Library view reads again, as the editor's own restore path.
if(failure?.code==='storage-changed') {reloadView();return;}
if(result && Number.isSafeInteger(result.deleted) && result.deleted>0) {focusHeadingOnMount=true;window.location.hash='#/library';return;}
if(outcome.reload) {detail=null;loader.load(positiveId);focusHeading(main);}
}});
}
scope.listen(back,'click',()=>{window.location.hash='#/library';});
scope.listen(edit,'click',()=>{
if(deleting) return;
const draft=metadataDrafts.get(positiveId);metadataDrafts.delete(positiveId);
editor.open({mode:'metadata',positiveId,pairId:current?.activePairId() ?? null,trigger:edit,draft:draft || null});
});
@@ -539,6 +584,19 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
if(detail && state.detail && !state.loading && !state.error) {detail=state.detail;updateParent();updateBranches(state);return;}
contentScope?.dispose();contentScope=createScope();detail=null;updateBranches=null;updateParent=null;current=null;send.disabled=true;
blocks?.dispose();blocks=null;galleryScope?.dispose();galleryScope=null;body.replaceChildren();
// #512: a prompt that no longer exists (deleted, or a stale link) says so, with the way back; no Retry.
// Edit metadata and ⋯ act on a loaded prompt only (review M2).
const hadFocus=[edit,moreMenu.button].includes(document.activeElement);
const loaded=!state.loading && !state.error;edit.disabled=!loaded;moreMenu.setDisabled(!loaded);
// #524: a prompt that no longer exists has no history to describe.
const gone=state.error?.status===404;description.textContent=gone?'':HISTORY_DESCRIPTION;description.hidden=gone;
if(gone) {
const message=el('p',PROMPT_GONE,'library-status');message.setAttribute('role','status');body.append(message);
const open=createButton('Open the Prompt library');contentScope.listen(open,'click',()=>{window.location.hash='#/library';});body.append(open);
// A focused control that was just disabled hands focus to the way back (review M3).
if(hadFocus) open.focus({preventScroll:true});
return;
}
if(state.loading || state.error) {
const message=el('p',state.loading?'Loading prompt history…':'Prompt history could not be loaded.','library-status');message.setAttribute('role',state.error?'alert':'status');body.append(message);
if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return;
@@ -689,7 +747,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const updateMeta=()=>{imagesMeta.textContent=countOf(detail.positive.image_count ?? 0,'image');variantsMeta.textContent=countOf(detail.positive.variant_count ?? 0,'negative variant');};
updateParent=()=>{
const name=displayTitle(detail.positive);
main.querySelector('h1').textContent=name;categoryChip.textContent=detail.positive.category || 'Uncategorized';updateMeta();
const h1=main.querySelector('h1');h1.textContent=name;h1.title=name;categoryChip.textContent=detail.positive.category || 'Uncategorized';updateMeta();
send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${name}`);blocks?.setTitle(name);
};
let activePairId;
+85
View File
@@ -0,0 +1,85 @@
import { createButton } from '../shared/toolkit/core.js';
/**
* A "⋯" (More) menu button for a page heading (D163, #512; the concept has no menu, so it uses the concept's button
* and surface styles). The button carries `aria-haspopup="menu"` and `aria-expanded`; Enter, Space or ArrowDown opens
* the menu on its first item, ArrowUp on its last; ArrowUp/ArrowDown/Home/End move between items; Escape closes it and
* returns focus to the button; Tab, an outside pointer press or choosing an item closes it. A held button
* (`aria-disabled`) or a disabled one opens nothing. Listeners live on `scope`.
*
* `items`: `[{ label, danger, onSelect }]`. Returns `{ element, button, setDisabled(disabled) }`.
*/
let menus = 0;
export function createMoreMenu({ scope, label, items }) {
const id = `more-menu-${++menus}`;
const element = document.createElement('div');
element.className = 'more-menu';
const button = createButton('⋯');
button.classList.add('more-menu-button');
button.setAttribute('aria-label', label);
button.setAttribute('aria-haspopup', 'menu');
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-controls', id);
const menu = document.createElement('div');
menu.className = 'more-menu-list';
menu.id = id;
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', label);
menu.hidden = true;
const entries = items.map(item => {
const entry = createButton(item.label, item.danger ? 'danger' : '');
entry.setAttribute('role', 'menuitem');
entry.tabIndex = -1;
scope.listen(entry, 'click', () => { close(false); item.onSelect(); });
return entry;
});
menu.append(...entries);
element.append(button, menu);
const isOpen = () => !menu.hidden;
const held = () => button.disabled || button.getAttribute('aria-disabled') === 'true';
function open(index) {
if (held()) return;
menu.hidden = false;
// Right-aligned under the button; where the heading wraps it to the left edge (390px), it opens rightwards.
menu.style.left = menu.style.right = '';
if (menu.getBoundingClientRect().left < 0) Object.assign(menu.style, { left: '0', right: 'auto' });
button.setAttribute('aria-expanded', 'true');
entries.at(index)?.focus();
}
function close(refocus) {
if (!isOpen()) return;
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
if (refocus) button.focus();
}
function move(step) {
const at = entries.indexOf(document.activeElement);
entries[(at + step + entries.length) % entries.length].focus();
}
scope.listen(button, 'click', () => (isOpen() ? close(true) : open(0)));
scope.listen(button, 'keydown', event => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
open(event.key === 'ArrowDown' ? 0 : -1);
});
scope.listen(menu, 'keydown', event => {
const keys = { ArrowDown: () => move(1), ArrowUp: () => move(-1), Home: () => entries[0].focus(),
End: () => entries.at(-1).focus(), Escape: () => close(true) };
if (event.key === 'Tab') { close(false); return; }
if (!keys[event.key]) return;
event.preventDefault();
keys[event.key]();
});
// Focus leaving the menu (a screen reader's cursor, a script) closes it too.
scope.listen(element, 'focusout', event => { if (event.relatedTarget && !element.contains(event.relatedTarget)) close(false); });
// An outside press closes it; capture, so a press that a page control stops still counts.
const outside = event => { if (isOpen() && !element.contains(event.target)) close(false); };
document.addEventListener('pointerdown', outside, true);
scope.cleanup(() => document.removeEventListener('pointerdown', outside, true));
return {
element,
button,
setDisabled(disabled) { button.disabled = disabled; if (disabled) close(false); },
};
}
+17 -11
View File
@@ -87,15 +87,6 @@ export function resolveRoute(hash) {
return routes.find(route => hash === `#/${route.id}`) || routes[0];
}
export function summarizeStats(stats) {
return [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]
.map(([label, key]) => {
const count = stats?.[key];
if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid library statistics');
return [label, count];
});
}
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the
// Favorites page's own listing, so its count is what that page lists.
const NAV_COUNT_SOURCES = Object.freeze([
@@ -129,9 +120,12 @@ export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']);
/** The count a review decision or a finished metadata scan can change. */
export const REVIEW_WRITE_COUNTS = Object.freeze(['review']);
/** Job kind -> the counts re-read once when such a job ends (a scan feeds Needs review; #501: a delete hides images). */
/**
* Job kind -> the counts re-read once when such a job ends: a scan feeds Needs review and can add prompts, and since
* #498 "Return images to the library" (a scan) shows removed images again; #501: a delete hides images.
*/
export const JOB_END_COUNTS = Object.freeze({
scan: REVIEW_WRITE_COUNTS,
scan: Object.freeze(['review', 'library', 'images']),
image_delete: Object.freeze(['images', 'favorites', 'review']),
});
// Trailing delay that coalesces quick star toggles into one Favorites count read (#249 M1).
@@ -147,6 +141,18 @@ export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_C
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
}
// #547: each Overview card is the count of the page it names, the same number as that page's sidebar badge.
const OVERVIEW_CARDS = Object.freeze([['Prompts', 'library'], ['Images', 'images'], ['Tags', 'tags']]);
export const OVERVIEW_COUNT_ROUTES = Object.freeze(OVERVIEW_CARDS.map(([, id]) => id));
/** Overview's state from the shell's counts: no entry is a read not settled yet, null a read that failed. */
export function summarizeOverviewCounts(counts) {
const cards = OVERVIEW_CARDS.map(([label, id]) => [label, counts.get(id) ?? null]);
const pending = OVERVIEW_COUNT_ROUTES.some(id => !counts.has(id));
const failed = OVERVIEW_COUNT_ROUTES.some(id => counts.get(id) === null);
return { cards, pending, failed, empty: !pending && cards.every(([, count]) => count === 0) };
}
/** Fired on the window to render the current route again, where setting the same hash fires no `hashchange`. */
const ROUTE_REFRESH_EVENT = 'prompt-manager:route-refresh';
+27 -9
View File
@@ -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 })) {
@@ -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();
@@ -514,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);
@@ -524,6 +531,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)));
},
};
}
+23
View File
@@ -482,3 +482,26 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
.tagging-modal .tag-action-notice { flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 0; color: var(--ca-text); font-size: 12px; /* D147 */ }
.tagging-modal .tag-action-notice > span { flex: 1 1 16em; }
.tag-image-preview.tag-preview-empty { display: grid; place-items: center; padding: 8px; text-align: center; font-size: 12px; color: var(--ca-muted); background: var(--ca-field); box-sizing: border-box; /* D147 */ }
/* D163 (#512): the prompt page's "⋯" More menu. The concept has no menu; it uses the concept's button, surface, border
and toast shadow, opens under its button, right-aligned so it stays inside a 390px viewport. */
.more-menu { position: relative; display: inline-flex; }
.more-menu-list {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; display: flex; flex-direction: column; gap: 4px;
min-width: 160px; padding: 6px; background: var(--ca-surface); border: 1px solid var(--ca-border); border-radius: 6px;
box-shadow: 0 8px 35px #0004;
}
.more-menu-list[hidden] { display: none; }
.more-menu-list > button { justify-content: flex-start; text-align: left; width: 100%; white-space: nowrap; }
/* D164 (#524): the prompt page has four heading actions and titles of any length, which the concept never shows. From
the concept's sidebar breakpoint up the actions keep one row and the title takes what is left, clamped to two lines
(its full text stays in the DOM and in `title`). A short title lays out exactly as the concept's. */
@media (min-width: 951px) {
.heading.prompt-page-heading > div:first-child { min-width: 0; }
.heading.prompt-page-heading > .heading-actions { flex: 0 0 auto; }
.heading.prompt-page-heading h1 {
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
overflow: hidden; overflow-wrap: anywhere;
}
}