Compare commits

..
Author SHA1 Message Date
Vito Sansevero 7e9fa0682b Merge remote-tracking branch 'origin/main' into fix/451-masonry-default 2026-10-28 18:59:57 -07:00
Vito 02190d03a8 feat(library): add tags to many selected prompts and select every loaded prompt (#442) (#453)
* 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

* docs(library): record Add tags and select-all as D151 (#442)
2026-10-29 01:30:58 +00:00
Vito 40dc0e5417 feat(collections): add to and remove from collections in the selection bar and the inspector (#331, PR 3) (#435)
* feat(saved-views): keep a collection in a saved view

Images saved views gain a collection key (#331 D140): empty or a collection
id of 1 to 9 digits. Writes refuse favorites together with a collection;
older rows read an empty collection, and a stored row with both opens the
collection, as the listing does for a URL with both.

Part of #331

* feat(collections): store collections in the library database

* feat(collections): add, remove and rename collections and keep members through duplicate cleanup

* feat(collections): add collection routes and the Images collection filter

* feat(images): store images' own categories in the library database (#332)

One new table, image_categories, and its category index, created empty
by a forward-only migration that reads and writes only its own objects.
Startup checks the structure; restore also checks the values. The store
sets, clears and reads own categories by file key (the deepest output
source holding the file and the path below it), and copies a removed
duplicate's category to a kept copy that has none.

* feat(images): duplicate cleanup keeps images' own categories (#332)

The kept copy's own category wins; a kept copy without one receives the
removed copy's, keyed by the deepest output source holding each file.
The removed copy's row is never deleted, so a give-back or a failed
delete loses nothing. The job log reports how many were kept.

* feat(images): set, remove and list images' own categories (#332)

PUT /prompt_manager/images/category sets the own category of up to 500
listed files, DELETE removes it (listed or not), and
GET /prompt_manager/images/categories lists the values whose file is
listed. Targets are the Images listing identity of an output source,
checked by media_target (shared with Collections). The routes never
touch prompts, recipes or links; POST /prompt_manager/bulk/category is
unchanged.

* fix(images): key symlinked output folders and keep key work off the event loop (#332)

Review fixes: a source matches by its configured spelling too, so a
detected output folder that is a symlink keys its files (routes and
duplicate cleanup). File keys are computed in the executor. A path no
source holds is a plain 403, a lone surrogate a 400. The install repairs
a table that lacks its category index instead of refusing to open.

* fix(collections): address review findings

* feat(images): set or remove a category on all N matching images (#332)

The category routes also accept the Images listing filter of "Select
all N matching" (#392) instead of explicit items, resolved now and
capped at 500 images like an explicit selection.

* feat(collections): browse a collection from the sidebar

The sidebar gains the concept's COLLECTIONS group (D136): one row per
collection with its folder icon, name and listed-member count, without the
PROPOSED badge; the label shows once a collection exists. A row opens
#/collection/<id>, the Images page filtered to that collection (D137): the
collection's name as h1, Collection as breadcrumb and title, Edit to rename
or delete it through the saved views' Edit dialog with collection nouns, and
a note counting members the sources do not list now. An unknown or deleted
collection shows a page that says so with a link to Images. A saved view on
a deleted collection lists nothing and says so (MC6, D140). The list is read
again after a restore and the counts 300 ms after a change.

Part of #331

* test(collections): compare the COLLECTIONS group with the concept

The concept hides COLLECTIONS at 950 px and below, which would leave a phone
no way to open a collection; production shows the rows there in the wrapped
layout of every other sidebar nav (D136). The shell comparison now compares
the group instead of filtering it out: the concept fixture seeds Landscapes
(12) and Experiments (8), the rows compare by name order, and at phone width
the nav compares against the concept's own sidebar-nav layout. The
collection rows are checked at 1600x1100 and 390x844 in both themes.

Part of #331

* test(vendor): count the #332 host case in the execution matrix

image-attribution.spec.mjs gains the image category route case, so the
execution config lists 92 tests (3 attribution cases per project).

* test(browser): install the slideshow clock before wrapping setTimeout

The slideshow fixture wrapped window.setTimeout before page.clock.install(),
so the viewer's timer ran on real time and clock.runFor() did not drive it;
a late WebKit timer failed 'slideshow exposes exact state labels' on CI.

* feat(collections): add and remove from the selection bar

Add to collection follows Set category in the Images selection bar and
opens the concept's simpleInput ("Add selected images to collection",
"Collection name", Add) with the existing names as a datalist. Remove
from collection follows it while a collection filter is active and
reloads the listing in place, keeping the checked files still listed.
Every outcome uses the design's strings (section 7.4). The routes take
at most 500 explicit files, so an "all N matching" selection is sent
only when every match is loaded, checked and at most 500; otherwise the
action toasts "Select at most 500 images." (D138, #331).

* fix(collections): address review findings

- A list read superseded by a newer one (the 300 ms count read, a restore)
  no longer shows "could not be loaded": the collection and saved-view
  pages wait for the list to settle.
- A listing that finds the open collection gone shows the deleted page once
  the list no longer holds it; when the list read fails or still holds it,
  the page keeps the listing's error and Retry instead of staying blank, and
  each failed read is reported again.
- The missing-members note follows every list read and goes away at zero.
- A rename that settles after its Edit dialog closed leaves any dialog
  opened since alone.
- The deleted-collection, list-failed and saved-view failure pages focus
  their title after a route change, as every page does.

Part of #331

* feat(collections): add and remove from the inspector and the viewer

The shared inspector's footer ends with Collections… (folder icon) after
Prompt history for a listed file, in the Images side panel and the
viewer. Its dialog reads the file's membership, lists every collection
as the concept's checkbox rows, takes an optional new name, and Save
sends one request per change (new name, added, removed). A failure keeps
the dialog with the membership read again. On a collection page a change
to the open collection reloads the listing at once from the side panel
and only when the viewer closes from the viewer. The inspector concept
comparison draws the button into the concept after Prompt history (D139,
#331).

* test(collections): compare the collection page and dialog with the concept

images-concept compares Add to collection in place in the selection bar
(only Remove links… stays omitted) and the collection page's heading,
toolbar and filter row against the concept's #collection-landscapes,
with D137's Edit drawn into the concept. collections-concept compares
the Add to collection dialog with the concept's simpleInput (D138:
"Add", an empty field, the datalist of names), in both themes at 1600
and 390 px, with a mutation check.

TESTING.md gains the Collections contract (database, keys, missing
members, duplicate move, restore, routes, rules, limits, the PR 1
rulings and the all-matching ruling), D136-D140, and amends D28 and D8;
docs/application-redesign.md gets the delivery-status line. The coverage
baseline adds the two new modules; optional collection callbacks are
null rather than no-op defaults so they do not count as uncovered
functions.

* test(coverage): keep main's floors for files this PR does not touch

coverage:update raised nine floors of untouched files from one run;
metadata-autocomplete.js branches then measured 84.84 against 85.

* fix(collections): address review findings

- M1: after a failed Save the Collections dialog shows the membership
  loading and keeps Save disabled until the re-read lands, so a stale
  plan cannot be sent.
- M2: the changes a Save made reach the page only when the dialog
  closes, so a collection page's reload cannot remove the dialog
  before its failure is read.
- L4: a New collection name equal to a listed collection ignoring case
  is that collection's checkbox, checked; never an add then a remove.
- L5: a name that cannot be a collection name is refused on the field;
  nothing is sent, the checkbox edits stay, no re-read.
- L6: focus starts on the field and moves to the first checkbox once
  listed; closing after a re-render replaced the trigger focuses the
  inspector's current Collections… button or its heading
  (openContentDialog takes an optional focusTarget).
- L3, L7: TESTING.md D138/D139 describe the stale-source and deleted
  collection outcomes and the above; the viewer spec asserts no listing
  request while it is open, and a browser test covers M2.

* test(legacy): record the image category routes in the legacy contract snapshot (#332)

#332 adds PUT/DELETE /prompt_manager/images/category and
GET /prompt_manager/images/categories; the legacy pages' routes are
unchanged.

* test(duplicates): a removed copy with a collection and a category moves both (#331, #332)

* test(collections): map the Add to collection suggestions to the shared dialog's datalist (#331)

After the merge with #332 PR 3 the input dialog places its datalist after the
field with the id workspace-input-suggestions; the concept mapping follows.
2026-10-29 01:14:20 +00:00
Vito Sansevero 8b12e95bc6 fix(images): open Masonry by default, keep a saved Default layout, crop portraits from the top (#451)
- A Default layout or Thumbnail size saved in System now reaches the next Images
  visit in an open tab (this tab or another): the last visit records the
  defaults it started from, and a value its toolbar never changed follows newer
  defaults. A toolbar choice stays the current view's own.
- Images opens in Masonry when the browser has no stored Default layout
  (gallery-settings.js DEFAULT_SETTINGS.workspaceLayout); stored choices are kept.
- Grid/List card art, Library card art, the inspector cover and the Tag image
  preview crop with object-position 50% 20%, so portrait images keep their top.
- D157 in TESTING.md; concept comparisons start from the concept's stored Grid.
2026-10-28 17:56:10 -07:00
Vito ac378f07d3 feat(library): set the category on many selected prompts (#437) (#446)
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
2026-10-29 00:31:40 +00:00
Vito 827de84f40 fix(workspace): keep an open dialog when a restore it saw finishes (#415) (#441)
When the page's Activity store saw a restore run (its 15 s job-list poll
landed inside it), the S9 reload on finish re-rendered the open page and
closed any open dialog, dropping what was typed without notice. Whether
that happened depended on the poll's timing.

Now the S9 reload waits while any dialog is open and runs once the last
one closes (createRestoreReload, a MutationObserver on dialog[open]), so
the dialog keeps its entries and its next write meets the existing
restored-storage refusal with Reload, as when the page never saw the
restore. An open record-changing review is the exception: its staged plan
no longer describes storage, so it closes as dismissed, as before
(closeReviewDialog).

Tests: followRestore makes the page see a restore run and finish without
timers; browser cases for Create tag, New prompt and the restore review;
unit tests for the deferral and closeReviewDialog.
2026-10-28 23:31:53 +00:00
Vito 9eb5e9453d docs(redesign): record Copy settings summary and Local Inspect PR L3 as shipped (#436) (#440)
* docs(redesign): record Copy settings summary and Local Inspect PR L3 as shipped (#436)

* docs(redesign): cite D117 and fix the stale Task 9 tense in local-resolver.mjs (#436)

* docs(inspect): local-resolver.mjs header says Task 9 shipped (#436)
2026-10-28 23:19:54 +00:00
Vito f5856076ce fix(test): own /dev/shm reset scratch by a flock, not a pid (#419) (#439)
A browser server inside an --ipc=host container shares the host's /dev/shm
but not its PID namespace, so the pid probe saw live servers' scratch folders
as orphans and deleted them. A live owner now holds a flock on owner.lock in
its folder; the kernel drops it when the owner dies, in any namespace. The
cleaner removes only held-named folders whose lock is free and leaves legacy
and staging folders alone. Without flock (Windows) the scratch stays on disk.
2026-10-28 23:12:24 +00:00
Vito 4040007264 fix(workspace): keep open pages valid across a restart and recover a changed source (#430) (#434)
The gallery root scope and the Images listing cursor were keyed with a
per-process random key, so every ComfyUI restart made an open tab's
scopes answer 409 source-changed (blank Tag image preview, a Generate
that did nothing) and its cursors fail Load more.

- utils/workspace_keys.py: one random secret in workspace-scope.key
  beside the database (owner-only, never config.json or the database),
  two derived keys; per-process fallback with a warning. Move library
  copies it.
- Cursor v2 binds the listing scope in its MAC instead of sending an
  unkeyed hash of the root paths; an unverifiable cursor answers 400
  cursor-stale and Images offers Refresh instead of a dead Retry.
- POST /prompt_manager/images/rescope maps a stale target to its
  current root only when the held scope was issued for it.
- Images finds a renumbered file again from the inspector, the viewer
  and the Tag image dialog (the dialog stays open, draft kept); nothing
  else is kept by an old root_index:path key.
- Tag image dialog: a state that blocks Save shows its reason above the
  actions with Refresh, disabled actions look disabled, and an empty
  preview says "Preview unavailable" (D147).
2026-10-28 23:06:36 +00:00
77 changed files with 5814 additions and 202 deletions
+4
View File
@@ -214,3 +214,7 @@ coverage/
# Old library files a Move library renamed (never deleted; #249 S6b)
*.moved-to-*
# Per-install key behind the workspace scopes and cursors (#430)
workspace-scope.key
.workspace-scope.key.*.tmp
+440 -22
View File
@@ -551,6 +551,49 @@ hold a walk open with a gated scan instead of sleeping.
time to network idle and the 10 slowest requests, from a fresh browser
context each run.
## Open pages across restarts and source changes (#430)
A page addresses a listed file as `{root_index, root_scope, path}`. The root
scope is an HMAC over the root's index and canonical path, so the server refuses
(409 `source-changed`) an action aimed at a root that was renumbered or replaced
since the page listed it (a same-named `ComfyUI_00001_.png` sits in every output
folder). Its key, and the listing cursors' key, derive from one random secret in
`workspace-scope.key` beside the database (`utils/workspace_keys.py`; owner-only,
64 hex characters; never in config.json or the database, so no backup or export
carries it; Move library copies it, best effort). A ComfyUI restart therefore keeps
every scope and cursor an open page holds: `tests/test_api_workspace_restart.py`
proves it with a second API on its own server and with two fresh interpreters on
the same database. The key file is created with a temp file and `os.link` (of two
first starts one file wins; without hard links `O_EXCL`), re-read after any write,
replaced with a WARNING when corrupt, and a folder that cannot hold it gives that
process a per-process key (one WARNING; open pages then refresh after a restart).
Tests never write it outside their own temp folders.
The cursor is `{"v": 2, "key"}` with the listing scope (it hashes every absolute
root) bound into its MAC only, never sent. Bump `v` whenever the key tuple's shape
changes: a cursor now outlives a restart and an upgrade. A cursor the server
cannot verify (another key, root list or filter set) answers 400
`cursor-stale`; Images then shows "The image collection changed. Refresh image
collection to continue." and its Retry becomes **Refresh image collection**.
When a source really changed, `POST /prompt_manager/images/rescope` (`{"target":
{...}}`, validated by `media_target`, index ≤ 4095) maps the held target to the
first current root whose scope at the old index equals the held scope, and answers
the file's current workspace descriptor; a removed or replaced root, another key
or a foreign scope is 409 `source-changed`, a missing file 404 `file-missing`, a
path leaving the root or a preview 403 (always `no-store`). Only the Images roots
are mapped. Images (`images-source-recovery.mjs`) asks once per held
`(key, scope)` pair, shared by the inspector (an inspection that turns
`refreshRequired` with `code: "source-changed"`), the viewer (a media load error)
and the Tag image dialog. Found under another index, nothing is kept by an old key
(`root_index:path` could name another source's file): the viewer closes, the checks
clear, a Source filter on the old index follows the file (another one falls back to
All sources), only that file stays selected and the collection lists again; a
manual Refresh forgets every pair. Not found, the existing visible Refresh stays.
Browser tests: `workspace-stale-source.spec.mjs` (`/__test__/sources/reverse` and
`/__test__/scope-key/rotate`, both undone by every reset); the manual-Refresh
recovery specs in `workspace-images.spec.mjs` answer every rescope 409.
## Windows portability preflight
Before changing paths, SQLite, streams, restore or cleanup:
@@ -1044,7 +1087,8 @@ Recorded shell deviations:
palette, then the OS.
- **D5**: retired by Activity (#249 Activity-1). The concept's Activity button and
Activity row are shown; their differences are D52 to D54.
- **D28**: no COLLECTIONS, and no Design review. The bottom nav is
- **D28**: no Design review. (Amended by #331: the COLLECTIONS group ships; its rows,
counts and phone layout are D136.) The bottom nav is
the concept's Activity and System, plus the production Overview hub (its own
outline home glyph, not the concept's `activity` pulse, and no count).
The connection reads "Your library, on your server" with a neutral muted dot.
@@ -1352,7 +1396,7 @@ The Images page renders the concept's DOM with real data. Its controls, top to b
(click, Enter, Space); Info selects the card into the side panel; the star toggles
the favorite; arrows, Home and End move between cards. Bounded rendering keeps the
DOM small, and returning to Images restores filters, layout, size, selection and
scroll.
scroll (a layout or size the toolbar never changed follows newer defaults, D157).
- **Continuation** (`#gallery-continuation`): scrolling near it loads the next page;
`Load more images` and `Retry` do the same by hand, with a toast per batch.
- **Viewer** (`dialog#viewer-dialog`): "<title> · <filename>", the concept toolbar
@@ -1386,8 +1430,10 @@ Recorded deviations on this page (spec §8):
"Removed from favorites." and a rollback toast on failure.
- **D8**: every Images card has the concept's select checkbox before the star; the
`#bulkbar` shows "N selected" and only the real actions in the concept's order (Add
tags, then D85's Auto-tag selected, Set category, Export metadata, Clear). Add to
collection waits for collections; "Remove links…" is dropped. (Amended by #249 PR D:
tags, then D85's Auto-tag selected, Set category, Add to collection, Export metadata,
Clear); "Remove links…" is dropped. (Amended by #331: Add to collection ships, and
Remove from collection follows it while a collection filter is active, see D138.)
(Amended by #249 PR D:
Add tags ships, see "Bulk tagging from the selection bar"; #392 adds range, keyboard,
touch and "all N matching" selection, see "Selecting many images", D135.) The selection survives Load more and
bounded re-mounting and is cleared whenever the collection is replaced (search,
@@ -1403,9 +1449,9 @@ Recorded deviations on this page (spec §8):
checked images' own categories: it opens the concept's `simpleInput` dialog
("Categorize selected images", `input#action-input` labelled Category) and writes
`PUT`/`DELETE /prompt_manager/images/category`, never a prompt (see D140). Prompt
categories are changed in the Prompt library editor and on the legacy admin page's
"Set Category"; `POST /prompt_manager/bulk/category` is unchanged and serves that
legacy page only. The write carries no page signal: leaving Images while it runs lets
categories are changed in the Prompt library editor, the Prompt library's selection
bar (D148, #437) and on the legacy admin page's "Set Category"; both bulk ones write
`POST /prompt_manager/bulk/category`. The write carries no page signal: leaving Images while it runs lets
it finish without a toast, and the return loads the collection afresh instead of the
saved visit. Its success reloads the categories and the listing together (clearing
the selection, focus to the heading, and, as any replaced collection does, the
@@ -1636,6 +1682,29 @@ Recorded deviations on this page (spec §8):
`scroll-margin-block-end: 96px`, so it is never under the toast.
`Rescan toast does not cover the discovery alert it leaves` ends the window inside
the toast's band at 1600 and 390 pixels and measures both boxes.
- **D157** (#451, maintainer request, beyond the concept): Images opens in **Masonry**
when this browser has no Default layout (`gallery-settings.js`
`DEFAULT_SETTINGS.workspaceLayout`); the concept starts at Grid (`state.view`, app.js
208). A stored choice is kept, and an invalid value reads as Masonry. Saved views,
favorites, collections and the embedded galleries keep their fixed Grid/Comfortable.
The return to Images (the last visit) keeps a layout or thumbnail size its toolbar
changed; a value it kept from the defaults it started with follows a Default layout or
Thumbnail size saved since, in this tab or another (a toolbar List then Default =
Masonry stays List; a toolbar List then back to Grid counts as untouched). A cover crop
of a generated image (Grid and List card art, Library card art, the side panel's and
History's inspector cover, the Tag image preview) uses `object-position: 50% 20%`
instead of the concept's centre: it acts only when the image is taller than its box
(portraits keep heads and faces), and a wider image stays centred. Masonry shows the
whole image and keeps the centre. The concept comparisons (`openProduction`) and the
specs of the Grid's own geometry start production from the concept's state, a stored
Default layout of Grid (`startInGrid`, concept-inventory.mjs, only while the browser
stored nothing); the comparisons do not measure `object-position`.
`a fresh browser opens Images in Masonry (#451)`, `a Default layout saved later reaches
the next Images visit in this tab (#451)`, `... in another tab ... (#451)` and `cover
crops keep the top of a portrait image; Masonry stays centred (#451)`
(workspace-system.spec.mjs) cover it. A browser whose legacy gallery wrote the old
default (`"grid"`) keeps Grid until Default layout is changed or "Reset display
defaults" is saved.
`tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's
full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes:
@@ -1697,10 +1766,11 @@ wins), and toasts the mock's copy verbatim, "<n> tags saved for <filename>." eve
for one tag, plus " Prompt association still awaits review." from Needs review.
Tagging one file never changes another file (an equal-byte copy included), a
prompt, a pair or a link. States the mock lacks (generating, busy, conflict,
changed storage or file, expired capture, unconfirmed save, unavailable model,
video) are text in `#tag-generation-status` only; **Retry** (D116) leads the
actions while the controller allows it, where the other workspace dialogs place
their Retry. The Tags
unavailable model, video) are text in `#tag-generation-status` only; a state that
blocks Save (a failed or refused capture, a changed source, file or storage, an
expired capture, an unconfirmed save) shows its reason beside the actions instead
(D147); **Retry** (D116) leads the action buttons while the controller allows it,
where the other workspace dialogs place their Retry. The Tags
page re-reads its lists after every confirmed save, so the Images counts follow.
The image-tagging plan's deviation numbers were renumbered by +23 because the
@@ -1744,8 +1814,8 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
its "Auto-tag images" always opens the dialog. Production opens it for the image
selected in Images when it was last left; with none it is `aria-disabled` and
pressing it toasts "Select an image in Images first." (no new chrome).
- **D116** (ruling T5): **Retry** leads `.dialog-actions` (before Cancel and the
primary) while a failed observation, a failed Generate, an unconfirmed save or an
- **D116** (ruling T5): **Retry** leads the buttons of `.dialog-actions` (before
Cancel and the primary; D147's notice row sits above them) while a failed observation, a failed Generate, an unconfirmed save or an
answer that needs a fresh observation (changed storage or file, expired capture)
can be retried; the concept has no failure states, so it has no Retry. After a
failed Generate it takes Generate's own path (an invalid field reports its
@@ -1756,6 +1826,25 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
Browser tests: "D116: Retry leads the actions after an unconfirmed save…", "D116:
Retry after an expired capture observes again and keeps the typed draft (M5)" and
the paired `production-unknown-outcome` capture.
- **D147** (#430, beyond the Global Constraints' "status text only"): a state
that blocks Save shows its reason in `div.tag-action-notice` (its sentence in a
`role="status"` span, written only when it changes), the first, full-width row of
the sticky `.dialog-actions`, above the buttons; `#tag-generation-status` is then
empty, so the sentence shows once. Generate and Save are `aria-disabled` and look
it (`.tagging-modal [aria-disabled="true"]`: opacity .55, `not-allowed`). A failed
or refused capture replaces the empty preview with a same-size
`div.tag-image-preview.tag-preview-empty` "Preview unavailable" (`role="img"`).
A changed source (409 `source-changed` from observe, Generate or Save, phase
`source-changed`, never retried as is) first asks the page where the file is
(`onSourceChanged`, see "Open pages across restarts and source changes"): found,
the dialog stays open and observes it there (`controller.retarget`, the typed
draft kept, the old capture released; refused while a save is in flight); not
found, the notice shows the server's sentence and **Refresh image collection**
(Images and Library-embedded galleries; **Reload page** elsewhere), which closes
the dialog first. Opened, generated and empty states are unchanged, so the concept
comparisons are not. Browser tests: `tests/browser/workspace-stale-source.spec.mjs`;
the file-changed and unconfirmed-save checks in `workspace-tagging.spec.mjs` and
`tagging-concept.spec.mjs` read the notice.
- **D29** (no prototype copy) in the dialog: no "Prototype · suggestions are
simulated…" note, the status reads "Suggestions from <model>. Approve tags to save."
(not "Sample suggestions from …"), and the toast has no " in this tab".
@@ -1808,7 +1897,8 @@ production-versus-concept difference of PR C and its record:
| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
| Auto-tag images `aria-disabled` without a selected image | D115 |
| Retry leads the actions when the controller allows it | D116 (ruling T5) |
| States the concept lacks (busy, conflict, changed storage or file, unconfirmed save, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
| States the concept lacks (busy, conflict, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
| A state that blocks Save (failed capture, changed source, file or storage, unconfirmed save): its reason and Refresh beside the actions, a "Preview unavailable" box | D147 (#430) |
### Bulk tagging from the selection bar (#249 PR D)
@@ -1850,8 +1940,9 @@ targets of the longest legal path (1024 code points, even escaped as `\uXXXX`) f
Auto-tagging." (the server's own sentence), so no job starts only to fail.
Start validates the fields as Generate does (native validity; nothing is sent).
The concept comparison (`images-concept.spec.mjs`) uses the concept page itself as
the oracle, with Add to collection and Remove links… removed and a copy of its Add
tags button reading "Auto-tag selected" inserted after it.
the oracle, with Remove links… removed and a copy of its Add tags button reading
"Auto-tag selected" inserted after it (Add to collection compares in place since
#331, D138).
- **D90** (plan D67, ruling T3-bulk): the dialog's **How to apply** radio group
(`.field[role=radiogroup]`, radios beside their labels): **Apply automatically**
(checked; primary "Start auto-tagging") starts the `image-tags` job
@@ -1942,7 +2033,7 @@ busy". Units:
| Difference | Record |
| --- | --- |
| Add tags ships (Add to collection and Remove links… still omitted) | D8 (amended) |
| Add tags ships (Remove links… still omitted; Add to collection ships with #331, D138) | D8 (amended) |
| Auto-tag selected and its dialog | D85 (ruling L1) |
| How to apply and the Review each image session | D90 (ruling T3-bulk) |
| Suggestions count in the bulk dialog | D91 |
@@ -2049,6 +2140,235 @@ npm run --prefix tools/vendor test:workflows -- workspace-tagging-lifecycle.spec
--project firefox --project webkit
```
### Collections contract (#331)
A collection is a named, hand-picked group of images (milestone 7 "Additional
organization"). It never copies, moves or changes a file, a prompt, a tag, a category
or a favorite. Legacy pages have no collections, so the legacy coverage table gains no
row.
**Database** (`database/image_collections.py`, mixed into `PromptDatabase`):
- `image_collections(id, name, name_key UNIQUE, created_at)` and
`image_collection_items(collection_id → image_collections ON DELETE CASCADE,
root_key, relative_path, added_at, PRIMARY KEY (collection_id, root_key,
relative_path))`, plus `idx_image_collection_items_file(root_key, relative_path)`.
`migrate_image_collections` runs right after the saved-views migration inside its own
savepoint, creates the three objects empty with no backfill, changes no existing
table and reads only `sqlite_master` and its own tables, so its cost does not depend
on library size (checked with an SQLite authorizer on a 23,000-image library; a
second run is a no-op). Startup validates structure only; restore runs the full value
check.
- A name is stored as typed after normalization: trimmed, each run of whitespace one
space, 1 to 100 characters, no control characters (Unicode `Cc`). `name_key` is
`NFC(casefold(NFC(name)))`, so names are unique ignoring case and Unicode form (a
macOS NFD name matches the Windows NFC one). Restore checks `name_key` only as
non-empty bounded text and does not recompute it: Python's Unicode case tables differ
between versions, so a backup made on another Python must still restore.
- A member names a file by location, exactly as a favorite does: the source folder's
real path (`_favorite_root_key`) plus the path below it (`_favorite_relative_key`),
case-folded on Windows, each at most 4,096 characters. A case-only rename keeps a
Windows member; two Linux files that differ only by case are two members; on macOS a
case-only rename hides the member, as it hides a favorite. The Windows folding is
tested on every OS through `ntpath`.
- Each write is one `BEGIN IMMEDIATE` transaction, so two tabs adding the same new
name create one collection. A collection is created only when at least one file is
inserted.
**Missing members (MC2, MC3):** a member whose file is moved, renamed or deleted
outside PromptManager, or whose source folder is offline, is hidden and its row kept;
it shows again when a file appears at the same path (which may be a different image,
as with favorites). Nothing follows a moved file, and there is no "forget missing"
cleanup; deleting the collection removes those rows.
**Duplicate cleanup (MC4):** System › Maintenance duplicate files moves the removed
copy's memberships to the kept copy as a union, in the transaction that moves its
favorite and image tags, and the job log reads "Moved N collection membership(s) to the
kept copies". The legacy gallery page's duplicate delete does not move them (as for
favorites).
**Restore (MC8):** `collections` joins the restore stage's summary keys and
`image_collections`'s `MAX(rowid)` its fingerprint; membership edits are not
fingerprinted, as favorite and tag edits are not. A backup made before collections
restores with no collections (the tables are created empty); the pre-restore safety
backup keeps the old ones. The restore review shows no collections count.
**Routes** (`py/api/collections.py`; every answer `{"success": bool, ...}`):
| Route | Request | Success |
| --- | --- | --- |
| `GET /prompt_manager/collections` | none | `{collections: [{id, name, count, missing}]}` in name-key, then id, order |
| `POST /prompt_manager/collections/items` | `{collection_id}` or `{name}`, and `items` | `{collection: {id, name}, created, added, already, not_found}` |
| `DELETE /prompt_manager/collections/items` | `{collection_id, items}` | `{collection: {id, name}, removed}` |
| `GET /prompt_manager/collections/membership` | query `root_index`, `root_scope`, `path`, each once | `{collection_ids: [...]}` |
| `PATCH /prompt_manager/collections/{id}` | `{name}` | `{collection: {id, name}}` |
| `DELETE /prompt_manager/collections/{id}` | none | `{deleted: true}` |
- Ids are `[1-9][0-9]{0,8}` (path) or that int (body); anything else, or a body with
both or neither of `collection_id` and `name`, is 400.
- `items` holds 1 to 500 targets `{root_index, root_scope, path}`, each checked by the
shared `py/api/media_target.py` (the image-annotation routes use the same check): 400
for a malformed shape, 403 for a traversal, drive, backslash or non-media path. A
`root_scope` that no longer names its root is 409 `source-changed` ("The sources
changed. Refresh the images and try again."). Any invalid item refuses the request
before a write; two targets that fold to one key count once.
- Add keeps only files the source's listing holds and counts the rest in `not_found`;
a new name with nothing inserted is 404 `files-not-found` and creates nothing. A name
matching an existing collection ignoring case adds to it. Remove accepts any
well-formed target, listed or not.
- An unknown id is 404 `collection-not-found`; a rename to another collection's name is
409 `name-taken` (a case variant of its own name is allowed). Any other failure is a
logged 500 with "Collections could not be read." or "The collection could not be
changed.", never the exception text.
- Counts: `count` is the members the current listing of every configured source shows
(a folder configured twice counts once), `missing` the stored rows beyond it,
clamped at 0. Both come from one read snapshot of every member and stored count, so a
write between two reads cannot skew them. When any source's scan or read fails, both
are `null` for every collection and the names still return.
- Every database read and every filesystem call (realpath, the scans) runs in the
executor; nothing touches the filesystem on the event loop.
- The Images listing (`/prompt_manager/images/output?mode=workspace`) accepts
`collection` (`""` or an id) among `LISTING_FILTER_KEYS`; the cursor's scope hash
includes it, so a cursor from one collection is refused on another. An unknown
collection is 404 `collection-not-found`, checked before any per-source read; a
failed members read is the listing's 500, never an unfiltered page. A collection and
`favorites=1` are never both applied (the collection wins).
**Limits (MC12):** names 1 to 100 characters; at most 500 files per add or remove; no
limit on the number of collections or members.
**Selection bar and "all N matching" (#331 PR 3 ruling):** the collection routes take
explicit files only (no listing filter). With "All N matching selected" (D135), Add to
collection and Remove from collection send the loaded images only when every match is
loaded and checked and there are at most 500; otherwise they toast "Select at most
500 images." and open nothing. Over 500 explicitly checked files toast the same.
Recorded deviations:
- **D136** (amends D28): the concept's COLLECTIONS group (`#collections-nav`,
index.html 28–31) without the "PROPOSED" badge. One `links()` row per collection
(folder icon, name, `.nav-count`) in name-key order, linking `#/collection/<id>`. The
count follows the nav-count rules of D51 (hidden until loaded, absent when it could
not be read, never a placeholder) and is re-read 300 ms after the last confirmed add,
remove, rename or delete; the list is re-read after a restore. The label is hidden
while no collection exists. At 950 px and below the concept hides
`#collections-nav`, which would leave a phone no way to open a collection: the rows
show in the wrapped nav (`workspace.css`, `/* D136 */`), the label stays hidden as
every sidebar label is, and the counts are hidden as on every concept nav row at that
width. (Open question for the maintainer: CUJ 15 asks for "with counts" at phone
width; production follows the concept's phone rule and hides them.) A failed list
read keeps the last rows.
- **D137**: the collection page `#/collection/<id>` is Images filtered to the
collection: the collection's name is the h1, "Collection" the breadcrumb and title
(concept a1186–1189), with the concept's Images description and every Images control.
Its heading leads with **Edit** ("Edit collection", "Collection name", Delete…
(danger), Cancel, Save; the saved views' D51 dialog with collection nouns): "Collection
renamed.", a taken name stays in the dialog as "A collection named “X” already
exists.", "The collection could not be renamed. Try again." keeps it open. Delete…
asks "Delete “X”? Its N images stay where they are; only the collection is removed."
(N the stored count) and toasts "Collection deleted." (a 404 counts as deleted),
then opens `#/images`; a failure toasts "The collection could not be deleted. Try
again.". A `p.small` after the description reads "N image(s) in this collection
is/are not in your current sources." when members are missing. An unknown id reads
"This collection no longer exists." with an "Open Images" link; a failed list reads
"Collections could not be loaded. Your saved data has not changed." with Retry. The
page neither restores nor replaces the remembered Images state, and its favorites
filter is off (DC10).
- **D138** (amends D8): **Add to collection** follows Set category and opens the
concept's `simpleInput` ("Add selected images to collection", "Collection name")
with the action "Add" (the concept's "Apply in prototype"), an empty field (the
concept pre-fills its sample "Experiments") of at most 100 characters, and a
`datalist` of the existing names (`#action-input-suggestions`; the field is then a
combobox, and Chromium draws its suggestion arrow inside the field, which the
geometry comparison does not see). An empty field keeps the dialog on it. The toasts: "Added N image(s) to
“X”." plus " N was/were already in it." and " N could not be found.", "All selected
images were already in “X”.", "None of the selected images could be found. Refresh
the images and try again." (404 `files-not-found`), "The sources changed. Refresh
the images and try again." (409; the sources and the listing are read again, which
clears the selection), "That collection no longer exists." and "The images could not
be added. Try again.". Otherwise the selection stays and the listing is not reloaded.
**Remove from collection** (beyond the concept) follows Add while a collection filter
is active (a collection page or a saved view on one), with no confirmation: "Removed
N image(s) from “X”.", then the loaded range is read again in place, keeping the
checked files still listed. "That collection no longer exists." (404, a collection
deleted meanwhile) reads the listing again, which then shows the deleted-collection
page; "The sources changed. Refresh the images and try again." (409) reads the
sources and listing again and clears the selection; "The images could not be removed.
Try again." keeps everything. A second press while it runs is ignored.
- **D139** (beyond the concept): the shared inspector's footer ends with
**Collections…** (the folder icon) after Prompt history, for a listed file only
(`rooted-file` with its scope), in the Images side panel and the viewer's details;
Inspect a file and the Prompt library's galleries have no client store and no
button. Its dialog "Collections": "Loading collections…" while the file's membership
is read ("Collections could not be loaded." with Retry on failure; Save waits), one
concept checkbox row (`label.tag-suggestion`) per collection in sidebar order,
checked where the file is a member ("No collections yet. Name one below." without
any), a "New collection" field (at most 100 characters; a name matching an existing
collection adds to it), then Cancel and Save. Focus starts on the field and moves to
the first checkbox once they are listed. A typed name equal to a listed collection
ignoring case is that collection's checkbox, checked (never an add then a remove); a
name that cannot be a collection name is refused on the field with "The collections
could not be updated. Try again.", nothing is sent and the checkbox edits stay. Save
sends one request per change: the new name, each newly checked collection, each
unchecked one; nothing changed sends nothing and closes. All sent: the dialog closes
with "Collections updated.". A failure keeps it open with "The collections could not
be updated. Try again." and the membership read again, Save waiting until that read
lands; × and Escape wait while it saves. The page hears of the changes that went
through only when the dialog closes (a failure included), so a reload cannot remove
the dialog before its message is read. On a collection page a change to the open
collection then reloads the listing at once from the side panel, and from the viewer
only when the viewer closes, so the viewer keeps its place (no listing request while
it is open). Closing returns focus to the trigger, or, when a re-render replaced it,
to the inspector's current Collections… button or its heading. The
inspector comparison draws the button into the concept's panel footer and full
details after Prompt history (a copy of its Prompt history button with the concept's
folder icon), so the browser lays out what follows.
- **D140**: saved views' Images state gains `collection` (`""` or an id, the `source`
encoding); older rows read `""`, and a write with both `favorites: true` and a
collection is 400. Save view on a collection page stores the id; the view reopens
the collection with its other filters under the view's own name (D51), with Remove
from collection and without the missing note. A view whose collection was deleted
(MC6) lists nothing and reads "The collection this view uses was deleted."; it never
falls back to every image, and the view is not changed.
| Difference | Record |
| --- | --- |
| COLLECTIONS group, rows, counts, phone width | D136 (amends D28) |
| Collection page, Edit, missing note, deleted page | D137 |
| Add to collection dialog and toasts; Remove from collection | D138 (amends D8) |
| Inspector and viewer Collections… and its dialog | D139 |
| Saved views keep a collection; MC6 empty state | D140 |
Comparisons: `tests/browser/shell-concept.spec.mjs` compares `#collections-nav` with
two seeded collections ("Landscapes" with 12 listed members, "Experiments" with 8), so
the concept's rows and counts compare as they are, mapping out only the badge and, at
390 px, the concept's hidden nav. `tests/browser/images-concept.spec.mjs` compares Add
to collection in place in the selection bar, and the collection page's heading,
breadcrumb, toolbar and filter row against the concept's `#collection-landscapes`
(the grid is not compared: the concept's members come from categories).
`tests/browser/collections-concept.spec.mjs` compares the bulk dialog with the
concept's `simpleInput` (D138 maps "Apply in prototype" to "Add", the sample value to
empty and adds the datalist), at 1600x1100 and 390x844 in dark and light, with a
mutation check. `tests/browser/inspector-concept.spec.mjs` maps D139.
Tests: Python `tests/test_image_collections_schema.py` (migration, validation,
restore), `tests/test_image_collections.py` (operations, keys, the duplicate move),
`tests/test_api_collections.py` (routes, rules, counts), `tests/test_api_workspace_images.py`
(the `collection` filter), `tests/test_api_duplicate_files.py`, `tests/test_restore_stage.py`,
`tests/test_saved_views.py`; units `tests/js/workspace-collections.test.js` (the client
store and strings), `tests/js/workspace-images-selection.test.js` (the bar's order, the
500 cap, the all-matching rule and every §7.4 string), `tests/js/workspace-file-inspector.test.js`
(D139's button and dialog), `tests/js/workspace-toolbar.test.js` (the datalist);
browser `tests/browser/workspace-collections.spec.mjs` (CUJ 1–15 on the fixture
`tests/browser/workspace_collections_fixture.py`: twelve Landscapes members and one
missing file, eight Experiments members, one loose file and a saved view on
Experiments), `tests/browser/full-inspector.spec.mjs` (the footer with Collections…).
```sh
python3 -m unittest tests.test_image_collections_schema tests.test_image_collections tests.test_api_collections -q
node --test tests/js/workspace-collections.test.js tests/js/workspace-images-selection.test.js tests/js/workspace-file-inspector.test.js
npm run --prefix tools/vendor test:workflows -- workspace-collections.spec.mjs collections-concept.spec.mjs \
images-concept.spec.mjs inspector-concept.spec.mjs shell-concept.spec.mjs --project chromium --project touch
```
### Prompt library cards and recorded deviations (#249 PR E)
The shelf uses the shared concept toolbar and caption (E1) and the concept's
@@ -2132,6 +2452,101 @@ npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \
workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit
```
### Selecting prompts and Set category (#437)
**D148** (beyond the concept, whose Library cards have no checkbox): the Prompt library
selects prompts and sets their category in one step, the legacy admin page's bulk "Set
Category". It is built only from the concept's Images parts:
- Each `article.prompt-card` leads with the Images `.image-overlays > label > input`
checkbox ("Select <title>"), placed by the concept's overlay CSS over the art; the card
carries `position: relative` (the concept puts it on `.media-art`). The checkbox comes
before the title in tab order (on Images it follows the image button). Tab and
Shift+Tab across windowed cards reach it (the card's controls are `button,
input[type=checkbox]`); Home, End and the arrow keys still move between titles.
- `div#library-bulkbar` sits between the toolbar and the caption, empty until something
is checked, then the concept's `.bulkbar`: "N selected", Set category, Clear (D151
adds Add tags before Set category, in the Images bar's order). The
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
anchor; Shift+click applies the anchor's range with the box's new state (the D135
helpers). Escape in the bar and Clear clear it, focus to the heading. There is no
Shift+arrow, long-press or "Select all N matching" on the Library (D151 adds
Ctrl/Cmd+A and "Select all N loaded").
- Set category opens the shared `simpleInput` dialog ("Categorize selected prompts",
`input#action-input` labelled Category, at most 100 characters, the Library's
categories as suggestions). A blank value keeps it open; a control character is
refused on the field ("A category cannot contain control characters."). More than
1,000 selected toasts "Select at most 1,000 prompts." and opens nothing.
- Apply posts `{prompt_ids, category, expected_epoch}` to `POST
/prompt_manager/bulk/category`, the epoch being the shelf's first page's
`library_epoch` (kept with a returning visit; a later page from another epoch is a
shelf error). The server checks the epoch, then sets the category on the Library
parents among the ids in one `BEGIN IMMEDIATE` transaction, writing (and registering as
Library parents, as the editor's PATCH does) only rows whose category differs, and
answers `{library_epoch, category, updated, unchanged, not_found}`. A restored storage
answers 409 `storage-changed` and writes nothing. The legacy `{prompt_ids, category}`
and `{images_filter, category}` bodies are unchanged.
- Outcomes: "Category “X” set on N prompts." (plus "M could not be found."), then the
shelf and its categories reload and cached Library views and the sidebar counts are
invalidated, also when the page was left during the write. None found: "None of the
selected prompts could be found. The Library was reloaded." Storage restored: "Prompt
storage was restored since this page loaded. No category was changed." and a reload.
No answer, 502, 503 or 504: "The category change could not be confirmed. The Library
was reloaded." Any other refusal: "Category could not be set. Try again.", the
selection kept. Every bar action (and D151's select-all offer) is `aria-disabled` while
a write runs.
- The bar's DOM and write loop live in `library-selection.mjs`; the toast text
(`setOutcome` with the noun "prompt"), the range helpers, the count and the dialog are
the Images bar's. One bar core shared with `images-selection.mjs` is a follow-up.
Tests: `tests/browser/workspace-library-selection.spec.mjs` (set, blank and control
characters, Clear/Escape/filter, Shift+click range and Tab across the window edge, the
fence after a return visit, an unseen restore, refused and unconfirmed writes);
`tests/browser/library-concept.spec.mjs` ("the Library selection checkbox and bar match
the concept's Images ones", 1600x1100 and 390x844, both themes, with a moved-checkbox
mutation); units `tests/js/workspace-library-selection.test.js`,
`tests/js/workspace-library-state.test.js` (the epoch); Python
`tests/test_bulk_library_category.py`. A Python change: ComfyUI needs a restart.
### Adding tags to selected prompts and selecting all (#442)
**D151** (beyond the concept, on D148's bar): the Prompt library adds tags to the selected
prompts in one step, the legacy admin page's bulk "Add Tags", and selects every loaded
prompt, as its select-all checkbox did (#444 ruling). The concept has neither.
- The bar reads "N selected", Add tags, Set category, Clear: the Images bar's order.
- Select all of the loaded shelf: Ctrl/Cmd+A in the grid (Images' key check; the search
box keeps its text meaning) selects every loaded prompt, windowed-out cards included,
and Escape in the grid clears it with focus kept on the card. "Select all N loaded"
follows the count while some loaded prompts are unchecked and at most 1,000 are loaded;
it selects them all and focus moves to Add tags; N follows each loaded batch. It sits
where D135's "Select all N matching" sits, but that offer appears once every loaded
image is checked; this one never reaches beyond the loaded prompts. No "all matching".
- Add tags opens the shared `simpleInput` dialog ("Tag selected prompts", "Tags,
separated by commas", the tag vocabulary `GET /prompt_manager/tags` as suggestions, the
list the editor offers). The field refuses no tag, more than 20, a tag over 50
characters or with a control character, each with its sentence. Apply posts
`{prompt_ids, tags, expected_epoch}` to `POST /prompt_manager/bulk/tags`: prompt tags
only, never image tags, auto-tags or pair tags. Names are normalized as image tags are
(trim, no commas, casefold repeats folded); registry rows are found and inserted by
their exact name and only when some prompt gets them; a prompt gets only the names it
lacks by casefold. A prompt whose tags would pass 20 is skipped (`over_limit`), so the
editor can still save it. The answer is `{library_epoch, tags, updated, unchanged,
not_found, over_limit}`; the toast "Added “a, b” to N prompts." adds already-had,
skipped and not-found sentences. Storage restored: "Prompt storage was restored since
this page loaded. No tags were added."; unconfirmed: "Adding the tags could not be
confirmed. The Library was reloaded."; refused: "Tags could not be added. Try again."
The legacy `{prompt_ids, tags}` body is unchanged; both forms refuse a body that is not
a JSON object with 400.
Tests: `tests/browser/workspace-library-selection.spec.mjs` (Add tags with its suggestions,
field refusals and restore refusal; Ctrl/Cmd+A and Select all N loaded across batches and
during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison now includes
Add tags); units `tests/js/workspace-library-selection.test.js`; Python
`tests/test_bulk_library_tags.py`. A Python change: ComfyUI needs a restart.
### Prompt history page and recorded deviations (#249 PR F-a)
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
@@ -4856,7 +5271,7 @@ category; without one it shows the category its link supplies.
category can still be removed. An unexpected failure is a logged 500 ("The
category could not be changed." / "Image categories could not be read.") without
the exception text. The routes never read or write `prompts`, `prompt_pairs` or
`generated_images`; `POST /prompt_manager/bulk/category` is unchanged.
`generated_images`; `POST /prompt_manager/bulk/category` (prompts only) is not one of them.
- Duplicate cleanup (System › Maintenance): the kept copy's own category wins. A
kept copy without one receives the removed copy's (`COPY_SQL`, part of the
cleanup's query-plan check). The removed copy's row is never deleted, so a
@@ -4874,7 +5289,8 @@ category; without one it shows the category its link supplies.
the side panel, the viewer and History's media inspector (D145); Tags & categories
counts images on each category row and lists image-only values (D143), and Manage
category previews and renames image rows too (D144). Prompt categories are changed in
the Prompt library editor and on the legacy admin page's "Set Category". Concept
the Prompt library editor, the Prompt library's selection bar (D148, #437) and on the
legacy admin page's "Set Category". Concept
comparisons, at 1600x1100 and 390x844 in both themes: `images-concept.spec.mjs` (the
Set category dialog and the options), `inspector-concept.spec.mjs` (the chip),
`history-concept.spec.mjs` (History's chip) and `vocabulary-visual.spec.mjs` (rows and
@@ -5977,9 +6393,11 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
longer hands off to the legacy tag manager. That page stays reachable from Overview's
"Open existing tag manager" card for tag search and sort, the untagged entry and
multi-tag AND/OR browsing; bulk prompt actions (delete, tag, category) stay on the
legacy admin page. A prompt's category is changed in the Prompt library editor or with
the legacy admin page's "Set Category" (`POST /prompt_manager/bulk/category`, unchanged);
multi-tag AND/OR browsing; bulk prompt delete stays on the legacy admin page (bulk tags:
the Prompt library's selection bar, D151). A
prompt's category is changed in the Prompt library editor, on many prompts at once with
the Prompt library's selection bar (D148, #437), or with the legacy admin page's "Set
Category" (both `POST /prompt_manager/bulk/category`);
the Images selection bar's Set category sets images' own categories only (D140, #332),
and Manage category renames a value on prompts, recipes and images together (D144). The screen consumes only the `/prompt_manager/vocabulary/*` routes
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
+142
View File
@@ -21,6 +21,10 @@ _UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
_TOKEN = re.compile(r"[0-9a-f]{64}")
# Parent metadata stored on the prompt row; title lives in prompt_library_state.
_PROMPT_COLUMNS = ("category", "rating", "notes")
# Ids per IN (...) read: well under SQLite's oldest bound-parameter limit (999).
_BULK_CHUNK = 500
# The tags one prompt may carry: what the editor's save accepts (``validate_tags``).
MAX_LIBRARY_TAGS = 20
class LibraryCommandError(Exception):
@@ -85,6 +89,13 @@ def _validate_epoch(expected_epoch: str) -> None:
raise ValueError("expected_epoch must be a canonical UUID")
def _validate_bulk_ids(prompt_ids: list[int]) -> None:
if not isinstance(prompt_ids, list) or not prompt_ids:
raise ValueError("prompt_ids must be a non-empty list")
if any(type(pid) is not int or not 1 <= pid <= 2**63 - 1 for pid in prompt_ids):
raise ValueError("prompt_ids must be positive integers")
def _validate_guards(positive_id: int, expected_epoch: str, if_match: str) -> None:
if type(positive_id) is not int or not 1 <= positive_id <= 2**63 - 1:
raise ValueError("Invalid positive prompt ID")
@@ -308,3 +319,134 @@ class LibraryCommandOperations:
positive_created=False,
pair_created=pair_created,
)
def bulk_set_library_category(
self, prompt_ids: list[int], category: str, *, expected_epoch: str
) -> dict:
"""Set one category on many Library parents, all or nothing (#437).
Guarded by the storage epoch the page read. Only rows whose category
differs are written (their ``updated_at`` and edit token change) and
registered as Library parents, as the editor's PATCH does; ids
that are missing or are not Library parents count as not found.
"""
_validate_epoch(expected_epoch)
_validate_bulk_ids(prompt_ids)
prompt_ids = list(dict.fromkeys(prompt_ids))
if not isinstance(category, str) or not category.strip():
raise ValueError("Category is required")
validate_category(category)
category = category.strip()
with self._pair_transaction() as conn:
epoch = self._library_epoch(conn, expected_epoch)
current = {}
for start in range(0, len(prompt_ids), _BULK_CHUNK):
chunk = prompt_ids[start : start + _BULK_CHUNK]
marks = ",".join("?" for _ in chunk)
current.update(
conn.execute(
f"SELECT p.id,p.category FROM prompts p " # nosec B608
f"WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
chunk,
).fetchall()
)
changed = [pid for pid, value in current.items() if value != category]
for pid in changed:
self._ensure_library_parent(conn, pid)
now = utc_now_iso()
conn.executemany(
"UPDATE prompts SET category=?,updated_at=? WHERE id=?",
[(category, now, pid) for pid in changed],
)
return {
"library_epoch": epoch,
"category": category,
"updated": len(changed),
"unchanged": len(current) - len(changed),
"not_found": len(prompt_ids) - len(current),
}
def bulk_add_library_tags(
self, prompt_ids: list[int], tags: list[str], *, expected_epoch: str
) -> dict:
"""Add tags to many Library parents, all or nothing (#442).
Names are normalized once (trim, the vocabulary name rules, casefold
repeats folded, at most 20). A parent is given only the names it lacks,
compared with ``casefold`` against its own tags; registry rows are
found and inserted by their exact name, and only for names some parent
gets. A parent whose tags would pass 20 is skipped (``over_limit``) so
the editor can still save it. Pair and image tags are not touched.
"""
from .annotations import ImageTagLimitError, normalize_image_tags
_validate_epoch(expected_epoch)
_validate_bulk_ids(prompt_ids)
prompt_ids = list(dict.fromkeys(prompt_ids))
too_many = f"At most {MAX_LIBRARY_TAGS} tags can be added at once"
try:
tags = normalize_image_tags(tags)
except ImageTagLimitError:
# Its message names images; this is a prompt route.
raise ValueError(too_many) from None
if not tags:
raise ValueError("tags must name at least one tag")
if len(tags) > MAX_LIBRARY_TAGS:
raise ValueError(too_many)
with self._pair_transaction() as conn:
epoch = self._library_epoch(conn, expected_epoch)
held = self._library_parent_tags(conn, prompt_ids)
additions, over_limit = {}, 0
for pid, names in held.items():
present = {name.casefold() for name in names}
missing = [name for name in tags if name.casefold() not in present]
if not missing:
continue
if len(names) + len(missing) > MAX_LIBRARY_TAGS:
over_limit += 1
continue
additions[pid] = missing
wanted = list(
dict.fromkeys(n for names in additions.values() for n in names)
)
tag_ids = self._ensure_tags(conn, wanted)
now = utc_now_iso()
for pid, names in additions.items():
conn.executemany(
"INSERT OR IGNORE INTO prompt_tags (prompt_id, tag_id) "
"VALUES (?, ?)",
[(pid, tag_ids[name]) for name in names],
)
self._ensure_library_parent(conn, pid)
conn.execute("UPDATE prompts SET updated_at=? WHERE id=?", (now, pid))
return {
"library_epoch": epoch,
"tags": tags,
"updated": len(additions),
"unchanged": len(held) - len(additions) - over_limit,
"not_found": len(prompt_ids) - len(held),
"over_limit": over_limit,
}
def _library_parent_tags(
self, conn: sqlite3.Connection, prompt_ids: list[int]
) -> dict[int, list[str]]:
"""The Library parents among ``prompt_ids`` and their own tag names."""
held: dict[int, list[str]] = {}
for start in range(0, len(prompt_ids), _BULK_CHUNK):
chunk = prompt_ids[start : start + _BULK_CHUNK]
marks = ",".join("?" for _ in chunk)
for (pid,) in conn.execute(
f"SELECT p.id FROM prompts p WHERE p.id IN ({marks}) " # nosec B608
f"AND {_ELIGIBLE}",
chunk,
):
held[pid] = []
for pid, name in conn.execute(
"SELECT pt.prompt_id,t.name FROM prompt_tags pt " # nosec B608
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.prompt_id IN ({marks})",
chunk,
):
if pid in held:
held[pid].append(name)
return held
+27 -1
View File
@@ -56,9 +56,11 @@ from .models import BUSY_TIMEOUT_SECONDS, PromptModel
try:
from ..utils import library_location as ll
from ..utils.logging_config import get_logger
from ..utils.workspace_keys import KEY_FILE_NAME
except ImportError:
from utils import library_location as ll
from utils.logging_config import get_logger
from utils.workspace_keys import KEY_FILE_NAME
logger = get_logger("prompt_manager.database.library_move")
@@ -491,7 +493,8 @@ class _Move:
sweep_move_temps(target)
if ll.read_marker(target).get("state") == ll.MARKER_PENDING:
# An earlier move stopped before it switched: its files are ours.
for name in (ll.DATABASE_NAME, ll.CONFIG_NAME) + self.plan.backups:
names = (ll.DATABASE_NAME, ll.CONFIG_NAME, KEY_FILE_NAME)
for name in names + self.plan.backups:
_remove(os.path.join(target, name))
for suffix in ("-wal", "-shm", "-journal"):
_remove(os.path.join(target, ll.DATABASE_NAME + suffix))
@@ -536,6 +539,7 @@ class _Move:
raise MoveFailed("copy-failed") from error
_check_copies([(self.plan.config_path, temp, expected)])
copies = copies + [(self.plan.config_path, temp, expected, ll.CONFIG_NAME)]
copies = copies + self._key_copy()
self.marker = write_marker(
target, ll.MARKER_PENDING, self.plan.db_path, self.plan.config_path
)
@@ -556,6 +560,28 @@ class _Move:
self.checkpoint("marked")
return files
def _key_copy(self) -> list:
"""The workspace key's verified copy (#430), or none: it is re-creatable.
Without it the moved library starts with a new key, so pages left open
across the restart refresh once; that never fails the move.
"""
source = os.path.join(self.plan.source_dir, KEY_FILE_NAME)
if not os.path.isfile(source):
return []
temp = self.temp(".key")
try:
expected = _hash_copy(source, temp)
_check_copies([(source, temp, expected)])
except (OSError, MoveFailed) as error:
_remove(temp)
logger.warning(
f"Library move: the workspace key was not copied ({error}); "
"open workspace pages refresh once after the restart"
)
return []
return [(source, temp, expected, KEY_FILE_NAME)]
def cleanup(self) -> None:
if self.moved:
return
+2 -1
View File
@@ -12,7 +12,8 @@ code. This plan proposes implementation order and acceptance gates.
- The native Prompt Library merged in [#210](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/210) for [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208): one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates.
- [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209).
- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216) adds the shared current-file inspector with exact generation facts, scoped provenance, raw/download evidence and loaded-collection viewer controls, including a slideshow that pauses at native video. This delivery does not establish whole-application parity, change the default entry point or constitute a release. Recorded chronological run/context browsing continues under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); image tagging/review, System consolidation and remaining organization capabilities continue under #196.
- Local Inspect (#249, PRs L1–L2) replaces the `#/inspect` handoff with the concept's native "Inspect a file" page: one local image or video previews inline and opens the shared viewer and full inspector on a record read entirely in the browser, with nothing uploaded, imported or stored. The legacy metadata page (`web/metadata.html`, served at `/prompt_manager/gallery.html`) stays reachable from Overview; its capabilities map to the shared inspector, except its one-click COPY ALL summary, whose ruled replacement ("Copy settings summary") is pending. Recorded deviations D60–D66 are in [TESTING.md](../TESTING.md). Local roles equal to the server's resolver follow in PR L3.
- Local Inspect (#249, PRs L1–L3) replaces the `#/inspect` handoff with the concept's native "Inspect a file" page: one local image or video previews inline and opens the shared viewer and full inspector on a record read entirely in the browser, with nothing uploaded, imported or stored. The legacy metadata page (`web/metadata.html`, served at `/prompt_manager/gallery.html`) stays reachable from Overview; its capabilities map to the shared inspector, including its one-click COPY ALL summary, which is the shared inspector's "Copy settings summary" (D117, LI-4). PR L3 ([#286](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/286)) shipped that button and local roles equal to the server's resolver (`web/js/workspace/local-resolver.mjs`, TESTING.md "Local roles (#249 PR L3, decision LI-1)"). Recorded deviations D60–D66 and D117 are in [TESTING.md](../TESTING.md).
- Collections ([#331](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/331), three PRs) adds named, hand-picked groups of images, stored in the library database by file location as favorites are: a COLLECTIONS sidebar group whose rows open Images filtered to one collection (`#/collection/<id>`) with rename and delete, Add to collection and Remove from collection in the Images selection bar, a Collections… checklist in the shared inspector for the side panel and the viewer, and saved views that keep a collection. Membership survives restarts, source reordering and System › Maintenance duplicate cleanup; no file is copied, moved or changed.
## Scope and compatibility
+3
View File
@@ -74,6 +74,7 @@ from .autotag_routes import AutotagRoutesMixin
from .lora_integration import LoraIntegrationMixin
from .lora_import_routes import LoraImportRoutesMixin
from .image_categories import ImageCategoryRoutesMixin
from .source_rescope import SourceRescopeMixin
try:
from ...database.operations import PromptDatabase
@@ -342,6 +343,7 @@ class PromptManagerAPI(
LoraIntegrationMixin,
LoraImportRoutesMixin,
ImageCategoryRoutesMixin,
SourceRescopeMixin,
):
"""REST API handler for PromptManager operations and web interface.
@@ -708,6 +710,7 @@ class PromptManagerAPI(
self._register_lora_routes(routes)
self._register_lora_import_routes(routes)
self._register_image_category_routes(routes)
self._register_rescope_routes(routes)
# Register gzip compression middleware (once)
global _gzip_registered
+74 -17
View File
@@ -13,6 +13,7 @@ import re
import stat
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
import sys
import threading
import time as _time
import urllib.parse
from bisect import bisect_left, bisect_right, insort
@@ -81,6 +82,7 @@ try:
reveal_executor,
reveal_in_file_manager,
)
from ...utils.workspace_keys import gallery_root_scope, workspace_keys_for
except ImportError:
from database.image_collections import CollectionMissing
from database.image_favorites import MAX_KEY_LENGTH
@@ -105,6 +107,7 @@ except ImportError:
reveal_executor,
reveal_in_file_manager,
)
from utils.workspace_keys import gallery_root_scope, workspace_keys_for
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
@@ -518,7 +521,8 @@ MAX_JSON_BODY_BYTES = 1024 * 1024
_READ_CHUNK = 64 * 1024
_WORKSPACE_CURSOR_SECRET = os.urandom(32)
# Guards the first load of an API's workspace keys (#430, _workspace_keys).
_WORKSPACE_KEYS_LOCK = threading.Lock()
def _workspace_root(query: dict, roots: list[Path]) -> int | None:
@@ -618,15 +622,37 @@ def _rated_key(entry: _Listed, rank: int) -> tuple:
return (-rank, *_workspace_key(entry, "rating"))
def _workspace_cursor(key: tuple, scope: str) -> str:
# Bump whenever the cursor's key tuple changes shape: a cursor now outlives a
# restart and an upgrade (#430), so an old shape must be refused, not parsed.
_CURSOR_VERSION = 2
_CURSOR_STALE = "The image collection changed; refresh the gallery"
class CursorStale(ValueError):
"""A well-formed cursor this listing cannot verify (#430).
Another key, another root list or other filters: the page refreshes its
collection instead of retrying the same cursor.
"""
def _cursor_mac(secret: bytes, scope: str, payload: bytes) -> bytes:
# The listing scope (it hashes every absolute root) is bound in the MAC
# only, never sent: the scope is 64 hex, so the "\n" join is unambiguous.
return hmac.new(secret, scope.encode() + b"\n" + payload, hashlib.sha256).digest()
def _workspace_cursor(key: tuple, scope: str, secret: bytes) -> str:
"""The signed keyset cursor of *key*; *secret* is the API's cursor key."""
payload = json.dumps(
{"v": 1, "scope": scope, "key": key}, separators=(",", ":"), ensure_ascii=False
{"v": _CURSOR_VERSION, "key": key}, separators=(",", ":"), ensure_ascii=False
).encode()
signature = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
signature = _cursor_mac(secret, scope, payload)
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
def _workspace_cursor_key(token: str, scope: str, order: str, secret: bytes) -> tuple:
"""The keyset key of *token*; CursorStale when it is not this listing's."""
try:
if len(token) > 4096:
raise ValueError()
@@ -634,13 +660,14 @@ def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
)
signature, payload = raw[:32], raw[32:]
expected = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected):
if len(signature) != 32 or not payload:
raise ValueError()
if not hmac.compare_digest(signature, _cursor_mac(secret, scope, payload)):
raise CursorStale(_CURSOR_STALE)
data = json.loads(payload)
key = data["key"]
if data["v"] != 1 or data["scope"] != scope or not isinstance(key, list):
raise ValueError()
if data["v"] != _CURSOR_VERSION or not isinstance(key, list):
raise CursorStale(_CURSOR_STALE)
types = (
(str, str, int, (float, int))
if order in ("name", "name-desc")
@@ -654,6 +681,8 @@ def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
):
raise ValueError()
return tuple(key)
except CursorStale:
raise
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error):
raise ValueError("Invalid gallery cursor; refresh the gallery") from None
@@ -1338,13 +1367,27 @@ def _in_subfolder(rel_dir, subfolder):
class ImageRoutesMixin:
"""Mixin providing image and gallery-related API endpoints."""
def _workspace_keys(self):
"""This API's scope and cursor keys, loaded once (#430).
They derive from the secret persisted beside the database, so a restart
keeps every scope and cursor an open page holds. The first call reads
(or writes) one small file; later calls return the cached keys, which
a live library move or restore does not change before a restart.
"""
keys = self.__dict__.get("_workspace_keys_cache")
if keys is None:
with _WORKSPACE_KEYS_LOCK:
keys = self.__dict__.get("_workspace_keys_cache")
if keys is None:
db = getattr(self, "db", None)
model = getattr(db, "model", None)
keys = workspace_keys_for(getattr(model, "db_path", None))
self.__dict__["_workspace_keys_cache"] = keys
return keys
def _gallery_root_scope(self, root: Path, index: int) -> str:
payload = json.dumps(
["prompt-manager-gallery-root-v1", index, str(_canonical(root))],
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
return hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).hexdigest()
return gallery_root_scope(self._workspace_keys(), str(_canonical(root)), index)
def _gallery_scope_error(
self, query: Mapping, roots: list[Path]
@@ -2008,6 +2051,8 @@ class ImageRoutesMixin:
limit, _offset = parse_page_params(request.query, default_limit=100)
except ValueError:
return bad_request("limit and offset must be integers")
# #430: the first use may create the key file; never on the event loop.
cursor_secret = (await self._run_in_executor(self._workspace_keys)).cursor
try:
root_index = _workspace_root(request.query, output_dirs)
filters = _workspace_filters(request.query)
@@ -2031,7 +2076,15 @@ class ImageRoutesMixin:
).hexdigest()
token = request.query.get("cursor")
after = (
_workspace_cursor_key(token, scope, filters["order"]) if token else None
_workspace_cursor_key(token, scope, filters["order"], cursor_secret)
if token
else None
)
except CursorStale as exc:
return web.json_response(
{"success": False, "code": "cursor-stale", "error": str(exc)},
status=400,
headers={"Cache-Control": "no-store"},
)
except ValueError as exc:
return bad_request(str(exc))
@@ -2472,7 +2525,11 @@ class ImageRoutesMixin:
positive_count = _COUNT_OMITTED
has_more = len(page) > limit
page = page[:limit]
next_cursor = _workspace_cursor(key(page[-1]), scope) if has_more else None
next_cursor = (
_workspace_cursor(key(page[-1]), scope, self._workspace_keys().cursor)
if has_more
else None
)
return (
[(entry.path, entry.mtime, entry.root, entry.index) for entry in page],
total,
+60 -1
View File
@@ -8,8 +8,10 @@ import sqlite3
from aiohttp import web
try:
from ...database.library_commands import LibraryCommandError
from ...database.pairs import PairDependencyError
except ImportError:
from database.library_commands import LibraryCommandError
from database.pairs import PairDependencyError
from urllib.parse import quote
@@ -1128,7 +1130,14 @@ class PromptRoutesMixin:
async def bulk_add_tags(self, request):
"""Bulk add tags to prompts."""
try:
data = await request.json()
try:
data = await request.json()
except ValueError:
return bad_request("Invalid JSON body")
if not isinstance(data, dict):
return bad_request("Expected a JSON object")
if "expected_epoch" in data:
return await self._bulk_library_tags(data)
prompt_ids = data.get("prompt_ids", [])
new_tags = data.get("tags", [])
@@ -1180,6 +1189,8 @@ class PromptRoutesMixin:
)
if "images_filter" in data:
return await self._bulk_category_by_filter(data)
if "expected_epoch" in data:
return await self._bulk_library_category(data)
if not data.get("prompt_ids"):
return web.json_response(
{"success": False, "error": "No prompt IDs provided"}, status=400
@@ -1216,6 +1227,54 @@ class PromptRoutesMixin:
status=500,
)
async def _bulk_library_tags(self, data: dict) -> web.Response:
"""The workspace Library's fenced form (#442).
``{prompt_ids, tags, expected_epoch}``: prompt tags only, never image tags.
"""
return await self._fenced_library_write(
data, "tags", self.db.bulk_add_library_tags
)
async def _fenced_library_write(
self, data: dict, field: str, write
) -> web.Response:
"""One fenced Library bulk write: exact keys, checked ids, 409 on a restore."""
fields = {"prompt_ids", field, "expected_epoch"}
if set(data) != fields:
return bad_request(
"Request must contain exactly " + ", ".join(sorted(fields))
)
try:
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
result = await self._run_in_executor(
write, prompt_ids, data[field], expected_epoch=data["expected_epoch"]
)
except LibraryCommandError as exc:
return web.json_response(
{
"success": False,
"code": exc.code,
"library_epoch": exc.epoch,
"error": "Prompt storage was restored; refresh the library",
},
status=409,
)
except ValueError as exc:
return bad_request(str(exc))
return web.json_response({"success": True, **result})
async def _bulk_library_category(self, data: dict) -> web.Response:
"""The workspace Library's fenced form (#437).
``{prompt_ids, category, expected_epoch}``: a non-blank category on
the selected Library parents, refused with 409 ``storage-changed``
when prompt storage was restored since the page read its epoch.
"""
return await self._fenced_library_write(
data, "category", self.db.bulk_set_library_category
)
async def _bulk_category_by_filter(self, data: dict) -> web.Response:
"""Set the category on every linked positive a listing filter admits.
+100
View File
@@ -0,0 +1,100 @@
"""Where a stale Images target is now (#430): ``POST /prompt_manager/images/rescope``.
A page addresses a listed file as ``{root_index, root_scope, path}``. When the
sources are renumbered its scope no longer names the root at that index, and
every per-file action answers 409 ``source-changed``. Before it gives up, the
page asks here. The target is mapped only when its scope is, at its old
index, the scope of one of the current roots: the server issued that scope
for exactly that folder, so the answer (the file's current listing
descriptor) names nothing the page could not already address. A removed or
replaced root, a scope of another key and a foreign scope stay refused.
Only the Images roots (``_get_all_output_dirs``) are mapped; a LoRA-linked
root's target is never re-scoped here.
"""
import hmac
from pathlib import Path
from aiohttp import web
from .images import (
_forbidden,
_is_thumbnail_cache_path,
_json_body_or_error,
_media_access_error,
)
from .media_target import media_target
from .prompts import bad_request
# Far above any real source count; keeps the index out of huge-int territory.
MAX_ROOT_INDEX = 4095
SOURCE_GONE = (
"The source of this image changed or was removed. Refresh the image "
"collection to find it again."
)
def _coded(status: int, code: str, message: str) -> web.Response:
return web.json_response(
{"success": False, "code": code, "error": message}, status=status
)
def _no_store(response: web.Response) -> web.Response:
response.headers["Cache-Control"] = "no-store"
return response
class SourceRescopeMixin:
"""The rescope route; composed into ``PromptManagerAPI``."""
def _register_rescope_routes(self, routes):
@routes.post("/prompt_manager/images/rescope")
async def rescope_route(request):
return _no_store(await self.rescope_image(request))
def _current_root_for(self, scope: str, old_index: int, roots: list) -> int | None:
"""The index of the first current root *scope* was issued for at *old_index*."""
for index, root in enumerate(roots):
if hmac.compare_digest(scope, self._gallery_root_scope(root, old_index)):
return index
return None
def _rescope_blocking(self, scope: str, old_index: int, relative: str):
"""The answer to one rescope (blocking: it resolves paths)."""
roots = list(self._get_all_output_dirs())
index = self._current_root_for(scope, old_index, roots)
if index is None:
return _coded(409, "source-changed", SOURCE_GONE)
root = Path(roots[index])
path = root / relative
denied = _media_access_error(path, [root])
if denied is not None:
return denied
try:
mtime = path.stat().st_mtime if path.is_file() else None
except OSError:
mtime = None
if mtime is None:
return _coded(404, "file-missing", "Image file not found")
[image] = self._format_workspace_page([(path, mtime, root, index)])
return web.json_response({"success": True, "image": image})
async def rescope_image(self, request) -> web.Response:
data, error = await _json_body_or_error(request)
if error is not None:
return error
if set(data) != {"target"}:
return bad_request("Send exactly one target")
target, error = media_target(data["target"])
if error is not None:
return error
old_index, scope, relative = target
if old_index > MAX_ROOT_INDEX:
return bad_request("Invalid gallery root")
if _is_thumbnail_cache_path(relative):
return _forbidden("Previews are not re-scoped; use the original")
return await self._run_in_executor(
self._rescope_blocking, scope, old_index, relative
)
+108
View File
@@ -0,0 +1,108 @@
import {test,expect} from './fixtures.mjs';
import {SIZES,THEMES,seedConceptAt,openConcept,openProduction,diffInventory,mutate} from './concept-inventory.mjs';
// #331 D138: the Images selection bar's "Add to collection" dialog against the concept's own (`bulk-collection` →
// simpleInput, app.js a1392–1398 and a1138), on the concept-shaped fixture, at 1600x1100 and 390x844 in both themes.
// Paired screenshots are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.gallery-body > .image-grid > .media-card';
/**
* Runs in the page: the open "Add selected images to collection" dialog: its title, the body's tree, the field's
* label, input and suggestions, and the actions, with the concept's geometry and tokens.
*/
function measureAddDialog() {
const dialog=document.querySelector('dialog#modal[open]');
const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
const name=node=>[node.localName,...[...node.classList].sort()].join('.');
const text=node=>node?.textContent.trim()??null;
const body=dialog.querySelector('#modal-body');
const field=body.querySelector(':scope > .field');
const label=field?.querySelector(':scope > label');
const input=field?.querySelector(':scope > input');
const list=input?.list??null;
const actions=body.querySelector(':scope > .dialog-actions');
const rect=dialog.getBoundingClientRect();
return {
title:text(dialog.querySelector('#modal-title')),
tree:[...body.children].map(name),
field:field&&{tree:[...field.children].map(name),height:Math.round(field.getBoundingClientRect().height),
...css(field,'display','flexDirection','rowGap','marginBottom')},
label:label&&{text:text(label),for:label.htmlFor,...css(label,'fontSize','fontWeight','color')},
input:input&&{id:input.id,type:input.type,value:input.value,maxLength:input.maxLength,list:input.getAttribute('list'),
...css(input,'height','paddingLeft','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')},
suggestions:list&&{id:list.id,display:getComputedStyle(list).display,options:[...list.options].map(option=>option.value)},
actions:{tree:[...actions.children].map(name),...css(actions,'display','justifyContent','columnGap','marginTop'),
buttons:[...actions.children].map(button=>({text:text(button),className:button.className,
...css(button,'backgroundColor','color','fontSize','paddingTop','paddingLeft','borderTopColor')}))},
width:Math.round(rect.width),height:Math.round(rect.height),
};
}
/**
* D138: the action is "Add" (the concept's "Apply in prototype"), the field starts empty (no sample "Experiments") and
* holds at most 100 characters, and a hidden `datalist` of the existing names (the fixture's two) follows the field:
* the shared input dialog's suggestions list, as Set category's (#332 D140).
*/
function mapAddDialogConcept(reference) {
const [cancel,apply]=reference.actions.buttons;
const [field,...rest]=reference.tree;
return {...reference,tree:[field,'datalist',...rest],
input:{...reference.input,value:'',maxLength:100,list:'workspace-input-suggestions'},
suggestions:{id:'workspace-input-suggestions',display:'none',options:['Experiments','Landscapes']},
actions:{...reference.actions,buttons:[cancel,{...apply,text:'Add'}]}};
}
async function openAddDialog(page,main) {
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await page.locator(`${main} #bulkbar`).getByRole('button',{name:'Add to collection',exact:true}).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add selected images to collection');
await page.mouse.move(0,0);
return page.evaluate(measureAddDialog);
}
async function conceptAddDialog(page,theme) {
await openConcept(page,'images',theme);
return openAddDialog(page,'#main');
}
async function productionAddDialog(page,theme) {
await openProduction(page,'images',theme);
await expect(page.locator(CARDS).first()).toBeVisible();
return openAddDialog(page,'#workspace-main');
}
test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);});
for(const theme of THEMES)for(const [width,height] of SIZES) {
test(`add to collection dialog matches the concept ${theme} ${width}x${height}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const concept=await conceptAddDialog(page,theme);
await page.screenshot({path:testInfo.outputPath(`concept-add-to-collection-${theme}-${width}.png`)});
const production=await productionAddDialog(page,theme);
await page.screenshot({path:testInfo.outputPath(`production-add-to-collection-${theme}-${width}.png`)});
expect(concept.input.value,'the concept pre-fills its sample').toBe('Experiments');
expect(diffInventory(mapAddDialogConcept(concept),production)).toEqual([]);
});
}
test('add to collection comparison detects mutations',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
const concept=await conceptAddDialog(page,'dark');
const expected=mapAddDialogConcept(concept);
const production=await productionAddDialog(page,'dark');
expect(diffInventory(expected,production)).toEqual([]);
// Unmapped, the concept differs only where D138 is recorded.
expect(diffInventory(concept,production).map(item=>item.path).sort()).toEqual(expect.arrayContaining(
['actions.buttons.1.text','tree.1','input.list','input.maxLength','input.value']));
const paths=async()=>diffInventory(expected,await page.evaluate(measureAddDialog)).map(item=>item.path);
await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Name';});
expect(await paths()).toEqual(['label.text']);
await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Collection name';});
const undoStyle=await mutate(page,{css:'dialog#modal #action-input { border-color: rgb(255, 0, 0); } dialog#modal .dialog-actions { gap: 31px; }'});
expect(await paths()).toEqual(expect.arrayContaining(['input.borderTopColor','actions.columnGap']));
await undoStyle();
await page.evaluate(()=>document.querySelector('#workspace-input-suggestions').remove());
expect(await paths()).toEqual(expect.arrayContaining(['suggestions']));
});
+18
View File
@@ -118,9 +118,27 @@ export async function openConcept(page,route,theme,motion='no-preference') {
await expect(page.locator('#main #page-title')).toBeVisible();
}
/**
* D157 (#451): production opens Images in Masonry when the browser has no Default layout; the concept starts at Grid.
* A comparison (and a test of the Grid's own geometry) starts production from the concept's state, a stored Default
* layout of Grid, once per tab and only while this browser stored nothing (a test's own settings win).
*/
export async function startInGrid(page) {
if (page.conceptLayoutSeeded) return;
page.conceptLayoutSeeded = true;
await page.addInitScript(() => {
try {
if (!location.pathname.startsWith('/prompt_manager/') || sessionStorage.getItem('pm-d157-seeded')) return;
sessionStorage.setItem('pm-d157-seeded', '1');
if (localStorage.getItem('gallerySettings') === null) localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' }));
} catch { /* A page without storage keeps the production default. */ }
});
}
/** Production at `#/<route>` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */
export async function openProduction(page,route,theme,motion='no-preference') {
await page.emulateMedia({reducedMotion:motion});
await startInGrid(page);
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
await selectTheme(page,theme);
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
+67 -17
View File
@@ -28,6 +28,11 @@ import weakref
from pathlib import Path
from typing import Any, Callable, TypeVar
try:
import fcntl
except ImportError: # Windows: no flock, and no /dev/shm to share
fcntl = None
T = TypeVar("T")
# Set to 1 to run seeders on the live database as before #384 (the equivalence
@@ -43,8 +48,13 @@ MEMORY_ROOT = Path("/dev/shm")
# the browsers and the other workers; with less free than this, the scratch
# copy goes on disk instead.
MEMORY_FREE_BYTES = 32 * 1024 * 1024
# Scratch folders are named <prefix><owner pid>-<random>.
# Scratch folders are created as <prefix>new-<owner pid>-<random>, locked, then
# renamed to <prefix>held-<owner pid>-<random>. The pid is for people only:
# ownership is the flock on OWNER_LOCK, which every PID namespace sees (#419).
SCRATCH_PREFIX = "pm-reset-"
STAGING_PREFIX = f"{SCRATCH_PREFIX}new-"
HELD_PREFIX = f"{SCRATCH_PREFIX}held-"
OWNER_LOCK = "owner.lock"
def _quote(name: str) -> str:
@@ -96,29 +106,40 @@ def table_digests(conn: sqlite3.Connection, schema: str = "main") -> dict[str, s
return result
def _process_alive(pid: int) -> bool:
def _hold(folder: Path, *, create: bool) -> int:
"""Take ``folder``'s owner lock without waiting; the open descriptor."""
flags = os.O_RDWR | (os.O_CREAT if create else 0)
fd = os.open(folder / OWNER_LOCK, flags, 0o600)
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
return True # it exists but belongs to someone else
return True
os.close(fd)
raise
return fd
def remove_orphaned_scratch(root: Path) -> None:
"""Remove scratch folders of servers that died without cleaning up.
A server killed outright (SIGKILL, a crash) never runs ``close``, and
tmpfs keeps its folder in memory until reboot. The folder name carries
the owner's process id; a folder whose process is gone is removed.
tmpfs keeps its folder in memory until reboot. A live owner holds the
flock on the folder's owner lock; the kernel drops it when the owner
dies, in whatever PID namespace it ran (a container with ``--ipc=host``
shares /dev/shm but not the pids, #419). A folder whose lock is free is
removed. Folders without a lock (staging, or named before #419) cannot
be proven orphaned and are left alone.
"""
if os.name != "posix":
return # os.kill(pid, 0) would terminate the process on Windows
for folder in root.glob(f"{SCRATCH_PREFIX}*-*"):
pid = folder.name[len(SCRATCH_PREFIX) :].split("-", 1)[0]
if pid.isdigit() and not _process_alive(int(pid)):
if fcntl is None:
return
for folder in root.glob(f"{HELD_PREFIX}*"):
try:
fd = _hold(folder, create=False)
except OSError:
continue # held by its owner, no lock to judge by, or gone
try:
shutil.rmtree(folder, ignore_errors=True)
finally:
os.close(fd)
class _SeedApi:
@@ -162,6 +183,7 @@ class ScratchSeeder:
self._fallback = Path(directory)
self._memory_root = memory_root
self._directory: Path | None = None
self._owner_lock: int | None = None
self._on_live = on_live
self._scratch = None
self._unsynced: "weakref.WeakSet[sqlite3.Connection]" = weakref.WeakSet()
@@ -266,6 +288,9 @@ class ScratchSeeder:
if self._directory is not None:
shutil.rmtree(self._directory, ignore_errors=True)
self._directory = None
if self._owner_lock is not None:
os.close(self._owner_lock) # after the removal: never a free lock
self._owner_lock = None
def _scratch_directory(self) -> Path:
"""A folder in memory (tmpfs) where there is one, else ``directory``.
@@ -274,10 +299,35 @@ class ScratchSeeder:
kernel is writing back the pages they rewrite.
"""
root = self._memory_root
if root is not None and root.is_dir() and os.access(root, os.W_OK):
if (
fcntl is not None
and root is not None
and root.is_dir()
and os.access(root, os.W_OK)
):
remove_orphaned_scratch(root)
if shutil.disk_usage(root).free >= MEMORY_FREE_BYTES:
prefix = f"{SCRATCH_PREFIX}{os.getpid()}-"
return Path(tempfile.mkdtemp(prefix=prefix, dir=root))
held = self._held_memory_directory(root)
if held is not None:
return held
self._fallback.mkdir(parents=True, exist_ok=True)
return self._fallback
def _held_memory_directory(self, root: Path) -> Path | None:
"""A new folder under ``root`` that this seeder holds the lock of.
It is locked under the staging name, which no cleaner judges, and
only then renamed: no cleaner ever sees it held-named and unlocked.
"""
staging = Path(
tempfile.mkdtemp(prefix=f"{STAGING_PREFIX}{os.getpid()}-", dir=root)
)
try:
fd = _hold(staging, create=True)
except OSError:
shutil.rmtree(staging, ignore_errors=True)
return None # no locks on this filesystem: use the disk
held = root / (HELD_PREFIX + staging.name[len(STAGING_PREFIX) :])
os.rename(staging, held)
self._owner_lock = fd
return held
+14 -11
View File
@@ -145,9 +145,10 @@ test('panel matches the concept order and copy',async({page,request})=>{
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
const footer=panel.locator('.detail-footer');
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']);
// #331 D139: every listed file's footer ends with Collections… (folder icon).
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2);
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
@@ -157,10 +158,11 @@ test('panel matches the concept order and copy',async({page,request})=>{
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']);
// Unlinked: no badge, category, tags, pair or history.
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Collections…']);
// Unlinked: no badge, category, tags, pair or history; the footer holds only Collections… (D139).
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'],
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
// Ambiguous saved association: the same honest absence.
@@ -258,7 +260,7 @@ test('pair send and history appear only with real roles and link',async({page,re
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
});
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
@@ -286,8 +288,8 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
// then the pair send and history buttons (D20).
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
// then the pair send and history buttons (D20) and D139's Collections….
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
@@ -298,7 +300,7 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
// File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines.
const provenance=details.locator('details').first();await provenance.locator('summary').click();
@@ -323,9 +325,10 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible();
await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG');
await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG');
// No badge, no line; no resolved pair or link, no buttons after the details.
// No badge, no line; no resolved pair or link: after the details only D139's Collections….
reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png');
expect((await viewerStructure(details)).outer).toEqual(['div']);
expect((await viewerStructure(details)).outer).toEqual(['div','button']);
await expect(details.locator(':scope > button')).toHaveText(['Collections…']);
await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence');
// The history button leaves the viewer for the prompt's history.
await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png');
+49 -4
View File
@@ -30,9 +30,12 @@ function captionDay(seconds,now=new Date()) {
return `${name} · ${date.toLocaleDateString('en-US',options)}`;
}
/** D8 (amended by #249 PR D): the selection bar's real actions only; Add to collection and Remove links… are left out. */
const OMITTED_BULK=Object.freeze(['Add to collection','Remove links…']);
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
/**
* D8 (amended by #249 PR D and #331): the selection bar's real actions only; Remove links… is left out. Add to
* collection compares in place (D138).
*/
const OMITTED_BULK=Object.freeze(['Remove links…']);
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
/** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */
const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]);
@@ -464,7 +467,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
'Add to collection','Export metadata','Remove links…','Clear']);
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
'Set category','Export metadata','Clear']);
'Set category','Add to collection','Export metadata','Clear']);
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
// The bar comparison notices a change to the production bar.
@@ -472,6 +475,48 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
});
/**
* #331 D137: the collection page's Edit leads the heading actions, drawn in the concept as a plain copy of its Import
* button reading "Edit" (no icon, no action), so the browser lays out what follows it; returns an undo.
*/
async function drawCollectionEdit(page) {
await page.evaluate(()=>{
const actions=document.querySelector('#main .heading-actions');
const edit=actions.querySelector('button').cloneNode(false);
for(const key of Object.keys(edit.dataset))delete edit.dataset[key];
edit.textContent='Edit';actions.prepend(edit);window.__collectionEdit=edit;
});
await settle(page);
return ()=>page.evaluate(()=>{window.__collectionEdit.remove();delete window.__collectionEdit;});
}
// #331 D137: `#/collection/<id>` against the concept's `#collection-landscapes` (app.js a1152–1157, a1186–1189): the
// collection's name as the h1, "Collection" as the breadcrumb, the Images description, toolbar and filter row. The grid
// is not compared: the concept's members come from its categories, production's from the stored collection.
for(const [width,height] of SIZES)for(const theme of THEMES)test(`collection page heading, toolbar and filter row match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;
await openConcept(page,'collection-landscapes',theme);await page.mouse.move(0,0);await settle(page);
await expect(page.locator('#main #page-title')).toHaveText('Landscapes');
await expect(page.locator('#breadcrumb')).toHaveText('Collection');
const restore=await drawCollectionEdit(page);
const reference=await inventoryOf(page,'#main');
await restore();
await openProduction(page,`collection/${manifest.collections.Landscapes}`,theme);await page.mouse.move(0,0);
await expect(page.locator(CARDS)).toHaveCount(12);
await expect(page.locator('#workspace-main #page-title')).toHaveText('Landscapes');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Collection');
await expect(page).toHaveTitle('Collection · Prompt Manager');
await settle(page);
const actual=await inventoryOf(page,'#workspace-main');
expect.soft(actual.heading.actions.buttons.map(item=>item.text),'production heading actions').toEqual(['Edit','Import','New prompt']);
expect.soft(reference.heading.actions.buttons.map(item=>item.text),'mapped concept heading actions').toEqual(['Edit','Import','New prompt']);
expect.soft(compare(reference,actual,manifest,['heading','toolbar','filterRow']),'collection page').toEqual([]);
// The comparison notices a lost Edit.
await mutate(page,{remove:['#workspace-main .heading-actions > button:first-child']});
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['heading']).length).toBeGreaterThan(0);
});
/** #332: an own category on one listed image, set through the real route (PUT /prompt_manager/images/category). */
const IMAGE_CATEGORY='Portraits';
async function setImageCategory(request,path,category) {
+31 -5
View File
@@ -51,10 +51,11 @@ function withCategoryScope(inventory) {
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139 and D146); everything else must
* equal production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do.
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
* "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details.
*/
export function mapInspectorConcept(reference,{viewport}) {
const {panel,viewerPlain,fileControls}=reference;
@@ -111,6 +112,22 @@ async function openProductionPanel(page,theme) {
await settle(page);
}
/**
* #331 D139: production's "Collections…" after Prompt history in `selector`'s buttons, drawn in the concept as its own
* Prompt history button (no action) with the concept's folder icon (its Images filter chip's); returns an undo.
*/
async function withCollectionsButton(page,selector) {
await page.evaluate(selector=>{
const history=[...document.querySelectorAll(`${selector} > button`)].find(button=>button.textContent.includes('Prompt history'));
const button=history.cloneNode(false);
for(const key of Object.keys(button.dataset))delete button.dataset[key];
button.append(document.querySelector('.filter-row .chip > svg.icon').cloneNode(true),' Collections…');
history.after(button);(window.__collectionsButtons??=[]).push(button);
},selector);
await settle(page);
return async()=>{await page.evaluate(()=>{for(const button of window.__collectionsButtons??[])button.remove();delete window.__collectionsButtons;});};
}
/**
* The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
* settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
@@ -129,8 +146,9 @@ async function plainViewerChips(page) {
summary.textContent='Copy settings summary';summary.classList.add('settings-summary');
document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary;
});
await settle(page);
const restoreCollections=await withCollectionsButton(page,'#viewer-details');
return async()=>{
await restoreCollections();
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
window.__summaryButton.remove();delete window.__summaryButton;});
await restoreSource();await restoreTargets();
@@ -169,10 +187,12 @@ async function viewerOf(page,concept,mutation=null) {
return {viewerDetails,viewerPlain,fileControls};
}
async function inventory(page,concept=false) {
// D146: the concept panel's category chip with its visible origin, laid out by the concept page itself.
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history.
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null;
if(concept)await settle(page);
const {panel}=await inspectorOf(page);
await restoreCollections?.();
await restoreSource?.();
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
@@ -340,7 +360,7 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt');
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
});
// #391: a tag chip replaces the collection; until its listing answers the caption says it is loading, never that the
@@ -414,6 +434,12 @@ test('inspector comparison detects mutations',async({page})=>{
expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/);
assertInspectorMatch(reference,await viewerMutated(),viewport);
// D139: Collections… is part of the mapped concept, so losing it is a difference.
const restoreButton=await mutate(page,{remove:['#image-inspector > .detail-footer > button:last-child']});
const withoutCollections=await measure();
expect(()=>assertInspectorMatch(reference,withoutCollections,viewport)).toThrow(/panel\.footer/);
await restoreButton();await settle(page);
// D146: the visible origin is part of the mapped concept, so losing it is a difference.
const restoreSource=await mutate(page,{remove:['#image-inspector .chip-source']});
const sourceMutated=await measure();
+53
View File
@@ -30,6 +30,9 @@ export function mapLibraryConcept(reference) {
// A shorter toolbar moves what follows it up by as much.
const rise=library.toolbar.height-toolbar.height;
const cards=library.cards.map((card,index)=>({...card,
// D148 (#437): each card leads with the concept's Images `.image-overlays` selection checkbox, placed absolutely
// over the art, so nothing else moves; it is compared with the concept's own overlay below.
children:['image-overlays',...card.children],
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
@@ -311,6 +314,56 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
});
/**
* D148 (#437): the selection checkbox overlay of `card` and the `.bulkbar` row inside `main`, with one item checked,
* measured alike in the concept's Images page and production's Library: the overlay's inset in its card, the checkbox,
* and the bar's box, tokens and the actions both bars have (Add tags, Set category, Clear; #442).
*/
const measureSelection=(page,{card,main})=>page.evaluate(({card,main})=>{
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const rect=node=>node.getBoundingClientRect(),px=value=>Math.round(value*100)/100;
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','color');
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginBottom','borderTopWidth','borderTopStyle',
'borderTopColor','borderTopLeftRadius','backgroundColor');
const host=document.querySelector(card),overlay=host.querySelector('.image-overlays'),label=overlay.querySelector(':scope > label');
const input=label.querySelector('input'),root=document.querySelector(main),bar=root.querySelector('.bulkbar');
const shared=['Add tags','Set category','Clear'];
return {
overlay:{left:px(rect(overlay).left-rect(host).left),top:px(rect(overlay).top-rect(host).top),right:px(rect(host).right-rect(overlay).right),
...css(overlay,'position','display','justifyContent','pointerEvents')},
label:css(label,'pointerEvents'),checkbox:{width:px(rect(input).width),height:px(rect(input).height),checked:input.checked,...css(input,'accentColor')},
bar:{left:px(rect(bar).left-rect(root).left),...css(bar,'display','alignItems','columnGap','rowGap','flexWrap'),...edges(bar),...font(bar),
count:{text:bar.querySelector(':scope > strong').textContent,...font(bar.querySelector(':scope > strong'))},
buttons:[...bar.querySelectorAll(':scope > button')].filter(button=>shared.includes(button.textContent.trim()))
.map(button=>({text:button.textContent.trim(),height:px(rect(button).height),...font(button),...edges(button)}))},
};
},{card,main});
async function conceptSelection(page,theme) {
await openConcept(page,'images',theme);
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
return measureSelection(page,{card:'#image-grid .media-card',main:'#main'});
}
async function productionSelection(page,theme) {
await openProductionLibrary(page,theme,'no-preference');
await page.locator(CARDS).first().locator('.image-overlays input').check();
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
return measureSelection(page,{card:CARDS,main:'#workspace-main'});
}
for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library selection checkbox and bar match the concept's Images ones ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const reference=await conceptSelection(page,theme);
await captureCanonicalState(page,testInfo,`concept-images-selection-${width}-${theme}`);
const actual=await productionSelection(page,theme);
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
expect(actual).toEqual(reference);
// The comparison notices a moved checkbox (spec §7 mutation check).
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
});
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width:1800,height:1100});
+6
View File
@@ -1,5 +1,6 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import { startInGrid } from './concept-inventory.mjs';
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
@@ -38,6 +39,9 @@ async function chooseDestination(page) {
test('collection viewer rejects stale source and sends only refreshed API evidence',async({page,request,context})=>{
const host=await fakeHost(context,page);
// #430: the file is never found again here, so the manual Refresh is what this covers (the found-again path is
// workspace-stale-source.spec.mjs).
await page.route('**/prompt_manager/images/rescope',route=>route.fulfill({status:409,json:{success:false,code:'source-changed',error:'The source of this image changed or was removed.'}}));
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>window.copied.push(text)}});});
try {
const loaded=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const stale=loaded.images.find(image=>image.root_index===0&&image.path==='shared/same.png');
@@ -249,6 +253,8 @@ test('responsive gallery defers observed size writes and still fits the new view
});}
};
});
// The Grid's observed sizes (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
await expect(page.locator('.media-card')).toHaveCount(4);
await page.setViewportSize({width:390,height:844});
+4 -1
View File
@@ -46,6 +46,7 @@ from tests.browser.workspace_library_editor_fixture import (
)
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
from tests.browser.workspace_tagging_fixture import register_tagging_commands
from tests.browser.source_change_fixture import register_source_change_commands
from tests.browser.workspace_review_fixture import register_review_commands
from tests.browser.workspace_collections_fixture import register_collections_commands
from tests.browser.workspace_diagnostics_fixture import (
@@ -231,7 +232,8 @@ def create_app(root: Path) -> web.Application:
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
register_tagging_commands(routes, api, root, model)
restore_sources = register_source_change_commands(routes, api)
register_tagging_commands(routes, api, root, model, on_reset=restore_sources)
register_review_commands(routes, api, root)
register_collections_commands(routes, api, root)
reveal_stub = register_reveal_stub(routes, api, root)
@@ -439,6 +441,7 @@ def create_app(root: Path) -> web.Application:
async def reset(request: web.Request) -> web.Response:
nonlocal history_fixture
request_holds.release()
restore_sources()
# No preview queued by the last test may land in the new fixture.
await api._run_in_executor(api.close_auto_thumbnails)
library_editor_commands.reset()
+66
View File
@@ -0,0 +1,66 @@
"""Source changes under an open workspace page (#430), undone by every reset.
- ``POST /__test__/sources/reverse``: the image sources are listed in reverse
order, as System › Sources would renumber them, so every held
``root_scope`` names another index and the page must re-scope its file.
- ``POST /__test__/scope-key/rotate``: the API's scope and cursor keys
change, as when ``workspace-scope.key`` is lost before a restart: a held
scope then cannot be mapped and the page offers Refresh.
"""
from __future__ import annotations
import secrets
from typing import TYPE_CHECKING, Callable
from aiohttp import web
from utils.workspace_keys import WorkspaceKeys
if TYPE_CHECKING:
from py.api import PromptManagerAPI
_CACHE = "_workspace_keys_cache"
def register_source_change_commands(
routes: web.RouteTableDef, api: PromptManagerAPI
) -> Callable[[], None]:
"""Register the commands; returns the restore every reset runs."""
# Imported here: py.config needs the host stubs create_app installs first.
from py.config import GalleryConfig
saved: dict = {}
def restore() -> None:
if "roots" in saved:
GalleryConfig.MONITORING_DIRECTORIES = saved.pop("roots")
api.invalidate_gallery_cache(retire=True)
if "keys" in saved:
keys = saved.pop("keys")
if keys is None:
api.__dict__.pop(_CACHE, None)
else:
api.__dict__[_CACHE] = keys
@routes.post("/__test__/sources/reverse")
async def reverse(request: web.Request) -> web.Response:
if request.query or request.can_read_body:
raise web.HTTPBadRequest()
roots = list(GalleryConfig.MONITORING_DIRECTORIES)
saved.setdefault("roots", roots)
GalleryConfig.MONITORING_DIRECTORIES = roots[::-1]
await api._run_in_executor(api.invalidate_gallery_cache, True)
return web.json_response({"success": True})
@routes.post("/__test__/scope-key/rotate")
async def rotate(request: web.Request) -> web.Response:
if request.query or request.can_read_body:
raise web.HTTPBadRequest()
saved.setdefault("keys", api.__dict__.get(_CACHE))
api.__dict__[_CACHE] = WorkspaceKeys(
secrets.token_bytes(32), secrets.token_bytes(32), False
)
return web.json_response({"success": True})
return restore
+5 -2
View File
@@ -231,6 +231,8 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
const save = () => tagDialog(page).locator('.dialog-actions .ca-primary').click();
const status = () => tagDialog(page).locator('#tag-generation-status');
// D147: a state that blocks Save shows its reason beside the actions, not in the status line.
const notice = () => tagDialog(page).locator('.dialog-actions > .tag-action-notice');
// Unavailable model.
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
@@ -269,7 +271,8 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await saveImageTags(request, path, CONCEPT_TAGS);
await openTagDialog(page, 'production', 'dark');
await save();
await expect(status()).not.toHaveText('');
await expect(notice()).toHaveText('The file changed since it was previewed. Reopen the Tag dialog.');
await expect(status()).toHaveText('');
await shot('production-file-changed');
await page.unroute(writes);
await page.keyboard.press('Escape');
@@ -278,7 +281,7 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await page.route(writes, route => route.fulfill({ status: 504, body: 'Gateway Timeout' }));
await openTagDialog(page, 'production', 'dark');
await save();
await expect(status()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
await expect(notice()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
expect((await inventory(page)).actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
await shot('production-unknown-outcome');
@@ -491,3 +491,366 @@ test('a page that says a collection or view could not be opened takes focus afte
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(heading(page)).toBeFocused();
});
// #331 D138 (design §7): the selection bar's Add to collection and Remove from collection.
const bar = page => page.locator('#bulkbar > .bulkbar');
const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true });
const addDialog = page => page.getByRole('dialog', { name: 'Add selected images to collection', exact: true });
const addField = page => addDialog(page).getByRole('combobox', { name: 'Collection name', exact: true });
const check = (page, file) => page.getByRole('checkbox', { name: `Select ${file}`, exact: true }).check();
const card = (page, path) => page.locator(`article[data-image-key$=":${path}"]`);
/** Add the checked images under `name` through the bar's dialog. */
async function addChecked(page, name) {
await barButton(page, 'Add to collection').click();
await expect(addDialog(page)).toBeVisible();
await addField(page).fill(name);
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(addDialog(page)).toHaveCount(0);
}
test('Add to collection creates a collection and adds to it ignoring case (CUJ 1–3)', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
for (const file of ['loose.png', 'landscape_01.png', 'experiment_01.png']) await check(page, file);
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Export metadata', 'Clear']);
await barButton(page, 'Add to collection').click();
// The concept's simpleInput with Add (D138) and the existing names as suggestions.
await expect(addDialog(page).locator('label')).toHaveText('Collection name');
await expect(addField(page)).toHaveValue('');
await expect(addField(page)).toHaveAttribute('maxlength', '100');
await expect(addDialog(page).locator('datalist option')).toHaveCount(2);
expect(await addDialog(page).locator('datalist option').evaluateAll(options => options.map(option => option.value)))
.toEqual(['Experiments', 'Landscapes']);
await expect(addDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Add']);
// An empty name keeps the dialog on its field.
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(addField(page)).toBeFocused();
await addField(page).fill(' LoRa testing ');
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(toast(page)).toHaveText('Added 3 images to “LoRa testing”.');
await expect(bar(page).locator('strong')).toHaveText('3 selected');
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('3');
await expect(rows(page)).toHaveCount(3);
// CUJ 2: one of two already in it.
await bar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await check(page, 'loose.png');
await check(page, 'landscape_02.png');
await addChecked(page, 'LoRa testing');
await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”. 1 was already in it.');
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('4');
// CUJ 3: a case variant joins the same collection.
await bar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await check(page, 'landscape_03.png');
await addChecked(page, 'lora TESTING');
await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”.');
await expect(rows(page)).toHaveCount(3);
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('5');
const created = (await listed(request)).find(item => item.name === 'LoRa testing');
expect(created).toMatchObject({ count: 5, missing: 0 });
// Everything already in it.
await addChecked(page, 'LoRa testing');
await expect(toast(page)).toHaveText('All selected images were already in “LoRa testing”.');
});
test('Remove from collection takes the checked images off the open collection (CUJ 5)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await expect(imageCards(page)).toHaveCount(12);
await check(page, 'landscape_01.png');
await check(page, 'landscape_02.png');
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Remove from collection', 'Export metadata', 'Clear']);
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 2 images from “Landscapes”.');
await expect(imageCards(page)).toHaveCount(10);
await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0);
// The removed files left the listing, so the bar has nothing checked.
await expect(bar(page)).toHaveCount(0);
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('10');
// A checked file still listed stays checked across the reload.
await check(page, 'landscape_03.png');
await check(page, 'landscape_04.png');
await page.route(`**${API}/items`, async route => {
if (route.request().method() !== 'DELETE') return route.fallback();
// Remove only landscape_04; landscape_03 stays listed and checked.
const body = route.request().postDataJSON();
await route.continue({ postData: JSON.stringify({ ...body, items: body.items.filter(item => item.path.endsWith('_04.png')) }) });
});
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 1 image from “Landscapes”.');
await expect(imageCards(page)).toHaveCount(9);
await expect(bar(page).locator('strong')).toHaveText('1 selected');
await expect(page.getByRole('checkbox', { name: 'Select landscape_03.png', exact: true })).toBeChecked();
// No file changed: the plain Images page still lists all of them.
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
});
test('Images without a collection filter has no Remove from collection', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/images`);
await check(page, 'loose.png');
await expect(barButton(page, 'Add to collection')).toBeVisible();
await expect(barButton(page, 'Remove from collection')).toHaveCount(0);
// A saved view on a collection has its filter active: Remove shows (design §5).
await page.goto(`${workspace}#/saved/${manifest.saved_view.id}`);
await expect(imageCards(page)).toHaveCount(8);
await check(page, 'experiment_01.png');
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 1 image from “Experiments”.');
await expect(imageCards(page)).toHaveCount(7);
});
test('a failed add keeps the selection; a stale source reloads the images', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
let reply = { status: 500, body: { success: false, error: 'The collection could not be changed.' } };
await page.route(`**${API}/items`, route => route.fulfill({ status: reply.status, contentType: 'application/json',
body: JSON.stringify(reply.body) }));
await check(page, 'loose.png');
await check(page, 'landscape_05.png');
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('The images could not be added. Try again.');
await expect(bar(page).locator('strong')).toHaveText('2 selected');
await expect(rows(page)).toHaveCount(2);
reply = { status: 404, body: { success: false, code: 'files-not-found', error: 'x' } };
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.');
await expect(bar(page).locator('strong')).toHaveText('2 selected');
reply = { status: 409, body: { success: false, code: 'source-changed', error: 'x' } };
const reloaded = page.waitForRequest(request => request.url().includes('/prompt_manager/gallery/subfolders'));
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.');
await reloaded;
await expect(imageCards(page)).toHaveCount(21);
await expect(bar(page)).toHaveCount(0);
});
test('more than 500 or an unloaded "all matching" selection is refused with the cap', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
// Every loaded image checked and the listing says more match: "Select all N matching" (#392, D135).
await page.route('**/prompt_manager/images/output?*', async route => {
// A next page never answers, so the listing stays at its loaded 21 of 900.
if (new URL(route.request().url()).searchParams.has('cursor')) return;
const response = await route.fetch();
const data = await response.json();
await route.fulfill({ response, json: { ...data, total: 900, has_more: true, next_cursor: 'more' } });
});
await page.reload();
await expect(imageCards(page)).toHaveCount(21);
await page.locator('#image-grid .image-open').first().focus();
await page.keyboard.press('ControlOrMeta+a');
await bar(page).getByRole('button', { name: 'Select all 900 matching', exact: true }).click();
await expect(bar(page).locator('strong')).toHaveText('All 900 matching selected');
await barButton(page, 'Add to collection').click();
await expect(toast(page)).toHaveText('Select at most 500 images.');
await expect(addDialog(page)).toHaveCount(0);
});
for (const theme of ['dark', 'light']) {
test(`the Add to collection dialog fits 390x844 ${theme}`, async ({ page, request }) => {
await seed(request);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await check(page, 'loose.png');
await expect(barButton(page, 'Add to collection')).toBeVisible();
await barButton(page, 'Add to collection').click();
await expect(addDialog(page)).toBeVisible();
const fits = await addDialog(page).evaluate(dialog => {
const box = dialog.getBoundingClientRect();
return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth;
});
expect(fits).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await addField(page).fill('Phone');
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(toast(page)).toHaveText('Added 1 image to “Phone”.');
await expect(row(page, 'Phone')).toBeVisible();
});
}
// #331 D139 (design §8): the shared inspector's Collections… in the Images side panel and in the viewer.
const panel = page => page.locator('#image-inspector');
const collectionsDialog = page => page.getByRole('dialog', { name: 'Collections', exact: true });
const choice = (page, name) => collectionsDialog(page).getByRole('checkbox', { name, exact: true });
const newCollection = page => collectionsDialog(page).getByRole('textbox', { name: 'New collection', exact: true });
async function membership(request, path) {
const [image] = (await (await request.get(`/prompt_manager/images/output?mode=workspace&q=${encodeURIComponent(path)}`)).json()).images;
const query = new URLSearchParams({ root_index: String(image.root_index), root_scope: image.root_scope, path: image.path });
const response = await request.get(`${API}/membership?${query}`);
await expect(response).toBeOK();
return (await response.json()).collection_ids;
}
test('Collections… in the side panel adds, removes and creates for one image (CUJ 6)', async ({ page, request }) => {
const manifest = await seed(request);
const { Landscapes, Experiments } = manifest.collections;
await page.goto(`${workspace}#/images`);
// The newest image, landscape_01, is the panel's.
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('landscape_01.png');
const open = panel(page).getByRole('button', { name: 'Collections…', exact: true });
await expect(panel(page).locator('.detail-footer > button').last()).toHaveText('Collections…');
await open.click();
await expect(collectionsDialog(page)).toBeVisible();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(collectionsDialog(page).locator('label.tag-suggestion')).toHaveText(['Experiments', 'Landscapes']);
await expect(collectionsDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
await choice(page, 'Landscapes').uncheck();
await choice(page, 'Experiments').check();
await newCollection(page).fill('Grids');
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
await expect(toast(page)).toHaveText('Collections updated.');
await expect(open).toBeFocused();
await expect(row(page, 'Grids').locator('.nav-count')).toHaveText('1');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11');
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('9');
const grids = (await listed(request)).find(item => item.name === 'Grids');
expect((await membership(request, 'landscape_01')).sort()).toEqual([Experiments.id, grids.id].sort());
expect(Landscapes.id).not.toBe(grids.id);
// The Images page lists the same files; the dialog opened again shows the new state.
await expect(imageCards(page)).toHaveCount(21);
await open.click();
await expect(choice(page, 'Grids')).toBeChecked();
await expect(choice(page, 'Experiments')).toBeChecked();
await expect(choice(page, 'Landscapes')).not.toBeChecked();
// Save without a change sends nothing.
let writes = 0;
page.on('request', request => { if (request.url().includes(`${API}/items`)) writes++; });
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
expect(writes).toBe(0);
});
test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
await expect(imageCards(page)).toHaveCount(12);
await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click();
const viewer = page.locator('#viewer-dialog[open]');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
// Review L7: no listing request while the viewer is open.
let listings = 0;
const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; };
page.on('request', countListing);
await viewer.locator('#viewer-details').getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
// Review L6: focus moves to the first checkbox once listed.
await expect(choice(page, 'Experiments')).toBeFocused();
await choice(page, 'Landscapes').uncheck();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collections updated.');
await expect(collectionsDialog(page)).toHaveCount(0);
// The viewer stays on the image; the page keeps listing it while the viewer is open.
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11');
await expect(imageCards(page)).toHaveCount(12);
expect(listings, 'no listing request while the viewer is open').toBe(0);
page.off('request', countListing);
await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click();
await expect(viewer).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(11);
await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0);
});
test('a failed membership read says so and Retry reads it again', async ({ page, request }) => {
await seed(request);
let failing = true;
await page.route(`**${API}/membership?*`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
: route.fallback());
await page.goto(`${workspace}#/images`);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert').first()).toHaveText('Collections could not be loaded.');
await expect(collectionsDialog(page).getByRole('checkbox')).toHaveCount(0);
await expect(collectionsDialog(page).getByRole('button', { name: 'Save', exact: true })).toHaveAttribute('aria-disabled', 'true');
failing = false;
await collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
// A failed write keeps the dialog with the current membership.
await page.route(`**${API}/items`, route => route.fulfill({ status: 500, contentType: 'application/json',
body: '{"success":false,"error":"The collection could not be changed."}' }));
await choice(page, 'Experiments').check();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.');
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(choice(page, 'Landscapes')).toBeChecked();
});
test('a file in no collection with no collections at all can start one', async ({ page }) => {
await page.goto(`${workspace}#/images`);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(collectionsDialog(page)).toContainText('No collections yet. Name one below.');
await newCollection(page).fill('First');
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collections updated.');
await expect(rows(page)).toHaveCount(1);
await expect(row(page, 'First').locator('.nav-count')).toHaveText('1');
});
for (const theme of ['dark', 'light']) {
test(`the Collections dialog fits 390x844 ${theme}`, async ({ page, request }) => {
await seed(request);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
const fits = await collectionsDialog(page).evaluate(dialog => {
const box = dialog.getBoundingClientRect();
return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth;
});
expect(fits).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
}
// Review M2: on a collection page, a Save that removed the file from the open collection and then failed keeps its
// dialog and message; the listing is read again only once the dialog closes.
test('a partly failed Save from the side panel keeps its dialog until it closes', async ({ page, request }) => {
const manifest = await seed(request);
const { Experiments, Landscapes } = manifest.collections;
// experiment_01 is in both collections; Experiments sorts first, so its remove is sent before Landscapes'.
const [image] = (await (await request.get('/prompt_manager/images/output?mode=workspace&q=experiment_01')).json()).images;
const item = { root_index: image.root_index, root_scope: image.root_scope, path: image.path };
await expect(await request.post(`${API}/items`, { data: { collection_id: Landscapes.id, items: [item] } })).toBeOK();
await page.route(`**${API}/items`, route => {
const body = route.request().postDataJSON();
return route.request().method() === 'DELETE' && body.collection_id === Landscapes.id
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback();
});
await page.goto(`${workspace}#/collection/${Experiments.id}`);
await expect(imageCards(page)).toHaveCount(8);
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('experiment_01.png');
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
await choice(page, 'Experiments').uncheck();
await choice(page, 'Landscapes').uncheck();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.');
// The remove from Experiments went through; the dialog shows it and the page has not reloaded under it.
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(imageCards(page)).toHaveCount(8);
await collectionsDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(7);
await expect(card(page, 'collections/experiment_01.png')).toHaveCount(0);
});
+8 -2
View File
@@ -1,6 +1,6 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
import { seedConcept, openConcept, openProduction, startInGrid } from './concept-inventory.mjs';
const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
@@ -569,6 +569,10 @@ test('source and folder discovery failure stays visible and Import Rescan restor
async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
// #430: the page first asks the server where a stale file is now. These tests cover the manual Refresh, so the
// file is never found; workspace-stale-source.spec.mjs covers a renumbered file that is found again.
await page.route('**/prompt_manager/images/rescope', route => route.fulfill({ status: 409,
json: { success: false, code: 'source-changed', error: 'The source of this image changed or was removed.' } }));
await page.goto(workspace);
const source = refineDialog(page).getByLabel('Source', { exact: true });
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
@@ -810,6 +814,8 @@ test('leaving during Rescan cancels it without replacing the next workspace', as
test('Images uses approved geometry while keeping real controls', async ({page,request}) => {
await request.post('/__test__/reset',{data:{workspace_visual:true}});
// The approved Grid geometry (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({width:1600,height:1100});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
@@ -1787,7 +1793,7 @@ test('card checkboxes select images into the concept bar and Clear empties it',
await checkbox(first, 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
// D85: Add tags (the concept's) and Auto-tag selected lead the real actions.
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
// Toggling neither opens the viewer, nor inspects, nor moves focus.
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(checkbox(first, 'same.png')).toBeFocused();
@@ -0,0 +1,294 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
// #437 D148: select prompts on the Library shelf and set their category in one step (the legacy admin's Set Category).
const route='/prompt_manager/workspace#/library';
const cards=page=>page.locator('article[data-prompt-id]');
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
const bar=page=>page.locator('#library-bulkbar > .bulkbar');
const dialog=page=>page.getByRole('dialog',{name:'Categorize selected prompts',exact:true});
const toast=page=>page.locator('.toast');
const heading=page=>page.locator('#workspace-main h1');
const tagsDialog=page=>page.getByRole('dialog',{name:'Tag selected prompts',exact:true});
const chips=(page,id)=>card(page,id).locator('.tags > .chip');
async function addTags(page,value) {
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
await expect(tagsDialog(page)).toBeVisible();
await tagsDialog(page).locator('#action-input').fill(value);
await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
}
async function shelf(request) {
const body=await(await request.get('/prompt_manager/library?limit=100')).json();
return body.items;
}
/** Every POST to the bulk category route, with its body. */
function recordWrites(page) {
const writes=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/category')writes.push(item.postDataJSON());});
return writes;
}
/** The first mounted card's index once two frames in a row agree (the shelf's window follows scrolling a frame later). */
async function stableFirstIndex(page) {
return page.evaluate(()=>new Promise(resolve=>{
const first=()=>document.querySelector('article[data-prompt-id]')?.dataset.index;
let previous=first();
const check=()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{const now=first();if(now===previous)resolve(Number(now));else{previous=now;check();}}));
check();
}));
}
async function setCategory(page,value) {
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
await expect(dialog(page)).toBeVisible();
await dialog(page).locator('#action-input').fill(value);
await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
}
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('Set category writes the selected prompts on the server, reloads the shelf and clears the selection',async({page,request})=>{
const items=await shelf(request);const [first,second,third]=items;
const writes=recordWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
// The checkbox is named after the card's title, as the Images overlay names its file.
await expect(box(page,first.id)).toHaveAccessibleName(new RegExp(`^Select `));
await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected');
await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected');
// Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling).
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Clear']);
await setCategory(page,' Studio set ');
await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.');
await expect(dialog(page)).toHaveCount(0);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
for(const item of [first,second]) await expect(card(page,item.id).locator('.card-title .chip')).toHaveText('Studio set');
await expect(card(page,third.id).locator('.card-title .chip')).toHaveText(third.category || 'Uncategorized');
await expect(box(page,first.id)).not.toBeChecked();
const after=await shelf(request);
expect(after.filter(item=>item.category==='Studio set').map(item=>item.id).sort()).toEqual([first.id,second.id].sort());
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[first.id,second.id],category:'Studio set',expected_epoch:epoch}]);
// The toolbar's category list now offers the new value, and so does the next dialog.
await expect(page.locator('#category-filter option',{hasText:'Studio set'})).toHaveCount(1);
await box(page,third.id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
await expect(dialog(page).locator('datalist option[value="Studio set"]')).toHaveCount(1);
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
expect(writes).toHaveLength(1);
});
test('a blank or control-character category keeps the dialog open; Clear and Escape clear the selection',async({page,request})=>{
const items=await shelf(request);const writes=recordWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
const field=dialog(page).locator('#action-input');
await field.fill(' ');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(dialog(page)).toBeVisible();await expect(field).toBeFocused();
await field.fill('bad\tvalue');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(dialog(page)).toBeVisible();
expect(await field.evaluate(node=>node.validationMessage)).toBe('A category cannot contain control characters.');
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
expect(writes).toHaveLength(0);
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
await expect(box(page,items[0].id)).not.toBeChecked();
await box(page,items[1].id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).focus();await page.keyboard.press('Escape');
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
// A filter change replaces the shelf and clears the selection too.
await box(page,items[1].id).check();await expect(bar(page)).toBeVisible();
await page.locator('#search').fill('portrait');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
});
test('Shift+click selects a range across windowed cards, Tab reaches the checkbox at the window edge, and a return keeps the fence',async({page,request,isMobile})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
const writes=recordWrites(page);
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(let index=0;index<3;index++) {
await expect(more).toHaveAttribute('aria-disabled','false');
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(more).toHaveAttribute('aria-disabled','false');
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,firstId).check();
// Windowed: fewer cards are mounted than are loaded.
expect(await cards(page).count()).toBeLessThan(160);
// Tab from the last mounted card's last control lands on the next (unmounted) card's checkbox.
const last=cards(page).last();const lastIndex=Number(await last.getAttribute('data-index'));
await last.locator('button').last().focus();await page.keyboard.press('Tab');
const next=page.locator(`article[data-index="${lastIndex+1}"]`);
await expect(next.locator('.image-overlays input')).toBeFocused();
// Shift+click that card's checkbox: every card from the first to it is selected, mounted or not.
await next.locator('.image-overlays > label').click({modifiers:['Shift']});
await expect(bar(page).locator('strong')).toHaveText(`${lastIndex+2} selected`);
// Shift+Tab from a checkbox whose previous card is unmounted lands on that card's last control.
// Halfway down the loaded cards (short of the continuation, so no page loads), once the window stops moving.
await page.evaluate(()=>{const grid=document.querySelector('.library-grid');scrollTo(0,grid.getBoundingClientRect().top+scrollY+grid.offsetHeight/2);});
await expect.poll(()=>page.locator('article[data-index="0"]').count()).toBe(0);
const topIndex=await stableFirstIndex(page);
await page.locator(`article[data-index="${topIndex}"] .image-overlays input`).focus();await page.keyboard.press('Shift+Tab');
await expect(page.locator(`article[data-index="${topIndex-1}"] .card-footer .row-actions > button`).last()).toBeFocused();
// Leaving for a prompt's history and coming back restores the shelf without a read; its epoch still fences.
await page.locator(`article[data-index="${topIndex}"]`).getByRole('button',{name:'Explore history',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(cards(page).first()).toBeVisible();await expect(page.locator('#library-bulkbar')).toBeEmpty();
const visible=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,visible).check();await setCategory(page,'Returned');
await expect(toast(page)).toHaveText('Category “Returned” set on 1 prompt.');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[visible],category:'Returned',expected_epoch:epoch}]);
});
test('a restore the page did not see refuses the write, changes nothing and reloads the shelf',async({page,request})=>{
await armUnseenRestores(page);
const items=await shelf(request);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
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();
const reload=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor');});
await setCategory(page,'After restore');
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No category was changed.');
await reload;await expect(page.locator('#library-bulkbar')).toBeEmpty();
expect((await shelf(request)).filter(item=>item.category==='After restore')).toEqual([]);
// The reloaded shelf reads the new epoch: the next write goes through.
await box(page,items[0].id).check();await setCategory(page,'After reload');
await expect(toast(page)).toHaveText('Category “After reload” set on 1 prompt.');
});
test('an answer that may follow a committed write says so and reloads; a refused write keeps the selection',async({page,request})=>{
const items=await shelf(request);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await page.route('**/prompt_manager/bulk/category',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1});
await setCategory(page,'Refused');
await expect(toast(page)).toHaveText('Category could not be set. Try again.');
await expect(bar(page).locator('strong')).toHaveText('1 selected');
let sent=false;
await page.route('**/prompt_manager/bulk/category',async route=>{sent=true;await route.fetch();await route.abort('failed');},{times:1});
await setCategory(page,'Unconfirmed');
await expect(toast(page)).toHaveText('The category change could not be confirmed. The Library was reloaded.');
expect(sent).toBe(true);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
await expect(card(page,items[0].id).locator('.card-title .chip')).toHaveText('Unconfirmed');
});
// #442: Add tags writes prompt tags (never image tags) on the selected prompts.
function recordTagWrites(page) {
const writes=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/tags')writes.push(item.postDataJSON());});
return writes;
}
async function promptTags(request,id) {
return (await shelf(request)).find(item=>item.id===id).tags;
}
test('Add tags adds prompt tags to the selected prompts, suggests the vocabulary and reloads the shelf',async({page,request})=>{
const items=await shelf(request);const [first,second,third]=items;const writes=recordTagWrites(page);
const vocabulary=(await(await request.get('/prompt_manager/tags')).json()).tags;
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,first.id).check();await box(page,second.id).check();
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
await expect(tagsDialog(page).locator('datalist option')).toHaveCount(vocabulary.length);
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(tagsDialog(page)).toHaveCount(0);
await addTags(page,' studio-lit , Night ,studio-lit');
await expect(toast(page)).toHaveText('Added “studio-lit, Night” to 2 prompts.');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
for(const item of [first,second]) {
await expect(chips(page,item.id).filter({hasText:'studio-lit'})).toHaveCount(1);
expect(await promptTags(request,item.id)).toEqual(expect.arrayContaining(['studio-lit','Night']));
}
expect(await promptTags(request,third.id)).not.toContain('studio-lit');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[first.id,second.id],tags:['studio-lit','Night'],expected_epoch:epoch}]);
// A second add of the same names changes nothing and says so.
await box(page,first.id).check();await addTags(page,'Studio-Lit');
await expect(toast(page)).toHaveText('1 prompt already had “Studio-Lit”.');
});
test('the Add tags field states what it refuses, and a restore the page did not see adds nothing',async({page,request})=>{
await armUnseenRestores(page);
const items=await shelf(request);const writes=recordTagWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
const field=tagsDialog(page).locator('#action-input');
for(const [value,message] of [[' , ','Enter at least one tag.'],['x'.repeat(51),'A tag can have at most 50 characters.'],
[Array.from({length:21},(_,i)=>`t${i}`).join(','),'Enter at most 20 tags.']]) {
await field.fill(value);await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(tagsDialog(page)).toBeVisible();
expect(await field.evaluate(node=>node.validationMessage)).toBe(message);
}
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();
expect(writes).toHaveLength(0);
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 addTags(page,'after-restore');
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No tags were added.');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
expect((await(await request.get('/prompt_manager/tags')).json()).tags).not.toContain('after-restore');
});
// #444 ruling (D151): select every loaded prompt, as the legacy page's select-all checkbox did for its page.
test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed ones included',async({page,request,isMobile})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
const writes=recordTagWrites(page);
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(let index=0;index<3;index++) {
await expect(more).toHaveAttribute('aria-disabled','false');
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(more).toHaveAttribute('aria-disabled','false');
const loaded=160;expect(await cards(page).count()).toBeLessThan(loaded);
// In the search box Ctrl/Cmd+A keeps its text meaning: it selects the box's text, no prompt.
await page.locator('#search').focus();await page.keyboard.press('ControlOrMeta+a');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
// One checked card offers the rest; the offer selects every loaded prompt and focus moves to the first action.
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,firstId).check();
const offer=bar(page).getByRole('button',{name:`Select all ${loaded} loaded`,exact:true});
await expect(bar(page).locator(':scope > *').nth(1)).toHaveText(`Select all ${loaded} loaded`);
await offer.click();
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
await expect(offer).toHaveCount(0);
await expect(bar(page).getByRole('button',{name:'Add tags',exact:true})).toBeFocused();
// Another batch arrives unchecked: the offer comes back with the new N.
{
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(bar(page).getByRole('button',{name:`Select all ${loaded+40} loaded`,exact:true})).toBeVisible();
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
// Escape in the grid clears; Ctrl/Cmd+A in the grid selects them all again.
const title=cards(page).first().locator('.card-title > button');
await title.focus();await page.keyboard.press('Escape');
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(title).toBeFocused();
await page.keyboard.press('ControlOrMeta+a');
const all=loaded+40;
await expect(bar(page).locator('strong')).toHaveText(`${all} selected`);
// While the write runs, every bar action and the offer are aria-disabled.
await box(page,firstId).uncheck();
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1});
await addTags(page,'everyone');
for(const name of ['Add tags','Set category',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
release();
await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`);
expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1);
});
+2 -1
View File
@@ -618,7 +618,8 @@ test('card matches the concept and never wraps the category mid-word',async({pag
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
await expect(card).toHaveClass('prompt-card');
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
// D148 (#437): the concept's Images selection overlay leads the card.
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['image-overlays','card-art','card-content','card-footer']);
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
@@ -0,0 +1,236 @@
// #430: an open workspace page whose sources changed under it. A ComfyUI restart no longer changes any scope (the
// key is persisted, see tests/test_api_workspace_restart.py); what is left is a real source change. Renumbered
// sources (`/__test__/sources/reverse`) are found again: the Tag image dialog stays open on its file and works, and
// the panel keeps the file. A scope that cannot be mapped (`/__test__/scope-key/rotate`, a lost key) blocks Generate
// and Save visibly, says why beside them and offers Refresh. Both commands are undone by every reset.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
const images = '/prompt_manager/workspace#/images';
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png';
const SOURCE_CHANGED = 'The source changed. Refresh the image collection before tagging this file.';
const card = (page, path, root = 0) => page.locator(`article[data-image-key="${root}:${path}"]`);
const cards = page => page.locator('.gallery-body article[data-image-key]');
const tagButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
const infoButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
const primary = page => dialog(page).locator('.dialog-actions .ca-primary');
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
const status = page => dialog(page).locator('#tag-generation-status');
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const toast = page => page.locator('#workspace-toast');
const answered = (page, suffix, method = 'POST') => page.waitForResponse(item => new URL(item.url()).pathname.endsWith(suffix) && item.request().method() === method);
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
page.staleErrors = [];
page.on('pageerror', error => page.staleErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
expect(page.staleErrors).toEqual([]);
// The next spec file starts on the standard sources and key.
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
});
async function openTag(page, path = FIRST, root = 0) {
const observed = answered(page, '/image-annotations/observe');
await tagButton(page, path, root).click();
await expect(dialog(page)).toBeVisible();
return observed;
}
async function savedTags(request, path, root) {
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
const item = listing.images.find(image => image.path === path && image.root_index === root);
const target = { root_index: root, root_scope: item.root_scope, path };
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
return observed.tags;
}
test('renumbered sources under an open Tag dialog: it finds the file again, keeps the draft and Generate works', async ({ page, request }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
expect((await openTag(page)).status()).toBe(200);
await manual(page).fill('kept_draft');
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
// Generate is refused (the held scope names another index now); the page asks where the file is and retargets.
const refused = answered(page, '/image-annotations/suggest');
const rescoped = answered(page, '/images/rescope');
const observedAgain = answered(page, '/image-annotations/observe');
await generate(page).click();
expect((await refused).status()).toBe(409);
const mapped = await (await rescoped).json();
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
const observed = await observedAgain;
expect(observed.status()).toBe(200);
expect(observed.request().postDataJSON().target).toEqual({ root_index: 1, root_scope: mapped.image.root_scope, path: FIRST });
await expect(dialog(page)).toBeVisible();
await expect(notice(page)).toHaveCount(0);
await expect(manual(page)).toHaveValue('kept_draft');
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
// The page listed again under the new numbering and kept the file selected.
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
await expect(card(page, FIRST, 0)).toHaveCount(0);
const suggested = answered(page, '/image-annotations/suggest');
await generate(page).click();
expect((await suggested).status()).toBe(200);
await expect(chips(page).first()).toBeVisible();
const saved = answered(page, '/image-annotations/tags', 'PUT');
await primary(page).click();
expect((await saved).request().postDataJSON().target.root_index).toBe(1);
await expect(toast(page)).toContainText('tags saved for dunes_00001.png.');
expect(await savedTags(request, FIRST, 1)).toContain('kept_draft');
});
test('the inspector finds a renumbered file again; checked files from the old numbering are cleared', async ({ page, request }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await card(page, FIRST).getByRole('checkbox', { name: /^Select / }).check();
await expect(page.locator('.bulkbar')).toBeVisible();
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const refused = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
new URL(item.url()).searchParams.get('root') === '0');
const reread = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
new URL(item.url()).searchParams.get('root') === '1');
await infoButton(page, SECOND).click();
expect((await refused).status()).toBe(409);
expect((await reread).status()).toBe(200);
await expect(card(page, SECOND, 1)).toHaveClass(/\bselected\b/);
await expect(panel(page)).not.toContainText('The source changed');
await expect(panel(page).getByRole('button', { name: 'Refresh image collection', exact: true })).toHaveCount(0);
// A checked key of the old list could name a same-named file in another source: nothing stays checked.
await expect(page.locator('.bulkbar')).toBeHidden();
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
});
test('a scope that cannot be mapped: Generate and Save are visibly disabled, the reason and Refresh sit beside them', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toBeVisible();
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
const rescoped = answered(page, '/images/rescope');
expect((await openTag(page)).status()).toBe(409);
expect((await rescoped).status()).toBe(409);
await expect(notice(page)).toContainText(SOURCE_CHANGED);
await expect(status(page)).toHaveText('');
const refresh = notice(page).getByRole('button', { name: 'Refresh image collection', exact: true });
await expect(refresh).toBeVisible();
for (const button of [generate(page), primary(page)]) {
await expect(button).toHaveAttribute('aria-disabled', 'true');
await expect(button).toHaveCSS('opacity', '0.55');
}
// The notice is its own first row of the sticky actions, above the buttons.
const rows = await dialog(page).locator('.dialog-actions').evaluate(actions => {
const noticeBox = actions.firstElementChild.getBoundingClientRect();
return { first: actions.firstElementChild.className, below: [...actions.querySelectorAll(':scope > button')].every(button => button.getBoundingClientRect().top >= noticeBox.bottom - 1) };
});
expect(rows).toEqual({ first: 'tag-action-notice', below: true });
const preview = dialog(page).locator('.tag-image-preview');
await expect(preview).toHaveText('Preview unavailable');
await expect(preview).toHaveAttribute('role', 'img');
const suggest = [];
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggest.push(item.url()); });
// Pressed anyway (Playwright refuses an aria-disabled target, so the press is dispatched), nothing is sent.
await generate(page).dispatchEvent('click');
await primary(page).dispatchEvent('click');
expect(suggest).toEqual([]);
await page.screenshot({ path: test.info().outputPath('stale-source-tag-dialog.png') });
// Refresh closes the dialog and lists again with the current scopes; Tag then works.
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
await refresh.click();
await expect(dialog(page)).toHaveCount(0);
await relisted;
await expect(card(page, FIRST)).toBeVisible();
expect((await openTag(page)).status()).toBe(200);
await expect(notice(page)).toHaveCount(0);
await expect(generate(page)).not.toHaveAttribute('aria-disabled', 'true');
});
test('an unmappable inspector read keeps its visible Refresh, and Refresh recovers the panel', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
const rescoped = answered(page, '/images/rescope');
await infoButton(page, SECOND).click();
expect((await rescoped).status()).toBe(409);
await expect(panel(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
await panel(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();
await relisted;
await expect(cards(page).first()).toHaveClass(/\bselected\b/);
await expect(panel(page)).not.toContainText('The source changed');
});
test('a Load more cursor the server cannot verify offers Refresh instead of a dead Retry', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 60 } })).toBeOK();
await page.goto(images);
await expect(cards(page).first()).toBeVisible();
await expect(page.locator('#gallery-continuation')).toContainText('more load as you scroll');
await expect(await request.post('/__test__/scope-key/rotate')).toBeOK();
const stale = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.has('cursor'));
await page.locator('#gallery-continuation').scrollIntoViewIfNeeded();
const answer = await stale;
expect(answer.status()).toBe(400);
expect((await answer.json()).code).toBe('cursor-stale');
const continuation = page.locator('#gallery-continuation');
await expect(continuation).toContainText('The image collection changed. Refresh image collection to continue.');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && !new URL(item.url()).searchParams.has('cursor'));
await continuation.getByRole('button', { name: 'Refresh image collection', exact: true }).click();
expect((await relisted).status()).toBe(200);
await expect(continuation).not.toContainText('The image collection changed');
});
test('a viewer whose media source was renumbered closes onto the file, kept selected', async ({ page, request }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const rescoped = answered(page, '/images/rescope');
await card(page, FIRST).locator('.image-open').click();
const mapped = await (await rescoped).json();
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
// Opened again, it shows the file under its current source.
await card(page, FIRST, 1).locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open]')).toBeVisible();
await expect(page.locator('#viewer-dialog').getByText('Media could not be loaded. Retry this file.', { exact: true })).toBeHidden();
await expect.poll(() => page.locator('#viewer-dialog img').first().evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
});
test('a collection page finds a renumbered member again and stays on its members', async ({ page, request }) => {
// Members are stored by root path, not index (#331), so a renumbering keeps them; the page's scopes go stale.
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
const first = listing.images.find(image => image.path === FIRST && image.root_index === 0);
const name = `Stale ${test.info().workerIndex}-${Date.now()}`;
const added = await request.post('/prompt_manager/collections/items', { data: { name, items: [
{ root_index: 0, root_scope: first.root_scope, path: FIRST }] } });
await expect(added).toBeOK();
const collections = (await (await request.get('/prompt_manager/collections')).json()).collections;
const { id } = collections.find(item => item.name === name);
await page.goto(`/prompt_manager/workspace#/collection/${id}`);
await expect(cards(page)).toHaveCount(1);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const rescoped = answered(page, '/images/rescope');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('collection') === String(id));
await card(page, FIRST).locator('.image-open').click();
expect((await (await rescoped).json()).image.root_index).toBe(1);
expect((await relisted).status()).toBe(200);
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
await expect(cards(page)).toHaveCount(1);
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
// Tag image works on the member under its new source.
expect((await openTag(page, FIRST, 1)).status()).toBe(200);
await expect(notice(page)).toHaveCount(0);
});
+106 -9
View File
@@ -481,17 +481,19 @@ test('defaults apply to a new visit but not to the current view or a saved view'
await page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'List', exact: true }).click();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
await openAppearance(page);
await expect(page.locator('#default-view')).toHaveValue('grid');
await page.locator('#default-view').selectOption('masonry');
// #451: a fresh browser's Default layout is Masonry.
await expect(page.locator('#default-view')).toHaveValue('masonry');
await page.locator('#default-view').selectOption('grid');
await page.locator('#density').selectOption('compact');
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await saveAppearance(page, 'Saved in this browser.');
const stored = await storedSettings(page);
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['masonry', 'compact']);
// Back in Images (the page session's last visit): the current view keeps its own layout and size.
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['grid', 'compact']);
// Back in Images (the page session's last visit): the toolbar's List is the current view's own and stays; the
// size the toolbar never changed follows the newer default (#451).
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
// The saved view opens as before.
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(imageCards(page).first()).toBeVisible();
@@ -501,15 +503,109 @@ test('defaults apply to a new visit but not to the current view or a saved view'
await openImages(page);
await page.reload();
await expect(imageCards(page).first()).toBeVisible();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'masonry');
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }))
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Grid', exact: true }))
.toHaveAttribute('aria-pressed', 'true');
} finally {
await request.delete(`/prompt_manager/saved-views/${view.id}`);
}
});
/** Choose a Default layout in System › Appearance and save it. */
async function saveDefaultLayout(page, value) {
await openAppearance(page);
await page.locator('#default-view').selectOption(value);
await saveAppearance(page, 'Saved in this browser.');
}
const imageLayoutOf = page => page.locator('#workspace-main .image-grid');
const layoutButton = (page, name) => page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name, exact: true });
test('a fresh browser opens Images in Masonry (#451)', async ({ page }) => {
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
// Nothing was stored for it: the default is the gallery settings' own.
expect(await page.evaluate(() => localStorage.getItem('gallerySettings'))).toBeNull();
// A stored explicit choice is kept.
await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' })));
await page.reload();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
});
test('a Default layout saved later reaches the next Images visit in this tab (#451)', async ({ page }) => {
// The visit starts from Grid, then System saves Masonry without a reload.
await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
await page.locator('#system-nav a[data-route="system"]').click();
await expect(page.locator('#workspace-main .settings-layout')).toBeVisible();
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
await expect(page.locator('#timeout')).toBeEnabled();
await page.locator('#default-view').selectOption('masonry');
await saveAppearance(page, 'Saved in this browser.');
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
});
test('a Default layout saved in another tab reaches this tab\'s next Images visit (#451)', async ({ page, context }) => {
await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
const other = await context.newPage();
try {
await saveDefaultLayout(other, 'list');
} finally {
await other.close();
}
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
// A toolbar choice afterwards is this view's own again: a return keeps it.
await layoutButton(page, 'Masonry').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
});
// #451 D157: a cover crop keeps a portrait image's top; Masonry shows the whole image and keeps the centre.
test('cover crops keep the top of a portrait image; Masonry stays centred (#451)', async ({ page }) => {
const position = locator => locator.evaluate(node => getComputedStyle(node).objectPosition);
const portrait = page.locator('#workspace-main article.media-card .image-open img[width="1024"][height="1536"]').first();
await openImages(page);
await layoutButton(page, 'Grid').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
await expect(portrait).toBeVisible();
expect(await position(portrait)).toBe('50% 20%');
await layoutButton(page, 'List').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
expect(await position(portrait)).toBe('50% 20%');
// The side panel's cover (also History's media inspector).
await portrait.locator('xpath=ancestor::article[1]').locator('.image-card-actions button').first().click();
await expect(panel(page).locator('.detail-cover')).toBeVisible();
expect(await position(panel(page).locator('.detail-cover'))).toBe('50% 20%');
await layoutButton(page, 'Masonry').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
expect(await position(portrait)).toBe('50% 50%');
// The Library's card art and a prompt history's shared gallery.
await page.goto(`${workspace}#/library`);
const art = page.locator('#workspace-main .prompt-card .card-art img').first();
await expect(art).toBeVisible();
expect(await position(art)).toBe('50% 20%');
await page.goto(`${workspace}#/library/${page.manifest.prompts[0].positive_id}`);
await expect(imageCards(page).first()).toBeVisible();
expect(await position(imageCards(page).first().locator('.image-open img'))).toBe('50% 20%');
});
test('the defaults reach the Images page only; embedded galleries keep their own presentation', async ({ page }) => {
await openAppearance(page);
await page.locator('#default-view').selectOption('list');
@@ -787,12 +883,13 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
await expect(page.locator('#default-view')).toHaveValue('list');
const reset = page.locator('#workspace-main .operation').filter({ hasText: 'Reset display defaults' }).getByRole('button', { name: 'Reset', exact: true });
await reset.click();
await expect(page.locator('#default-view')).toHaveValue('grid');
// #451 D157: the default layout is Masonry.
await expect(page.locator('#default-view')).toHaveValue('masonry');
await expect(page.locator('#video-muted')).toBeChecked();
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await saveAppearance(page, 'Saved in this browser.');
const stored = await storedSettings(page);
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'masonry',
videoMute: true, showImageInfo: true });
});
@@ -73,7 +73,7 @@ const jobStart = page => page.waitForRequest(item => item.method() === 'POST' &&
test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => {
await select(page, FIRST);
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
});
test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => {
+4 -1
View File
@@ -21,6 +21,8 @@ const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model'
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
const status = page => dialog(page).locator('#tag-generation-status');
// D147: the reason a state blocks Save, on its own row in the sticky actions.
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
const toast = page => page.locator('#workspace-toast');
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
@@ -728,7 +730,8 @@ test('D116: Retry leads the actions after an unconfirmed save and resends the sa
let lost = true;
await page.route('**/prompt_manager/image-annotations/tags', route => (lost ? (lost = false, route.fulfill({ status: 504, body: 'Gateway Timeout' })) : route.continue()));
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
await expect(status(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
await expect(notice(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
await expect(status(page)).toHaveText('');
const inventory = await page.locator('dialog#modal').evaluate(taggingInventory);
expect(inventory.actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
+3
View File
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { startInGrid } from './concept-inventory.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const workspace = '/prompt_manager/workspace';
@@ -415,6 +416,8 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
test('supplemental extreme zoom grows real footers preserves the visible anchor and resets reservations', async ({ page, request }) => {
await request.post('/__test__/reset', { data: { workspace_visual: true } });
// The Grid's footer reservations (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
await expect(cards).toHaveCount(24);
+10 -2
View File
@@ -194,9 +194,16 @@ def tagging_snapshot(api: PromptManagerAPI) -> dict:
def register_tagging_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
routes: web.RouteTableDef,
api: PromptManagerAPI,
root: Path,
model,
on_reset=lambda: None,
) -> None:
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input.
``on_reset`` runs first on every tagging reset (the source-change undo).
"""
# The fake download (``/__test__/tagging/download``): waits on ``event``,
# then answers ``result``. Every reset sets the event and puts back the
# model's raising default, so no test can leave a worker hanging.
@@ -230,6 +237,7 @@ def register_tagging_commands(
raise web.HTTPBadRequest()
if not 0 <= extra <= MAX_EXTRA_IMAGES:
raise web.HTTPBadRequest()
on_reset()
model.unavailable = set()
model.missing_packages = {}
model.suggestions = None
+1
View File
@@ -121,6 +121,7 @@
"POST /prompt_manager/images/generate-thumbnails",
"POST /prompt_manager/images/generate-thumbnails/progress",
"POST /prompt_manager/images/link",
"POST /prompt_manager/images/rescope",
"POST /prompt_manager/images/reveal",
"POST /prompt_manager/jobs/autotag",
"POST /prompt_manager/jobs/duplicates",
+5 -2
View File
@@ -64,12 +64,15 @@ test("infinite scrolling is off by default and must be a boolean", () => {
test("the workspace keys have their own defaults and validation", () => {
const { WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES } = require("../../web/js/gallery-settings.js");
assert.equal(DEFAULT_SETTINGS.workspaceLayout, "grid");
// #451: Images opens in Masonry unless the browser chose otherwise.
assert.equal(DEFAULT_SETTINGS.workspaceLayout, "masonry");
assert.equal(DEFAULT_SETTINGS.workspaceDensity, "comfortable");
assert.deepEqual(WORKSPACE_LAYOUTS, ["grid", "list", "masonry"]);
assert.deepEqual(WORKSPACE_DENSITIES, ["comfortable", "large", "compact"]);
assert.equal(normalizeGallerySettings({ workspaceLayout: "masonry" }).workspaceLayout, "masonry");
assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "grid");
assert.equal(normalizeGallerySettings({ workspaceLayout: "grid" }).workspaceLayout, "grid");
assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "masonry");
assert.equal(normalizeGallerySettings(null).workspaceLayout, "masonry");
assert.equal(normalizeGallerySettings({ workspaceDensity: "large" }).workspaceDensity, "large");
assert.equal(normalizeGallerySettings({ workspaceDensity: 3 }).workspaceDensity, "comfortable");
// The legacy gallery's own view mode is separate: it has no masonry.
+279
View File
@@ -384,6 +384,285 @@ test('the D117 wrap rule targets only the settings summary button', async () =>
assert.doesNotMatch(css, /#viewer-details > div > button/);
});
// #331 D139 (design §8): the shared inspector's "Collections…" footer button and its checklist dialog.
const collectionsDialog = await import('../../web/js/workspace/inspector-collections.mjs').catch(() => null);
const dialogModule = () => { assert.ok(collectionsDialog, 'inspector-collections.mjs is not implemented'); return collectionsDialog; };
const COLLECTION_SCOPE = 'a'.repeat(64);
const FILE = {root_index:2, root_scope:COLLECTION_SCOPE, path:'a.png'};
function collectionsStore({member = [3], membership, add, remove} = {}) {
const calls = [];
const store = {
calls,
collections:[{id:3, name:'LoRa testing', count:3, missing:0}, {id:5, name:'Portraits', count:1, missing:0}],
membership: async target => { calls.push(['membership', target]); return membership ? membership() : [...member]; },
add: async (where, items) => { calls.push(['add', where, items]); if (add) return add(where, items);
return {collection:{id:where.collectionId ?? 9, name:where.name ?? 'x'}, created:!!where.name, added:1, already:0, notFound:0}; },
remove: async (id, items) => { calls.push(['remove', id, items]); if (remove) return remove(id, items);
return {collection:{id, name:'x'}, removed:1}; },
settled: async () => ({}),
};
return store;
}
function footerButtons(f) {
return f.walk(f.container, n => n.tagName === 'button').map(n => n.textContent.trim());
}
test('inspector shows Collections… only for a rooted file with a store', async () => {
for (const variant of ['panel', 'full']) {
const f = mountedDom(), scope = createScope();
try {
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, collections:collectionsStore()});
view.setState({loading:false, error:false, record:{...record(), historyTarget:'#/library/1'}});
const labels = footerButtons(f);
assert.ok(labels.includes('Collections…'), `${variant}: the button is offered`);
// After Prompt history, as the last footer action.
assert.equal(labels.at(-1), 'Collections…');
assert.equal(labels.at(-2), 'Prompt history');
const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent.trim() === 'Collections…')[0];
assert.equal(button.children[0].tagName, 'svg', 'with the folder icon');
view.dispose();
} finally { scope.dispose(); f.restore(); }
}
// No store (Prompt library galleries, Inspect a file) or a file that is not a listed one: no button.
const local = await localRecord(localPng('Fox Render.png'));
for (const [shown, store] of [[record(), null], [local, collectionsStore()],
[{...record(), identity:{...record().identity, root_scope:null}}, collectionsStore()]]) {
const f = mountedDom(), scope = createScope();
try {
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full', collections:store});
view.setState({loading:false, error:false, record:shown});
assert.ok(!footerButtons(f).includes('Collections…'));
view.dispose();
} finally { scope.dispose(); f.restore(); }
}
});
/** Open the Collections dialog over FILE; returns its nodes by role and helpers. */
function openCollections(store, extra = {}) {
const {openCollectionsDialog} = dialogModule();
const f = mountedDom(), scope = createScope(), toasts = [], changes = [];
let dialog = null, closed = 0;
openCollectionsDialog({scope, collections:store, target:FILE, trigger:null, notify:text => toasts.push(text),
onChanged:change => changes.push(change), openDialog:args => { dialog = args; f.container.append(...args.nodes); },
// As the shell does: closing runs the dialog's onClose once.
closeDialog:() => { closed++; dialog?.onClose?.(); }, ...extra});
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
const find = predicate => f.walk(f.container, predicate);
const boxes = () => find(n => n.tagName === 'input' && n.type === 'checkbox');
const button = text => find(n => n.tagName === 'button' && n.textContent === text)[0];
const text = () => find(n => n.tagName === 'p').map(n => n.textContent).filter(Boolean);
return {f, scope, toasts, changes, get dialog() { return dialog; }, get closed() { return closed; }, settle, find, boxes, button, text,
done() { scope.dispose(); f.restore(); }};
}
test('the Collections dialog loads the membership and lists every collection', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
assert.equal(view.dialog.title, 'Collections');
assert.ok(view.text().includes('Loading collections…'));
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
await view.settle();
assert.deepEqual(store.calls, [['membership', FILE]]);
const labels = view.find(n => n.tagName === 'label' && n.className === 'tag-suggestion');
assert.deepEqual(labels.map(label => label.textContent), ['LoRa testing', 'Portraits']);
assert.deepEqual(view.boxes().map(box => box.checked), [true, false]);
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
assert.equal(field.maxLength, 100);
assert.equal(view.find(n => n.tagName === 'label' && n.htmlFor === field.id)[0].textContent, 'New collection');
assert.deepEqual(view.find(n => n.className === 'dialog-actions')[0].children.map(n => n.textContent), ['Cancel', 'Save']);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
} finally { view.done(); }
});
test('without collections the dialog says to name one', async () => {
const store = collectionsStore({member:[]});
store.collections = [];
const view = openCollections(store);
try {
await view.settle();
assert.ok(view.text().includes('No collections yet. Name one below.'));
assert.equal(view.boxes().length, 0);
} finally { view.done(); }
});
test('a failed membership read offers Retry', async () => {
let failing = true;
const store = collectionsStore({membership:() => { if (failing) throw new TypeError('offline'); return [5]; }});
const view = openCollections(store);
try {
await view.settle();
assert.ok(view.text().includes('Collections could not be loaded.'));
assert.equal(view.button('Retry').hidden, false);
assert.equal(view.boxes().length, 0);
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
failing = false;
view.button('Retry').fire('click');
await view.settle();
assert.deepEqual(view.boxes().map(box => box.checked), [false, true]);
assert.equal(view.button('Retry').hidden, true);
} finally { view.done(); }
});
test('Save sends the new name, each added and each removed collection, in that order', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' Grids ';
view.button('Save').fire('click');
await view.settle(); await view.settle();
assert.deepEqual(store.calls.slice(1), [
['add', {name:'Grids'}, [FILE]],
['add', {collectionId:5}, [FILE]],
['remove', 3, [FILE]],
]);
assert.equal(view.closed, 1);
assert.deepEqual(view.toasts, ['Collections updated.']);
assert.deepEqual(view.changes, [{added:[9, 5], removed:[3]}]);
} finally { view.done(); }
});
test('Save without a change sends nothing and closes', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' ';
view.button('Save').fire('click');
await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership']);
assert.equal(view.closed, 1);
assert.deepEqual(view.toasts, []);
assert.deepEqual(view.changes, []);
} finally { view.done(); }
});
test('a failed Save keeps the dialog, says so and shows the current membership', async () => {
let reads = 0;
const store = collectionsStore({membership:() => (reads++ ? [5] : [3]),
remove:() => { throw Object.assign(new Error('x'), {status:500}); }});
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
view.button('Save').fire('click');
// While it saves, × and Escape keep the dialog and Save is busy.
assert.equal(view.dialog.canClose(), false);
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
for (let index = 0; index < 4; index++) await view.settle();
assert.equal(view.closed, 0);
assert.deepEqual(view.toasts, []);
const alert = view.find(n => n.getAttribute?.('role') === 'alert')[0];
assert.equal(alert.textContent, 'The collections could not be updated. Try again.');
// The add went through; the list shows what the server holds now. Review M2: the page hears about it only once
// the dialog closes, so a listing refresh cannot dispose the dialog before its failure is read.
assert.deepEqual(view.changes, []);
assert.deepEqual(view.boxes().map(box => box.checked), [false, true]);
assert.equal(view.dialog.canClose(), true);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
view.button('Cancel').fire('click');
assert.deepEqual(view.changes, [{added:[5], removed:[]}]);
} finally { view.done(); }
});
test('a name the store cannot take is refused on the field, keeping the edits (review L5)', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
const validity = [];
field.setCustomValidity = text => validity.push(text);
field.reportValidity = () => validity.push('reported');
field.value = 'bad\u0001name';
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership'], 'nothing sent and no re-read');
assert.deepEqual(validity, ['The collections could not be updated. Try again.', 'reported']);
assert.deepEqual(view.boxes().map(box => box.checked), [false, true], 'the checkbox edits stay');
assert.equal(view.closed, 0);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
// Editing the field clears its message.
field.fire('input');
assert.equal(validity.at(-1), '');
} finally { view.done(); }
});
test('after a failed Save, Save waits for the membership re-read (review M1)', async () => {
let release;
const store = collectionsStore({remove:() => { throw new Error('x'); }});
store.settled = () => new Promise(resolve => { release = resolve; });
const view = openCollections(store);
try {
await view.settle();
view.boxes()[0].checked = false;
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.equal(typeof release, 'function', 'the store is settling');
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
assert.ok(view.text().includes('Loading collections…'));
const sent = store.calls.length;
view.button('Save').fire('click');
await view.settle();
assert.equal(store.calls.length, sent, 'no stale plan is sent');
release({});
for (let index = 0; index < 3; index++) await view.settle();
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
} finally { view.done(); }
});
test('a typed name of an existing collection is that collection checkbox (review L4)', async () => {
// "portraits" names Portraits (not a member): one add by id, no name request.
let store = collectionsStore();
let view = openCollections(store);
try {
await view.settle();
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' PORTRAITS ';
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.deepEqual(store.calls.slice(1), [['add', {collectionId:5}, [FILE]]]);
} finally { view.done(); }
// Unchecking LoRa testing while typing its name keeps it: nothing to send.
store = collectionsStore();
view = openCollections(store);
try {
await view.settle();
view.boxes()[0].checked = false;
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = 'lora testing';
view.button('Save').fire('click');
await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership']);
assert.equal(view.closed, 1);
} finally { view.done(); }
});
test('focus starts on the field, moves to the first checkbox once listed, and falls back on close (review L6)', async () => {
const trigger = {isConnected:false, focus() { assert.fail('a detached trigger is not focused'); }};
const fallback = {focused:false, focus() { this.focused = true; }};
const view = openCollections(collectionsStore(), {trigger, focusFallback:() => fallback});
try {
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
assert.equal(view.dialog.focusTarget, field);
await view.settle();
assert.equal(view.boxes()[0].focused, true);
view.button('Cancel').fire('click');
assert.equal(fallback.focused, true);
} finally { view.done(); }
const empty = collectionsStore({member:[]});
empty.collections = [];
const none = openCollections(empty);
try {
await none.settle();
assert.equal(none.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].focused, true);
} finally { none.done(); }
});
test('category chip names its origin visibly and shows an own category for any link status (#332 D146)', async () => {
const base = await localRecord(localPng('a.png'));
const chipTexts = record => {
+66 -3
View File
@@ -253,15 +253,16 @@ test('an unreadable or malformed models read falls back to the documented defaul
test('observe failures and malformed answers end in observe-error and retry observes again', async () => {
const { createImageTaggingController } = await load();
let answer = () => fail(409, 'source-changed', 'The source changed. Refresh the image collection before tagging this file.');
let answer = () => fail(500, 'observe-failed');
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answer() }));
const c = createImageTaggingController({ api: h.api, target: TARGET });
await c.open();
assert.equal(c.state.phase, 'observe-error');
assert.equal(c.state.statusText, 'The source changed. Refresh the image collection before tagging this file.');
assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
assert.equal(c.state.canSave, false);
assert.equal(c.state.canGenerate, false);
assert.equal(c.state.canRetry, true);
assert.equal(c.state.blocked, true, 'a failed capture blocks Generate and Save with its reason');
answer = () => response(200, observed({ tag_revision: -1 }));
await c.retry();
assert.equal(c.state.phase, 'observe-error');
@@ -274,9 +275,71 @@ test('observe failures and malformed answers end in observe-error and retry obse
await c.retry();
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.blocked, false);
assert.equal(h.of('POST', OBSERVE).length, 4);
});
test('#430: a changed source blocks the dialog until it is retargeted, keeping the draft', async () => {
const { createImageTaggingController } = await load();
const SOURCE = 'The source changed. Refresh the image collection before tagging this file.';
const fresh = { root_index: 1, root_scope: 'e'.repeat(64), path: TARGET.path };
const h = harness(defaultRoutes({
[`POST ${OBSERVE}`]: call => (call.body.target.root_scope === SCOPE ? fail(409, 'source-changed', SOURCE) : response(200, observed())),
}));
const c = createImageTaggingController({ api: h.api, target: TARGET });
await c.open();
assert.equal(c.state.phase, 'source-changed');
assert.equal(c.state.statusText, SOURCE);
assert.deepEqual([c.state.canSave, c.state.canGenerate, c.state.canRetry, c.state.blocked], [false, false, false, true]);
assert.equal(await c.retry(), false, 'the same stale target is never observed again');
assert.equal(h.of('POST', OBSERVE).length, 1);
assert.throws(() => c.retarget({ ...fresh, root_scope: 'bad' }), TypeError);
await c.retarget(fresh);
await settle();
assert.equal(c.state.phase, 'ready');
assert.deepEqual(h.of('POST', OBSERVE).at(-1).body.target, fresh);
assert.deepEqual({ ...c.target }, fresh);
// Generate and Save now carry the new target.
c.setManual('kept draft');
await c.generate();
assert.deepEqual(h.of('POST', SUGGEST)[0].body.target, fresh);
});
test('#430: a source change answered to Generate or Save also blocks; retarget keeps the typed draft', async () => {
for (const route of [SUGGEST, TAGS]) {
const method = route === TAGS ? 'PUT' : 'POST';
let stale = true;
const { c, h } = await opened({
[`${method} ${route}`]: call => (stale ? fail(409, 'source-changed', 'server says source') :
route === TAGS ? response(200, receipt(call.body.tags)) : response(200, suggested())),
});
c.setManual('kept draft');
if (route === TAGS) await c.save(); else await c.generate();
assert.equal(c.state.phase, 'source-changed');
assert.equal(c.state.statusText, 'server says source');
stale = false;
await c.retarget({ root_index: 2, root_scope: 'f'.repeat(64), path: TARGET.path });
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.manual, 'kept draft');
assert.equal(h.of('DELETE', CAPTURE).length, 1, 'the old capture is released');
}
});
test('#430: retarget waits for nothing: it refuses while a save is in flight and after close', async () => {
const gate = deferred();
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => gate.promise });
const saving = c.save();
await flush();
assert.equal(c.state.phase, 'saving');
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
gate.resolve(response(200, receipt(['Minimal'])));
await saving;
c.cancel();
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
assert.equal(h.of('POST', OBSERVE).length, 1);
});
test('model change clears suggestions count and status but keeps manual', async () => {
const { c } = await opened();
c.setManual('Minimal, dune');
@@ -547,7 +610,7 @@ test('write errors keep the draft and allow another save', async () => {
});
test('storage-changed and file-changed disable save until reopen', async () => {
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'file-changed'], ['capture-expired', 'capture-expired']]) {
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'source-changed'], ['capture-expired', 'capture-expired']]) {
const message = `server says ${code}`;
let answer = () => fail(409, code, message);
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => answer() });
@@ -59,3 +59,9 @@ test('the toolbar list merges prompt and image categories once each', async () =
assert.deepEqual(mergeCategories(['A', '', ' ', 7], null), ['A']);
assert.deepEqual(mergeCategories(undefined, ['landscapes', 'Landscapes']), ['landscapes', 'Landscapes']);
});
test('the outcome names what was set: images by default, prompts for the Library bar (#437)', async () => {
const {setOutcome} = await source;
assert.equal(setOutcome({updated:1, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 1 prompt.');
assert.equal(setOutcome({updated:2, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 2 prompts.');
});
+285
View File
@@ -0,0 +1,285 @@
// The Images selection bar's collection actions (#331 D138, design §7): Add to collection after Set category, Remove
// from collection beside it while a collection filter is active, the 500 cap and every toast of §7.4.
import test from 'node:test';
import assert from 'node:assert/strict';
// Guarded: before the module exists every test fails on an assertion, not on the import.
const loaded = await import('../../web/js/workspace/images-collections.mjs').catch(() => null);
const module = () => { assert.ok(loaded, 'images-collections.mjs is not implemented'); return loaded; };
const { mountSelectionBar } = await import('../../web/js/workspace/images-selection.mjs');
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
const strings = await import('../../web/js/workspace/collections.mjs');
const SCOPE = 'b'.repeat(64);
const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra });
const target = path => ({ root_index: 0, root_scope: SCOPE, path });
const images = count => Array.from({ length: count }, (_, index) => image(`set/${index}.png`));
const keyOf = item => `${item.root_index}:${item.path}`;
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
const coded = (code, status = 400) => Object.assign(new Error(code), { name: 'ApiError', code, status });
/** A minimal DOM for the selection bar: elements with order, attributes, listeners, focus and a toast. */
function fakeDOM() {
const previous = globalThis.document;
let active = null;
class Element extends EventTarget {
constructor(tag) {
super();
this.tagName = tag.toUpperCase();
this.children = [];
this.parentNode = null;
this.attributes = {};
this.dataset = {};
this.className = '';
this.id = '';
this._text = '';
}
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
get nextElementSibling() {
const siblings = this.parentNode?.children ?? [];
return siblings[siblings.indexOf(this) + 1] ?? null;
}
append(...nodes) {
for (const node of nodes) { node.remove(); node.parentNode = this; this.children.push(node); }
}
after(node) {
node.remove();
node.parentNode = this.parentNode;
this.parentNode.children.splice(this.parentNode.children.indexOf(this) + 1, 0, node);
}
remove() {
if (!this.parentNode) return;
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode = null;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
removeAttribute(name) { delete this.attributes[name]; }
querySelector() { return this.children.find(child => child.tagName === 'BUTTON' && child.getAttribute('aria-disabled') !== 'true') ?? null; }
querySelectorAll() { return []; }
focus() { active = this; }
click() { this.dispatchEvent(new Event('click')); }
}
const toast = new Element('div');
const body = new Element('body');
globalThis.document = {
body,
get activeElement() { return active ?? body; },
createElement: tag => new Element(tag),
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
getElementById: id => (id === 'workspace-toast' ? toast : null),
};
return { Element, body, toast, restore() { globalThis.document = previous; } };
}
/** The bar over `items` with `checked` keys; `options` adds the collection props. */
function mountBar(dom, items, checkedKeys, options = {}) {
const scope = createScope();
const grid = new dom.Element('div');
const focusFallback = new dom.Element('h1');
const bar = mountSelectionBar({ scope, api: {}, grid, getItems: () => items, focusFallback, onCategorySet() {},
activity: options.activity ?? null, onTagged() {}, getFilter: () => ({}),
getListing: () => ({ total: options.total ?? items.length, hasMore: false, positiveCount: null }),
checked: Object.freeze(new Set(checkedKeys)), ...options.props });
return { scope, bar, labels: () => bar.element.children[0]?.children.map(child => child.textContent) ?? [],
button: text => bar.element.children[0].children.find(child => child.textContent === text) };
}
function store(overrides = {}) {
const calls = [];
return {
calls,
collections: [{ id: 3, name: 'Experiments', count: 8, missing: 0 }, { id: 7, name: 'Landscapes', count: 12, missing: 1 }],
add: async (where, items) => { calls.push(['add', where, items]); return overrides.add?.(where, items); },
remove: async (id, items) => { calls.push(['remove', id, items]); return overrides.remove?.(id, items); },
};
}
test('selection bar lists Add to collection after Set category', () => {
const dom = fakeDOM();
try {
const items = images(2);
const collections = store();
const plain = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections } });
assert.deepEqual(plain.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Export metadata', 'Clear']);
plain.scope.dispose();
// §7.1: Remove from collection beside Add, only while a collection filter is active.
const filtered = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections, collection: '7' } });
assert.deepEqual(filtered.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Remove from collection', 'Export metadata', 'Clear']);
filtered.scope.dispose();
// Without the client store (a page that has none) neither shows.
const none = mountBar(dom, items, [keyOf(items[0])]);
assert.deepEqual(none.labels(), ['1 selected', 'Set category', 'Export metadata', 'Clear']);
none.scope.dispose();
} finally { dom.restore(); }
});
test('more than 500 checked toasts and opens nothing', async () => {
const dom = fakeDOM();
try {
const items = images(501);
const collections = store();
const view = mountBar(dom, items, items.map(keyOf), { props: { collections, collection: '7' } });
view.button('Add to collection').click();
assert.equal(dom.toast.textContent, 'Select at most 500 images.');
assert.equal(dom.body.children.length, 0, 'no dialog opened');
dom.toast.textContent = '';
view.button('Remove from collection').click();
await flush();
assert.equal(dom.toast.textContent, 'Select at most 500 images.');
assert.deepEqual(collections.calls, []);
view.scope.dispose();
} finally { dom.restore(); }
});
test('Remove from collection removes the checked files and reloads the listing', async () => {
const dom = fakeDOM();
try {
const items = images(3);
let changed = 0;
const collections = store({ remove: () => ({ collection: { id: 7, name: 'Landscapes' }, removed: 2 }) });
const view = mountBar(dom, items, [keyOf(items[0]), keyOf(items[2])], { props: { collections, collection: '7',
onCollectionsChanged: () => { changed++; } } });
const remove = view.button('Remove from collection');
remove.click();
// A second press while the first is in flight is ignored.
assert.equal(remove.getAttribute('aria-disabled'), 'true');
remove.click();
await flush();
assert.deepEqual(collections.calls, [['remove', 7, [target('set/0.png'), target('set/2.png')]]]);
assert.equal(dom.toast.textContent, 'Removed 2 images from “Landscapes”.');
assert.equal(changed, 1);
assert.equal(remove.getAttribute('aria-disabled'), null);
view.scope.dispose();
} finally { dom.restore(); }
});
test('the collection-target rule keeps the all-matching selection to its loaded images', () => {
const { collectionTargets } = module();
assert.deepEqual(collectionTargets(images(2)), { targets: [target('set/0.png'), target('set/1.png')] });
assert.deepEqual(collectionTargets(images(501)), { refusal: strings.TOO_MANY_TOAST });
// All N matching: only when every match is loaded and checked, and at most 500.
assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 2 }), { targets: [target('set/0.png'), target('set/1.png')] });
assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 3 }), { refusal: strings.TOO_MANY_TOAST });
assert.deepEqual(collectionTargets(images(501), { allMatching: true, total: 501 }), { refusal: strings.TOO_MANY_TOAST });
// A file without a listing identity cannot be named; none left is "could not be found".
assert.deepEqual(collectionTargets([{ ...image('x.png'), root_scope: 'nope' }]), { refusal: strings.FILES_NOT_FOUND_TOAST });
});
const pageScope = () => {
const controller = new AbortController();
return { signal: controller.signal, cleanup() {}, abort: () => controller.abort() };
};
test('Add to collection opens the concept dialog with the existing names as suggestions', () => {
const { openAddToCollection } = module();
let dialog = null;
const collections = store();
openAddToCollection({ scope: pageScope(), collections, images: images(1), trigger: 'trigger', notify: assert.fail,
openInput: args => { dialog = args; } });
assert.equal(dialog.title, 'Add selected images to collection');
assert.equal(dialog.label, 'Collection name');
assert.equal(dialog.action, 'Add');
assert.equal(dialog.maxLength, 100);
assert.equal(dialog.trigger, 'trigger');
assert.deepEqual(dialog.suggestions, ['Experiments', 'Landscapes']);
assert.equal(dialog.value ?? '', '');
});
/** Apply `name` in the Add dialog over `count` images; resolves with the toasts and the store's calls. */
async function add(name, answer, { count = 1, extra = {} } = {}) {
const { openAddToCollection } = module();
const toasts = [];
let dialog = null, sourceChanged = 0;
const collections = store({ add: answer });
openAddToCollection({ scope: pageScope(), collections, images: images(count), notify: text => toasts.push(text),
openInput: args => { dialog = args; }, onSourceChanged: () => { sourceChanged++; }, ...extra });
await dialog.onApply(name);
return { toasts, calls: collections.calls, sourceChanged };
}
const answer = (added, already, notFound, created = false) => () => ({ collection: { id: 9, name: 'LoRa testing' }, created,
added, already, notFound });
test('Add sends the normalized name and the checked files', async () => {
const { toasts, calls } = await add(' LoRa testing ', answer(3, 0, 0, true), { count: 3 });
assert.deepEqual(calls, [['add', { name: 'LoRa testing' }, [target('set/0.png'), target('set/1.png'), target('set/2.png')]]]);
assert.deepEqual(toasts, ['Added 3 images to “LoRa testing”.']);
});
test('Add toasts every outcome of §7.4', async () => {
const cases = [
[answer(1, 0, 0), 'Added 1 image to “LoRa testing”.'],
[answer(1, 1, 0), 'Added 1 image to “LoRa testing”. 1 was already in it.'],
[answer(2, 3, 0), 'Added 2 images to “LoRa testing”. 3 were already in it.'],
[answer(1, 0, 1), 'Added 1 image to “LoRa testing”. 1 could not be found.'],
[answer(1, 2, 4), 'Added 1 image to “LoRa testing”. 2 were already in it. 4 could not be found.'],
[answer(0, 2, 0), 'All selected images were already in “LoRa testing”.'],
[answer(0, 1, 1), 'Added 0 images to “LoRa testing”. 1 was already in it. 1 could not be found.'],
[() => { throw coded('files-not-found', 404); }, 'None of the selected images could be found. Refresh the images and try again.'],
[() => { throw coded('collection-not-found', 404); }, 'That collection no longer exists.'],
[() => { throw new TypeError('offline'); }, 'The images could not be added. Try again.'],
];
for (const [reply, text] of cases) assert.deepEqual((await add('LoRa testing', reply)).toasts, [text], text);
});
test('a stale source on Add says so and reloads the listing', async () => {
const { toasts, sourceChanged } = await add('Grids', () => { throw coded('source-changed', 409); });
assert.deepEqual(toasts, ['The sources changed. Refresh the images and try again.']);
assert.equal(sourceChanged, 1);
});
test('Add with a name the store cannot take sends nothing and says it failed', async () => {
const { toasts, calls } = await add('bad\u0001name', answer(1, 0, 0));
assert.deepEqual(calls, []);
assert.deepEqual(toasts, ['The images could not be added. Try again.']);
});
test('Add after the page left shows no toast', async () => {
const { openAddToCollection } = module();
const scope = pageScope();
const toasts = [];
let dialog = null;
openAddToCollection({ scope, collections: store({ add: answer(1, 0, 0) }), images: images(1), notify: text => toasts.push(text),
openInput: args => { dialog = args; } });
scope.abort();
await dialog.onApply('Grids');
assert.deepEqual(toasts, []);
});
test('Add refuses an over-cap or all-matching selection before any dialog', () => {
const { openAddToCollection } = module();
const toasts = [];
const open = extra => openAddToCollection({ scope: pageScope(), collections: store(), notify: text => toasts.push(text),
openInput: () => assert.fail('no dialog opens'), ...extra });
open({ images: images(501) });
open({ images: images(2), allMatching: true, total: 40 });
assert.deepEqual(toasts, [strings.TOO_MANY_TOAST, strings.TOO_MANY_TOAST]);
});
/** Remove `count` images from collection 7 with `reply`; resolves with the toasts and callbacks. */
async function remove(reply, count = 1) {
const { removeFromCollection } = module();
const toasts = [];
let changed = 0, sourceChanged = 0;
const collections = store({ remove: reply });
await removeFromCollection({ scope: pageScope(), collections, collectionId: 7, images: images(count),
notify: text => toasts.push(text), onChanged: () => { changed++; }, onSourceChanged: () => { sourceChanged++; } });
return { toasts, changed, sourceChanged, calls: collections.calls };
}
test('Remove toasts every outcome of §7.4', async () => {
assert.deepEqual(await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 1 })),
{ toasts: ['Removed 1 image from “Landscapes”.'], changed: 1, sourceChanged: 0, calls: [['remove', 7, [target('set/0.png')]]] });
assert.deepEqual((await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 3 }), 3)).toasts,
['Removed 3 images from “Landscapes”.']);
// A failure keeps the listing; a deleted collection reloads it (the page then says the collection is gone).
const failed = await remove(() => { throw new TypeError('offline'); });
assert.deepEqual([failed.toasts, failed.changed], [['The images could not be removed. Try again.'], 0]);
const gone = await remove(() => { throw coded('collection-not-found', 404); });
assert.deepEqual([gone.toasts, gone.changed], [['That collection no longer exists.'], 1]);
const stale = await remove(() => { throw coded('source-changed', 409); });
assert.deepEqual([stale.toasts, stale.changed, stale.sourceChanged], [['The sources changed. Refresh the images and try again.'], 0, 1]);
});
@@ -0,0 +1,172 @@
// Run with: node --test tests/js/workspace-images-source-recovery.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const load = () => import('../../web/js/workspace/images-source-recovery.mjs');
const OLD = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'sub/a.png', filename: 'a.png' });
const FRESH = Object.freeze({ root_index: 1, root_scope: 'b'.repeat(64), path: 'sub/a.png', filename: 'a.png', url: '/u' });
function fakeApi(answer) {
const calls = [];
return { calls, post: async (url, body, options) => { calls.push({ url, body, options }); return answer(body); } };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('rescopeImage posts only the target and returns the fresh descriptor', async () => {
const { rescopeImage, RESCOPE_URL } = await load();
const api = fakeApi(() => ({ success: true, image: FRESH }));
const signal = new AbortController().signal;
const fresh = await rescopeImage(api, OLD, { signal });
assert.deepEqual(fresh, FRESH);
assert.ok(Object.isFrozen(fresh));
assert.deepEqual(api.calls, [{ url: RESCOPE_URL, body: { target: { root_index: 0, root_scope: OLD.root_scope, path: 'sub/a.png' } }, options: { signal } }]);
});
test('rescopeImage answers null for a refusal, a malformed answer, another path or a bad target', async () => {
const { rescopeImage } = await load();
const refused = { post: async () => { throw Object.assign(new Error('gone'), { status: 409, code: 'source-changed' }); } };
assert.equal(await rescopeImage(refused, OLD), null);
const lost = { post: async () => { throw new TypeError('Failed to fetch'); } };
await assert.rejects(rescopeImage(lost, OLD), TypeError, 'a lost answer is not final');
for (const answer of [null, { success: false }, { success: true, image: { ...FRESH, root_scope: 'x' } },
{ success: true, image: { ...FRESH, path: 'other.png' } }, { success: true, image: { ...FRESH, root_index: -1 } }]) {
assert.equal(await rescopeImage(fakeApi(() => answer), OLD), null);
}
const api = fakeApi(() => ({ success: true, image: FRESH }));
for (const image of [null, { ...OLD, root_scope: 'short' }, { ...OLD, path: '' }, { ...OLD, root_index: 1.5 }]) {
assert.equal(await rescopeImage(api, image), null);
}
assert.equal(api.calls.length, 0, 'a malformed target is never sent');
});
test('one rescope per held pair: callers share it, a tried pair answers its first result, reset forgets', async () => {
const { createSourceRecovery } = await load();
let answer = { success: true, image: FRESH };
const api = fakeApi(() => answer);
const applied = [];
const recovery = createSourceRecovery({ api, generation: () => 7, apply: async (image, fresh, started) => { applied.push([image.path, fresh.root_index, started]); return fresh; } });
const [first, second] = await Promise.all([recovery.recover(OLD), recovery.recover({ ...OLD })]);
assert.deepEqual([first, second], [FRESH, FRESH]);
assert.equal(api.calls.length, 1);
assert.deepEqual(applied, [['sub/a.png', 1, 7]]);
answer = { success: false };
assert.deepEqual(await recovery.recover(OLD), FRESH, 'the pair is not asked again');
recovery.reset();
assert.equal(await recovery.recover(OLD), null, 'after a reset the pair is asked again');
assert.equal(api.calls.length, 2);
assert.equal(await recovery.recover({ ...OLD, root_scope: 'bad' }), null);
assert.equal(api.calls.length, 2);
});
test('a refused pair never loops, an aborted page applies nothing and a failing apply is null', async () => {
const { createSourceRecovery } = await load();
const api = fakeApi(() => ({ success: true, image: FRESH }));
const page = new AbortController();
let applied = 0;
const recovery = createSourceRecovery({ api, signal: page.signal, apply: async () => { applied += 1; return FRESH; } });
page.abort();
assert.equal(await recovery.recover(OLD), null);
assert.equal(applied, 0);
const failing = createSourceRecovery({ api, apply: async () => { throw new Error('page gone'); } });
assert.equal(await failing.recover(OLD), null);
assert.throws(() => createSourceRecovery({ api }), TypeError);
// An api without post (an embedded gallery's page adapter) finds nothing.
assert.equal(await createSourceRecovery({ api: {}, apply: async () => FRESH }).recover(OLD), null);
});
test('the memo forgets its oldest pairs past its limit', async () => {
const { createSourceRecovery } = await load();
const api = fakeApi(body => ({ success: true, image: { ...FRESH, path: body.target.path } }));
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
for (let index = 0; index < 65; index++) await recovery.recover({ ...OLD, path: `f${index}.png` });
assert.equal(api.calls.length, 65);
await recovery.recover({ ...OLD, path: 'f64.png' });
assert.equal(api.calls.length, 65, 'a recent pair is still remembered');
await recovery.recover({ ...OLD, path: 'f0.png' });
assert.equal(api.calls.length, 66, 'the oldest pair was forgotten');
});
function fakePage({ selected = true, root = 'all', current = true } = {}) {
const log = [];
const page = {
filters: { root, subfolder: root === 'all' ? '' : 'sub' },
current: started => { log.push(['current', started]); return current; },
selected: image => selected && image.path === OLD.path,
inspect: image => log.push(['inspect', image.root_index]),
dropSelection: () => log.push(['drop']),
stream: {
refresh: async () => { log.push(['refresh']); throw new Error('read failed'); },
reset: (filters, options) => log.push(['reset', { ...filters }, options]),
},
updateScope: () => log.push(['scope']),
closeViewer: () => log.push(['close-viewer']),
clearChecks: () => log.push(['clear-checks']),
listsNow: () => true,
rediscover: () => log.push(['rediscover']),
};
return { page, log };
}
test('a renumbered source keeps nothing by its old key: only the file stays selected', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage({ root: '0' });
assert.equal(await recoveredSource(page)(OLD, FRESH, 3), FRESH);
assert.deepEqual(log, [['current', 3], ['close-viewer'], ['clear-checks'], ['inspect', 1], ['scope'],
['reset', { root: '1', subfolder: 'sub' }, { load: true }], ['rediscover']]);
});
test('another Source filter falls back to All sources; an unselected file drops the old selection', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage({ root: '2', selected: false });
await recoveredSource(page)(OLD, FRESH, 0);
assert.deepEqual(log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
assert.ok(log.some(entry => entry[0] === 'drop'));
assert.ok(!log.some(entry => entry[0] === 'inspect'));
const all = fakePage({ root: 'all' });
await recoveredSource(all.page)(OLD, FRESH, 0);
assert.deepEqual(all.log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
});
test('an unchanged numbering re-reads in place; a page that moved on is left alone', async () => {
const { recoveredSource } = await load();
const same = { ...FRESH, root_index: 0 };
const { page, log } = fakePage();
assert.equal(await recoveredSource(page)(OLD, same, 1), same);
assert.deepEqual(log, [['current', 1], ['inspect', 0], ['refresh']], 'a failed re-read is not an error');
const unselected = fakePage({ selected: false });
await recoveredSource(unselected.page)(OLD, same, 1);
assert.deepEqual(unselected.log, [['current', 1], ['refresh']]);
const moved = fakePage({ current: false });
assert.equal(await recoveredSource(moved.page)(OLD, FRESH, 1), FRESH, 'the dialog may still retarget');
assert.deepEqual(moved.log, [['current', 1]]);
await flush();
});
test('a scope that still names its root is not stale: nothing is applied', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage();
assert.equal(await recoveredSource(page)(OLD, { ...OLD, url: '/u' }, 1), null);
assert.deepEqual(log, []);
});
test('a lost rescope answer is not remembered: the pair is asked again', async () => {
const { createSourceRecovery } = await load();
let down = true;
const api = { calls: 0, post: async () => { api.calls += 1; if (down) throw Object.assign(new Error('bad gateway'), { status: 502 }); return { success: true, image: FRESH }; } };
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
assert.equal(await recovery.recover(OLD), null);
down = false;
assert.deepEqual(await recovery.recover(OLD), FRESH);
assert.equal(api.calls, 2);
});
test('the same index under another scope (a changed key) re-reads in place and keeps the selection', async () => {
const { recoveredSource } = await load();
const rekeyed = { ...OLD, root_scope: 'c'.repeat(64) };
const { page, log } = fakePage();
page.stream.refresh = async () => { log.push(['refresh']); };
assert.equal(await recoveredSource(page)(OLD, rekeyed, 2), rekeyed);
assert.deepEqual(log, [['current', 2], ['inspect', 0], ['refresh']]);
assert.ok(!log.some(entry => ['reset', 'drop', 'clear-checks', 'close-viewer'].includes(entry[0])));
});
+29 -1
View File
@@ -525,7 +525,35 @@ test('defaults apply to a new visit but not to the current view or a saved view'
assert.deepEqual(initialImagesView({ saved: { q: '' }, preferences }), { layout: 'grid', density: 'comfortable' });
// Without readable preferences nothing changes.
assert.deepEqual(initialImagesView({}), { layout: 'grid', density: 'comfortable' });
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'grid', density: 'comfortable' });
// #451: a preference object without a layout falls back to the default layout, Masonry.
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'masonry', density: 'comfortable' });
});
test('a default saved after the last visit started reaches the next visit unless the toolbar changed it (#451)', async () => {
const { initialImagesView } = await source;
const preferences = { workspaceLayout: 'masonry', workspaceDensity: 'compact' };
const started = { layout: 'grid', density: 'comfortable' };
// The visit kept the defaults it started from: the newer defaults win.
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started }, preferences }),
{ layout: 'masonry', density: 'compact' });
// A toolbar choice is the current view's own, whatever the defaults became; each value on its own.
assert.deepEqual(initialImagesView({ prior: { layout: 'list', density: 'comfortable', defaults: started }, preferences }),
{ layout: 'list', density: 'compact' });
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'large', defaults: started }, preferences }),
{ layout: 'masonry', density: 'large' });
// Unchanged defaults: the visit returns as it was.
assert.deepEqual(initialImagesView({ prior: { layout: 'masonry', density: 'compact', defaults: { layout: 'masonry', density: 'compact' } }, preferences }),
{ layout: 'masonry', density: 'compact' });
// A saved view and an embedded gallery never get a prior; without preferences the prior is kept.
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started } }),
{ layout: 'grid', density: 'comfortable' });
});
test('the defaults a visit starts from are the fresh-visit defaults (#451)', async () => {
const { visitDefaults } = await source;
assert.deepEqual(visitDefaults({ workspaceLayout: 'list', workspaceDensity: 'large' }), { layout: 'list', density: 'large' });
assert.deepEqual(visitDefaults({}), { layout: 'masonry', density: 'comfortable' });
assert.deepEqual(visitDefaults(null), { layout: 'grid', density: 'comfortable' });
});
test('listingFilter is the listing query as an object, without paging (#392)', async () => {
@@ -0,0 +1,96 @@
const test = require('node:test');
const assert = require('node:assert/strict');
// #437 D148: the Prompt library's selection bar sets one category on the selected prompts, fenced by the storage epoch.
const source = import('../../web/js/workspace/library-selection.mjs');
test('the dialog is the shared simpleInput with the prompt title, and the route and cap are the legacy ones', async () => {
const {PROMPT_CATEGORY_DIALOG, CATEGORY_URL, MAX_CATEGORY_PROMPTS, TOO_MANY_PROMPTS} = await source;
assert.deepEqual({...PROMPT_CATEGORY_DIALOG}, {title:'Categorize selected prompts', label:'Category', action:'Apply'});
assert.equal(CATEGORY_URL, '/prompt_manager/bulk/category');
assert.equal(MAX_CATEGORY_PROMPTS, 1000);
assert.equal(TOO_MANY_PROMPTS, 'Select at most 1,000 prompts.');
});
test('a confirmed write toasts the shared outcome with "prompt", invalidates and reloads', async () => {
const {writeOutcome} = await source;
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:2, unchanged:1, not_found:0}, null),
{toast:'Category “Studio” set on 3 prompts.', committed:true, reload:true});
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:1, unchanged:0, not_found:2}, null),
{toast:'Category “Studio” set on 1 prompt. 2 could not be found.', committed:true, reload:true});
});
test('none found, a restored storage, an unconfirmed answer and a refused write each say what happened', async () => {
const {writeOutcome, NONE_FOUND, STORAGE_CHANGED, UNCONFIRMED, CATEGORY_FAILED} = await source;
assert.deepEqual(writeOutcome({success:true, category:'X', updated:0, unchanged:0, not_found:3}, null),
{toast:NONE_FOUND, committed:false, reload:true});
assert.deepEqual(writeOutcome(null, {status:409, code:'storage-changed'}), {toast:STORAGE_CHANGED, committed:false, reload:true});
for (const failure of [new TypeError('Failed to fetch'), {status:502}, {status:503}, {status:504}]) {
assert.deepEqual(writeOutcome(null, failure), {toast:UNCONFIRMED, committed:true, reload:true});
}
assert.deepEqual(writeOutcome(null, null), {toast:UNCONFIRMED, committed:true, reload:true});
for (const status of [400, 500]) {
assert.deepEqual(writeOutcome(null, {status}), {toast:CATEGORY_FAILED, committed:false, reload:false});
}
assert.equal(STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No category was changed.');
});
test('the field refuses control characters as validate_category does', async () => {
const {categoryProblem, CONTROL_CHARACTERS} = await source;
assert.equal(categoryProblem('Studio portraits'), null);
assert.equal(categoryProblem('tab\there'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('nul\u0000'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('unicode ✓ é'), null);
});
test('Add tags uses the Images dialog words for prompts, the tag routes and the editor limits (#442)', async () => {
const {PROMPT_TAGS_DIALOG, TAGS_URL, TAG_OPTIONS_URL, MAX_PROMPT_TAGS} = await source;
assert.deepEqual({...PROMPT_TAGS_DIALOG}, {title:'Tag selected prompts', label:'Tags, separated by commas', action:'Apply'});
assert.equal(TAGS_URL, '/prompt_manager/bulk/tags');
assert.equal(TAG_OPTIONS_URL, '/prompt_manager/tags');
assert.equal(MAX_PROMPT_TAGS, 20);
});
test('the Add tags toast says what was added, already present, skipped and missing', async () => {
const {tagsOutcome, writeOutcome, NONE_FOUND} = await source;
assert.equal(tagsOutcome({tags:['a', 'b'], updated:2, unchanged:0, over_limit:0, not_found:0}), 'Added “a, b” to 2 prompts.');
assert.equal(tagsOutcome({tags:['a'], updated:1, unchanged:2, over_limit:1, not_found:3}),
'Added “a” to 1 prompt. 2 prompts already had them. 1 prompt would pass 20 tags and was skipped. 3 could not be found.');
assert.equal(tagsOutcome({tags:['a'], updated:0, unchanged:2, over_limit:0, not_found:0}), '2 prompts already had “a”.');
// Every prompt skipped: found, so the skip sentence, never "none found"; nothing committed.
assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:2, not_found:0}, null, 'tags'),
{toast:'2 prompts would pass 20 tags and were skipped.', committed:false, reload:true});
assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:0, not_found:2}, null, 'tags'),
{toast:NONE_FOUND, committed:false, reload:true});
});
test('Add tags failures name tags, not the category', async () => {
const {writeOutcome, TAGS_STORAGE_CHANGED, TAGS_UNCONFIRMED, TAGS_FAILED} = await source;
assert.equal(writeOutcome(null, {status:409, code:'storage-changed'}, 'tags').toast, TAGS_STORAGE_CHANGED);
assert.equal(writeOutcome(null, {status:503}, 'tags').toast, TAGS_UNCONFIRMED);
assert.equal(writeOutcome(null, {status:400}, 'tags').toast, TAGS_FAILED);
assert.equal(TAGS_STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No tags were added.');
});
test('the Add tags field refuses what validate_tags refuses', async () => {
const {tagsProblem, TAG_PROBLEMS} = await source;
assert.equal(tagsProblem('red, blue'), null);
assert.equal(tagsProblem(' , ,'), TAG_PROBLEMS.none);
assert.equal(tagsProblem(Array.from({length:21}, (_, i) => `t${i}`).join(',')), TAG_PROBLEMS.many);
assert.equal(tagsProblem(Array.from({length:21}, () => 'same').join(',')), null);
assert.equal(tagsProblem('x'.repeat(51)), TAG_PROBLEMS.long);
assert.equal(tagsProblem('bad\ttag'), TAG_PROBLEMS.control);
});
test('Select all N loaded is offered while some loaded prompts are unchecked (#444 ruling)', async () => {
const {loadedOffer, offersLoaded} = await source;
assert.equal(loadedOffer(3), 'Select all 3 loaded');
assert.equal(loadedOffer(1203), 'Select all 1,203 loaded');
assert.equal(offersLoaded(new Set([1]), [1, 2, 3]), true);
assert.equal(offersLoaded(new Set([1, 2, 3]), [1, 2, 3]), false);
assert.equal(offersLoaded(new Set(), [1, 2]), false);
assert.equal(offersLoaded(new Set([9]), [9]), false);
// More loaded than any action accepts: never offered (Ctrl/Cmd+A still selects them).
assert.equal(offersLoaded(new Set([1]), Array.from({length:1001}, (_, i) => i + 1)), false);
assert.equal(offersLoaded(new Set([1]), Array.from({length:1000}, (_, i) => i + 1)), true);
});
+10
View File
@@ -104,3 +104,13 @@ test('archived variant reads use their own scope and cursor without changing the
const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false);
assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'}));
});
test('the stream keeps the first page\'s storage epoch and refuses a later page from another one (#437)',async()=>{
const {createLibraryStream}=await source;const A='11111111-1111-4111-8111-111111111111',B='22222222-2222-4222-8222-222222222222';
const pending=[{...page([item(1)],'next'),library_epoch:A},{...page([item(2)],'more'),library_epoch:A},{...page([item(3)]),library_epoch:B}];
const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.epoch,null);
await s.reset();assert.equal(s.state.epoch,A);await s.loadMore();assert.equal(s.state.epoch,A);assert.equal(s.state.items.length,2);
await s.loadMore();assert.ok(s.state.error);assert.equal(s.state.items.length,2);assert.equal(s.state.epoch,A);
const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.epoch,A);restored.dispose();
const fresh=[{...page([item(4)]),library_epoch:B}];const reset=s.reset({text:'x'});assert.equal(s.state.epoch,null);
s.dispose();const next=createLibraryStream({api:{get:async()=>fresh.shift()}});await next.reset();assert.equal(next.state.epoch,B);next.dispose();await reset;
});
+2 -2
View File
@@ -44,7 +44,7 @@ test('normalizes new workspace keys and keeps legacy keys', () => {
// Invalid workspace values fall back to the defaults, as every legacy key does.
store.text = JSON.stringify({ workspaceLayout: 'mosaic', workspaceDensity: 7, videoMute: 'no' });
const fallback = module.readPreferences(() => store).values;
assert.equal(fallback.workspaceLayout, 'grid');
assert.equal(fallback.workspaceLayout, 'masonry'); // #451: the default layout
assert.equal(fallback.workspaceDensity, 'comfortable');
assert.equal(fallback.videoMute, true);
// The legacy normalizer knows the new keys and still keeps unknown ones.
@@ -94,7 +94,7 @@ test('denied storage reports tab-only', () => {
module.resetTabPreferences();
const read = module.readPreferences(denied);
assert.equal(read.persisted, false);
assert.equal(read.values.workspaceLayout, 'grid');
assert.equal(read.values.workspaceLayout, 'masonry');
const result = module.writePreferences({ workspaceLayout: 'list', videoAutoplay: true }, denied);
assert.deepEqual(result, { persisted: false, conflicts: [] });
// The change is kept for this tab: every reader in it (a new Images visit, the viewer) sees it.
+2 -1
View File
@@ -162,7 +162,8 @@ test('reset restores defaults without touching other keys', async () => {
const reset = dom.find(surface.children.at(-1), node => node.tagName === 'BUTTON');
assert.equal(reset.textContent, 'Reset');
reset.click();
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'grid', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
// #451: the default layout is Masonry.
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
// The host fields and every key outside the display defaults are left as they are.
assert.deepEqual(drafts.changes('host'), { result_timeout: 9 });
assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort());
+3
View File
@@ -594,6 +594,9 @@ test('an input dialog offers suggestions and a secondary action', async () => {
const [plainField, plainActions] = byId(dom, 'modal-body').children;
assert.equal(plainField.children[1].getAttribute('list'), null);
assert.deepEqual(plainActions.children.map(node => node.textContent), ['Cancel', 'A']);
// An empty list (Add to collection with no collections yet, #331 D138) adds no datalist either.
openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [], onApply() {} });
assert.equal(dom.all(node => node.tagName === 'DATALIST').length, 0);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [1], onApply() {} }), TypeError);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: '' }, onApply() {} }), TypeError);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: 'X' }, onApply() {} }), TypeError);
+4 -3
View File
@@ -2556,9 +2556,10 @@ class TestRootScopeServing(ImageAPITestCase):
listing = await self.client.get("/prompt_manager/images/output?mode=workspace")
descriptor = (await listing.json())["images"][0]
self.assertRegex(descriptor.get("root_scope", ""), r"^[0-9a-f]{64}$")
with patch.object(
images_module, "_WORKSPACE_CURSOR_SECRET", b"different-process-key"
):
from utils.workspace_keys import WorkspaceKeys
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
self.api._get_all_output_dirs = lambda: []
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
+3 -2
View File
@@ -1418,9 +1418,10 @@ class TestPromptInspector(ImageAPITestCase):
async def test_root_scope_rotated_key_and_removed_index_skip_reader_and_db(self):
png(self.output_dir / "image.png", prompt=graph())
descriptor = await self.scoped_descriptor()
import py.api.images as images_module
from utils.workspace_keys import WorkspaceKeys
with patch.object(images_module, "_WORKSPACE_CURSOR_SECRET", b"new-key"):
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64))
self.api._get_all_output_dirs = lambda: []
await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64))
+169
View File
@@ -0,0 +1,169 @@
"""POST /prompt_manager/images/rescope: a stale target's current identity (#430).
A page that holds ``{root_index, root_scope, path}`` after the sources were
renumbered asks where that file is now. The server maps the target only when
its scope was issued, at its old index, for one of the current roots, so the
answer names nothing the page could not already address; a removed or
replaced root, a foreign scope or a changed key stay refused.
"""
import json
import os
import sys
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from test_api_images import ImageAPITestCase, make_png
except ImportError: # imported as tests.test_api_rescope
from tests.test_api_images import ImageAPITestCase, make_png
from utils.workspace_keys import WorkspaceKeys # noqa: E402
RESCOPE = "/prompt_manager/images/rescope"
class TestRescopeRoute(ImageAPITestCase):
async def get_application(self):
app = await super().get_application()
self.other_dir = self.comfy_dir / "other"
self.other_dir.mkdir()
make_png(self.output_dir / "sub" / "a.png")
make_png(self.other_dir / "sub" / "a.png", color=(0, 0, 255))
return app
def target(self, path="sub/a.png", index=0, root=None):
root = root or self.output_dir
return {
"root_index": index,
"root_scope": self.api._gallery_root_scope(root, index),
"path": path,
}
async def post(self, body):
response = await self.client.post(RESCOPE, json=body)
data = await response.json()
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
return response.status, data
async def listed(self):
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace"}
)
return {
(item["root_index"], item["path"]): item
for item in (await response.json())["images"]
}
async def test_an_unchanged_root_answers_the_listing_descriptor(self):
status, data = await self.post({"target": self.target()})
self.assertEqual(status, 200)
self.assertTrue(data["success"])
self.assertEqual(data["image"], (await self.listed())[(0, "sub/a.png")])
async def test_a_renumbered_root_maps_to_its_new_index(self):
held = self.target()
self.api._get_all_output_dirs = lambda: [self.other_dir, self.output_dir]
status, data = await self.post({"target": held})
self.assertEqual(status, 200)
image = data["image"]
self.assertEqual((image["root_index"], image["path"]), (1, "sub/a.png"))
self.assertEqual(
image["root_scope"], self.api._gallery_root_scope(self.output_dir, 1)
)
self.assertEqual(image, (await self.listed())[(1, "sub/a.png")])
async def test_a_removed_replaced_or_foreign_root_is_source_changed(self):
held = self.target()
cases = {
"removed": lambda: [],
"replaced": lambda: [self.other_dir],
}
for name, roots in cases.items():
with self.subTest(name):
self.api._get_all_output_dirs = roots
status, data = await self.post({"target": held})
self.assertEqual((status, data["code"]), (409, "source-changed"))
self.api._get_all_output_dirs = lambda: [self.output_dir]
for scope in ("0" * 64, self.api._gallery_root_scope(self.output_dir, 1)):
with self.subTest(scope=scope[:8]):
status, data = await self.post(
{"target": {**held, "root_scope": scope}}
)
self.assertEqual((status, data["code"]), (409, "source-changed"))
async def test_a_changed_key_cannot_be_mapped(self):
held = self.target()
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
status, data = await self.post({"target": held})
self.assertEqual((status, data["code"]), (409, "source-changed"))
async def test_the_first_of_two_spellings_of_one_root_wins(self):
held = self.target()
self.api._get_all_output_dirs = lambda: [
self.other_dir,
self.output_dir,
self.output_dir / "sub" / "..",
]
status, data = await self.post({"target": held})
self.assertEqual((status, data["image"]["root_index"]), (200, 1))
async def test_a_missing_file_is_not_found(self):
status, data = await self.post({"target": self.target("sub/gone.png")})
self.assertEqual((status, data["code"]), (404, "file-missing"))
(self.output_dir / "dir.png").mkdir()
status, data = await self.post({"target": self.target("dir.png")})
self.assertEqual((status, data["code"]), (404, "file-missing"))
async def test_a_path_leaving_the_root_is_refused(self):
outside = make_png(self.comfy_dir / "outside.png")
self._make_symlink(outside, self.output_dir / "escape.png")
status, _data = await self.post({"target": self.target("escape.png")})
self.assertEqual(status, 403)
async def test_previews_are_not_rescoped(self):
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
status, _data = await self.post(
{"target": self.target("thumbnails/a_thumb.png")}
)
self.assertEqual(status, 403)
async def test_malformed_bodies_are_refused_before_any_file_access(self):
good = self.target()
bodies = [
b"{",
[],
{},
{"target": good, "extra": 1},
{"target": {**good, "root_index": -1}},
{"target": {**good, "root_index": True}},
{"target": {**good, "root_index": 4096}},
# Past Python's int-to-text digit limit: never a 500.
b'{"target": {"root_index": 1' + b"0" * 5000 + b"}}",
{"target": {**good, "root_scope": "A" * 64}},
{"target": {**good, "root_scope": "abc"}},
{"target": {**good, "path": ""}},
{"target": {**good, "path": "../a.png"}},
{"target": {**good, "path": "/abs.png"}},
{"target": {**good, "path": "C:/a.png"}},
{"target": {**good, "path": "sub\\a.png"}},
{"target": {**good, "path": "sub//a.png"}},
{"target": {**good, "path": "notes.txt"}},
{"target": {**good, "path": "x" * 2000 + ".png"}},
]
with patch("pathlib.Path.is_file", side_effect=AssertionError("file access")):
for body in bodies:
with self.subTest(body=str(body)[:60]):
if isinstance(body, bytes):
response = await self.client.post(
RESCOPE,
data=body,
headers={"Content-Type": "application/json"},
)
else:
response = await self.client.post(RESCOPE, json=body)
self.assertIn(response.status, (400, 403))
self.assertFalse((await response.json())["success"])
+6 -2
View File
@@ -1882,9 +1882,13 @@ class TestWorkspaceCursorEncoding(unittest.TestCase):
for order in ("newest", "oldest", "name", "name-desc"):
with self.subTest(sort=order):
key = _workspace_key(entry, order)
token = _workspace_cursor(key, "scope")
token = _workspace_cursor(key, "scope", b"k" * 32)
self.assertLessEqual(len(token), 4096)
self.assertEqual(_workspace_cursor_key(token, "scope", order), key)
self.assertEqual(
_workspace_cursor_key(token, "scope", order, b"k" * 32), key
)
with self.assertRaises(ValueError):
_workspace_cursor_key(token, "scope", order, b"o" * 32)
# The pre-#379 candidate read, kept as the differential reference for the
+218
View File
@@ -0,0 +1,218 @@
"""A ComfyUI restart keeps an open workspace page's scopes and cursors (#430).
A restart is a new ``PromptManagerAPI`` on the same database in a new
process. Here a second API instance on its own aiohttp server stands in for
it (``tests/test_workspace_keys.py`` also derives the scope in a real child
process); it must accept every ``root_scope`` and listing cursor the first one
handed out, while a changed root list or key still refuses them.
"""
import base64
import json
import os
import sys
from unittest.mock import patch
from urllib.parse import quote
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from test_api_images import ImageAPITestCase, make_png
except ImportError: # imported as tests.test_api_workspace_restart
from tests.test_api_images import ImageAPITestCase, make_png
from database.operations import PromptDatabase # noqa: E402
from py.api import PromptManagerAPI # noqa: E402
from utils.workspace_keys import KEY_FILE_NAME, WorkspaceKeys # noqa: E402
LISTING = "/prompt_manager/images/output"
def _payload(token):
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
return json.loads(raw[32:])
class TestRestartKeepsTheOpenPage(ImageAPITestCase):
async def get_application(self):
app = await super().get_application()
for index in range(3):
make_png(self.output_dir / f"image_{index}.png")
return app
async def first_page(self, client=None, **params):
response = await (client or self.client).get(
LISTING, params={"mode": "workspace", "limit": "1", **params}
)
self.assertEqual(response.status, 200)
return await response.json()
async def restarted(self):
"""A second API on the same database and roots, on its own server."""
api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
api._get_all_output_dirs = lambda: [self.output_dir]
api._find_comfyui_output_dir = lambda: str(self.output_dir)
app = web.Application()
routes = web.RouteTableDef()
api.add_routes(routes)
app.router.add_routes(routes)
client = TestClient(TestServer(app))
await client.start_server()
async def close():
await client.close()
api.close_auto_thumbnails()
api.db.close_all()
self.addAsyncCleanup(close)
return api, client
async def test_a_restart_keeps_every_held_scope_and_cursor_valid(self):
page = await self.first_page()
image, cursor = page["images"][0], page["next_cursor"]
self.assertTrue(
os.path.isfile(
os.path.join(os.path.dirname(self._temp_db.name), KEY_FILE_NAME)
)
)
_api, client = await self.restarted()
more = await client.get(
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
)
self.assertEqual(more.status, 200, await more.text())
self.assertNotEqual((await more.json())["images"][0]["path"], image["path"])
inspected = await client.get(
f"/prompt_manager/images/inspect/{quote(image['path'])}",
params={"root": "0", "root_scope": image["root_scope"]},
)
self.assertEqual(inspected.status, 200, await inspected.text())
served = await client.get(
f"/prompt_manager/images/serve/{quote(image['path'])}",
params={"root": "0", "root_scope": image["root_scope"]},
)
self.assertEqual(served.status, 200)
relisted = await self.first_page(client)
self.assertEqual(relisted["images"][0]["root_scope"], image["root_scope"])
async def test_a_key_change_refuses_the_cursor_as_stale(self):
cursor = (await self.first_page())["next_cursor"]
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
response = await self.client.get(
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
)
self.assertEqual(response.status, 400)
self.assertEqual((await response.json())["code"], "cursor-stale")
async def test_renumbered_roots_refuse_the_cursor_as_stale(self):
cursor = (await self.first_page())["next_cursor"]
other = self.comfy_dir / "other"
other.mkdir()
self.api._get_all_output_dirs = lambda: [other, self.output_dir]
response = await self.client.get(
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
)
self.assertEqual(response.status, 400)
self.assertEqual((await response.json())["code"], "cursor-stale")
async def test_another_listing_refuses_the_cursor_as_stale(self):
cursor = (await self.first_page())["next_cursor"]
response = await self.client.get(
LISTING,
params={"mode": "workspace", "limit": "1", "cursor": cursor, "q": "x"},
)
self.assertEqual(response.status, 400)
self.assertEqual((await response.json())["code"], "cursor-stale")
async def test_a_malformed_cursor_is_a_plain_bad_request(self):
# A 32-byte signature over a payload is well formed: a wrong one is stale.
for cursor in ("!!", "a" * 5000, base64.urlsafe_b64encode(b"x" * 20).decode()):
with self.subTest(cursor=cursor[:10]):
response = await self.client.get(
LISTING,
params={"mode": "workspace", "limit": "1", "cursor": cursor},
)
self.assertEqual(response.status, 400)
self.assertNotEqual((await response.json()).get("code"), "cursor-stale")
async def test_the_cursor_carries_no_digest_of_the_root_paths(self):
cursor = (await self.first_page())["next_cursor"]
payload = _payload(cursor)
# v2: the listing scope (it hashes every absolute root) is in the MAC only.
self.assertEqual(set(payload), {"v", "key"})
self.assertEqual(payload["v"], 2)
async def test_another_database_folder_gets_other_scopes(self):
page = await self.first_page()
other_db = self.comfy_dir / "elsewhere" / "prompts.db"
other_db.parent.mkdir()
api = PromptManagerAPI(db=PromptDatabase(str(other_db)))
self.addCleanup(api.db.close_all)
self.addCleanup(api.close_auto_thumbnails)
self.assertNotEqual(
api._gallery_root_scope(self.output_dir, 0), page["images"][0]["root_scope"]
)
# A fresh interpreter: the API on the given database, as after a restart.
_CHILD = """
import json, sys
from pathlib import Path
from unittest.mock import MagicMock
sys.path.insert(0, sys.argv[1])
from tests.host_stubs import install_folder_paths_stub
install_folder_paths_stub()
sys.modules.setdefault("server", MagicMock())
from database.operations import PromptDatabase
from py.api import PromptManagerAPI
from py.api.images import _workspace_cursor_key
api = PromptManagerAPI(db=PromptDatabase(sys.argv[2]))
scope = api._gallery_root_scope(Path(sys.argv[3]), 0)
secret = api._workspace_keys().cursor
key = _workspace_cursor_key(sys.argv[4], "s" * 64, "newest", secret)
api.close_auto_thumbnails()
api.db.close_all()
print(json.dumps({"scope": scope, "key": list(key)}))
"""
class TestRestartInANewProcess(ImageAPITestCase):
"""The real thing: two other interpreters on the same database."""
def child(self, cursor):
import subprocess
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
result = subprocess.run(
[
sys.executable,
"-c",
_CHILD,
repo,
self._temp_db.name,
str(self.output_dir),
cursor,
],
cwd=self.comfy_dir,
capture_output=True,
text=True,
timeout=120,
)
self.assertEqual(result.returncode, 0, result.stderr[-2000:])
return json.loads(result.stdout.strip().splitlines()[-1])
async def test_new_processes_derive_the_same_scope_and_accept_the_cursor(self):
from py.api.images import _workspace_cursor
make_png(self.output_dir / "image.png")
listing = await self.client.get(LISTING, params={"mode": "workspace"})
scope = (await listing.json())["images"][0]["root_scope"]
key = (-12.5, "image.png", "0" * 64, 0)
cursor = _workspace_cursor(key, "s" * 64, self.api._workspace_keys().cursor)
for _restart in range(2):
self.assertEqual(self.child(cursor), {"scope": scope, "key": list(key)})
+125 -15
View File
@@ -22,6 +22,9 @@ from database.operations import PromptDatabase
from tests.browser import fast_reset
from tests.browser.fast_reset import ScratchSeeder, table_digests
# The owner lock of /dev/shm scratch folders needs POSIX flock (#419).
HAS_FLOCK = unittest.skipUnless(os.name == "posix", "needs POSIX flock")
def digests(db: PromptDatabase) -> dict:
return table_digests(db.model.get_connection())
@@ -275,6 +278,7 @@ class TestScratchSeeder(unittest.TestCase):
self.assertEqual(digests(self.live), expected)
self.assertFalse((self.root / "unused").exists())
@HAS_FLOCK
def test_the_scratch_lives_under_the_memory_root_when_there_is_one(self):
memory = self.root / "memory"
memory.mkdir()
@@ -288,6 +292,16 @@ class TestScratchSeeder(unittest.TestCase):
seeder.close()
self.assertEqual(list(memory.iterdir()), [])
@unittest.skipIf(os.name == "posix", "without flock the memory root is unused")
def test_without_flock_the_scratch_stays_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
seeder.run(reseed)
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
def test_a_full_memory_root_puts_the_scratch_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
@@ -303,26 +317,32 @@ class TestScratchSeeder(unittest.TestCase):
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
@unittest.skipUnless(os.name == "posix", "process probe is POSIX only")
def test_scratch_folders_of_dead_servers_are_removed(self):
@HAS_FLOCK
def test_the_scratch_folder_is_held_by_a_lock_until_close(self):
memory = self.root / "memory"
memory.mkdir()
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
orphan = memory / f"pm-reset-{dead.pid}-abc"
alive = memory / f"pm-reset-{os.getpid()}-def"
other = memory / "pm-reset-notapid"
for folder in (orphan, alive, other):
folder.mkdir()
(folder / "seed.db").write_bytes(b"x")
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
seeder.run(reseed)
self.assertFalse(orphan.exists())
self.assertTrue(alive.exists())
self.assertTrue(other.exists())
own = [p for p in memory.glob(f"pm-reset-{os.getpid()}-*") if p != alive]
self.assertEqual(len(own), 1)
(folder,) = memory.iterdir()
self.assertTrue(folder.name.startswith(f"pm-reset-held-{os.getpid()}-"))
self.assertFalse(lock_is_free(folder / "owner.lock"))
seeder.close()
self.assertEqual(list(memory.iterdir()), [])
@HAS_FLOCK
def test_a_lock_that_cannot_be_taken_puts_the_scratch_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
refuse = mock.patch.object(
fast_reset.fcntl, "flock", side_effect=OSError("no locks here")
)
with refuse:
seeder.run(reseed)
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
def test_close_releases_the_scratch_files(self):
self.seeder.run(reseed)
@@ -331,5 +351,95 @@ class TestScratchSeeder(unittest.TestCase):
self.seeder.close() # idempotent
# A process that takes the owner lock of a folder, says so, and waits.
HOLDER = """
import fcntl, os, sys, time
fd = os.open(os.path.join(sys.argv[1], "owner.lock"), os.O_RDWR | os.O_CREAT)
fcntl.flock(fd, fcntl.LOCK_EX)
print("held", flush=True)
time.sleep(600)
"""
def lock_is_free(path: Path) -> bool:
fd = os.open(path, os.O_RDWR)
try:
fast_reset.fcntl.flock(fd, fast_reset.fcntl.LOCK_EX | fast_reset.fcntl.LOCK_NB)
except BlockingIOError:
return False
finally:
os.close(fd)
return True
@HAS_FLOCK
class TestOrphanedScratch(unittest.TestCase):
"""Only folders whose owner lock is free are orphans (#419)."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.memory = Path(self.tmp.name)
def folder(self, name: str) -> Path:
folder = self.memory / name
folder.mkdir()
(folder / "seed.db").write_bytes(b"x")
return folder
def hold(self, folder: Path) -> subprocess.Popen:
holder = subprocess.Popen(
[sys.executable, "-c", HOLDER, str(folder)],
stdout=subprocess.PIPE,
text=True,
)
self.addCleanup(holder.wait)
self.addCleanup(holder.stdout.close)
self.addCleanup(holder.kill)
self.assertEqual(holder.stdout.readline().strip(), "held")
return holder
def test_a_folder_whose_owner_was_killed_is_removed(self):
folder = self.folder("pm-reset-held-123-abc")
holder = self.hold(folder)
holder.kill()
holder.wait()
fast_reset.remove_orphaned_scratch(self.memory)
self.assertFalse(folder.exists())
def test_a_held_folder_is_kept_whatever_pid_its_name_carries(self):
# Under --ipc=host the owner's pid is from another PID namespace: it
# does not exist here, but its lock does.
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
folder = self.folder(f"pm-reset-held-{dead.pid}-abc")
self.hold(folder)
fast_reset.remove_orphaned_scratch(self.memory)
self.assertTrue(folder.exists())
self.assertTrue((folder / "seed.db").exists())
def test_folders_without_an_owner_lock_are_kept(self):
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
legacy = self.folder(f"pm-reset-{dead.pid}-abc")
staging = self.folder(f"pm-reset-new-{dead.pid}-def")
unlocked = self.folder(f"pm-reset-held-{dead.pid}-ghi")
fast_reset.remove_orphaned_scratch(self.memory)
for folder in (legacy, staging, unlocked):
self.assertTrue(folder.exists(), folder.name)
def test_a_folder_held_by_another_seeder_in_this_process_is_kept(self):
api = types.SimpleNamespace(db=PromptDatabase(str(self.memory / "live.db")))
self.addCleanup(api.db.close_all)
root = self.memory / "shm"
root.mkdir()
other = ScratchSeeder(api, self.memory / "fallback", memory_root=root)
self.addCleanup(other.close)
other.run(reseed)
(folder,) = root.iterdir()
fast_reset.remove_orphaned_scratch(root)
self.assertTrue(folder.exists())
if __name__ == "__main__":
unittest.main()
+255
View File
@@ -0,0 +1,255 @@
"""#437: the workspace Library's fenced bulk "Set category" (DB command and route)."""
import sqlite3
import tempfile
import unittest
from pathlib import Path
from database.execution_history_schema import rotate_history_epoch
from database.library_commands import LibraryCommandError
from database.operations import PromptDatabase
from utils.hashing import generate_prompt_hash
try:
from test_api_prompts import RouteCoverageCase
except ImportError:
from tests.test_api_prompts import RouteCoverageCase
URL = "/prompt_manager/bulk/category"
def save(db, text, **metadata):
return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
def row(db, prompt_id):
with db.model.get_connection() as conn:
found = conn.execute(
"SELECT category, updated_at FROM prompts WHERE id=?", (prompt_id,)
).fetchone()
return tuple(found) if found else None
class TestBulkLibraryCategory(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = PromptDatabase(str(Path(self.temp.name) / "bulk.db"))
self.epoch = self.db.get_library_options()["library_epoch"]
def tearDown(self):
self.db.close_all()
self.temp.cleanup()
def run_bulk(self, ids, category, epoch=None):
return self.db.bulk_set_library_category(
ids, category, expected_epoch=epoch or self.epoch
)
def test_sets_changed_rows_and_counts_unchanged_and_missing(self):
a = save(self.db, "Alpha", category="Old")
b = save(self.db, "Beta", category="New")
c = save(self.db, "Gamma")
before_b = row(self.db, b)
result = self.run_bulk([a, b, c, 99999], " New ")
self.assertEqual(
result,
{
"library_epoch": self.epoch,
"category": "New",
"updated": 2,
"unchanged": 1,
"not_found": 1,
},
)
self.assertEqual(row(self.db, a)[0], "New")
self.assertEqual(row(self.db, c)[0], "New")
# An unchanged row keeps its timestamp (and so its edit token).
self.assertEqual(row(self.db, b), before_b)
def test_changed_category_rotates_the_edit_token_only_for_changed_rows(self):
a = save(self.db, "Alpha", category="Old")
b = save(self.db, "Beta", category="New")
tokens = {
pid: self.db.get_library_positive(pid)["positive"]["edit_token"]
for pid in (a, b)
}
self.run_bulk([a, b], "New")
self.assertNotEqual(
self.db.get_library_positive(a)["positive"]["edit_token"], tokens[a]
)
self.assertEqual(
self.db.get_library_positive(b)["positive"]["edit_token"], tokens[b]
)
def test_changed_rows_become_registered_library_parents_like_the_editor(self):
a = save(self.db, "Alpha", category="Old")
b = save(self.db, "Beta", category="Same")
def registered(pid):
with self.db.model.get_connection() as conn:
return bool(
conn.execute(
"SELECT 1 FROM prompt_library_state WHERE prompt_id=?", (pid,)
).fetchone()
)
self.assertEqual((registered(a), registered(b)), (False, False))
self.run_bulk([a, b], "Same")
self.assertEqual((registered(a), registered(b)), (True, False))
def test_updated_at_is_utc_iso_text(self):
a = save(self.db, "Alpha")
self.run_bulk([a], "Utc")
self.assertTrue(row(self.db, a)[1].endswith("+00:00"))
def test_negative_only_prompts_are_not_library_parents(self):
pair = self.db.save_prompt_pair("Cat", "Blur")
negative = self.db.get_prompt_pair(pair)["negative_prompt_id"]
positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
result = self.run_bulk([positive, negative], "Animals")
self.assertEqual((result["updated"], result["not_found"]), (1, 1))
self.assertIsNone(row(self.db, negative)[0])
def test_restored_storage_writes_nothing(self):
a = save(self.db, "Alpha", category="Old")
new_epoch = rotate_history_epoch(self.db.model.get_connection())
with self.assertRaises(LibraryCommandError) as caught:
self.run_bulk([a], "New")
self.assertEqual(caught.exception.code, "storage-changed")
self.assertEqual(caught.exception.epoch, new_epoch)
self.assertEqual(row(self.db, a)[0], "Old")
def test_a_failure_mid_write_leaves_every_row_as_it_was(self):
ids = [save(self.db, f"P{i}", category="Old") for i in range(3)]
with self.db.model.get_connection() as conn:
conn.execute(
"CREATE TRIGGER fail_third BEFORE UPDATE OF category ON prompts "
f"WHEN OLD.id={ids[2]} BEGIN SELECT RAISE(ABORT, 'boom'); END"
)
with self.assertRaises(sqlite3.DatabaseError):
self.run_bulk(ids, "New")
self.assertEqual([row(self.db, pid)[0] for pid in ids], ["Old"] * 3)
def test_many_ids_are_read_in_chunks(self):
ids = [save(self.db, f"Many {i}") for i in range(1000)]
result = self.run_bulk(ids, "Bulk")
self.assertEqual(result["updated"], 1000)
def test_rejects_invalid_arguments(self):
a = save(self.db, "Alpha")
for ids, category, epoch in (
([a], "", self.epoch),
([a], " ", self.epoch),
([a], None, self.epoch),
([a], "x" * 101, self.epoch),
([a], "bad\x01", self.epoch),
([a], "ok", "not-a-uuid"),
([], "ok", self.epoch),
):
with self.subTest(category=category, epoch=epoch, ids=ids):
with self.assertRaises(ValueError):
self.run_bulk(ids, category, epoch)
class TestBulkLibraryCategoryRoute(RouteCoverageCase):
def epoch(self):
return self.api.db.get_library_options()["library_epoch"]
async def test_fenced_form_sets_categories_and_reports_counts(self):
ids = [self._save_prompt(f"route {i}", category="Old") for i in range(2)]
status, body = await self._json(
"POST",
URL,
json={
"prompt_ids": ids,
"category": "Fresh",
"expected_epoch": self.epoch(),
},
)
self.assertEqual(status, 200)
self.assertEqual(
body,
{
"success": True,
"library_epoch": self.epoch(),
"category": "Fresh",
"updated": 2,
"unchanged": 0,
"not_found": 0,
},
)
self.assertEqual(self.api.db.get_prompt_by_id(ids[1])["category"], "Fresh")
async def test_restored_storage_answers_409_storage_changed(self):
pid = self._save_prompt("route restore", category="Old")
stale = self.epoch()
new_epoch = rotate_history_epoch(self.api.db.model.get_connection())
status, body = await self._json(
"POST",
URL,
json={"prompt_ids": [pid], "category": "Fresh", "expected_epoch": stale},
)
self.assertEqual((status, body["code"]), (409, "storage-changed"))
self.assertFalse(body["success"])
self.assertEqual(body["library_epoch"], new_epoch)
self.assertIsInstance(body["error"], str)
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Old")
async def test_fenced_form_validates_its_body(self):
pid = self._save_prompt("route validate")
epoch = self.epoch()
for body in (
{"prompt_ids": [pid], "category": "", "expected_epoch": epoch},
{"prompt_ids": [pid], "category": " ", "expected_epoch": epoch},
{"prompt_ids": [pid], "category": 5, "expected_epoch": epoch},
{"prompt_ids": [pid], "category": "x" * 101, "expected_epoch": epoch},
{"prompt_ids": [pid], "category": "ok", "expected_epoch": "nope"},
{"prompt_ids": [pid], "category": "ok", "expected_epoch": 7},
{"prompt_ids": [], "category": "ok", "expected_epoch": epoch},
{"prompt_ids": [True], "category": "ok", "expected_epoch": epoch},
{
"prompt_ids": list(range(1, 1002)),
"category": "ok",
"expected_epoch": epoch,
},
{"prompt_ids": [pid], "expected_epoch": epoch},
{
"prompt_ids": [pid],
"category": "ok",
"expected_epoch": epoch,
"extra": 1,
},
):
with self.subTest(body=body):
status, answer = await self._json("POST", URL, json=body)
self.assertEqual(status, 400)
self.assertFalse(answer["success"])
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
async def test_fenced_form_reports_an_unexpected_failure(self):
pid = self._save_prompt("route failure")
self._break("bulk_set_library_category")
status, body = await self._json(
"POST",
URL,
json={
"prompt_ids": [pid],
"category": "ok",
"expected_epoch": self.epoch(),
},
)
self.assertEqual(status, 500)
self.assertFalse(body["success"])
async def test_legacy_form_is_unchanged(self):
pid = self._save_prompt("route legacy", category="Old")
status, body = await self._json(
"POST", URL, json={"prompt_ids": [pid], "category": "Legacy"}
)
self.assertEqual((status, body["updated_count"]), (200, 1))
self.assertNotIn("library_epoch", body)
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Legacy")
if __name__ == "__main__":
unittest.main()
+286
View File
@@ -0,0 +1,286 @@
"""#442: the workspace Library's fenced bulk "Add tags" (DB command and route)."""
import sqlite3
import tempfile
import unittest
from pathlib import Path
from database.execution_history_schema import rotate_history_epoch
from database.library_commands import LibraryCommandError
from database.operations import PromptDatabase
from utils.hashing import generate_prompt_hash
try:
from test_api_prompts import RouteCoverageCase
except ImportError:
from tests.test_api_prompts import RouteCoverageCase
URL = "/prompt_manager/bulk/tags"
def save(db, text, **metadata):
return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
def tags_of(db, prompt_id):
with db.model.get_connection() as conn:
rows = conn.execute(
"SELECT t.name FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=? ORDER BY t.name",
(prompt_id,),
).fetchall()
return [row[0] for row in rows]
def updated_at(db, prompt_id):
with db.model.get_connection() as conn:
return conn.execute(
"SELECT updated_at FROM prompts WHERE id=?", (prompt_id,)
).fetchone()[0]
def tag_names(db):
with db.model.get_connection() as conn:
return sorted(row[0] for row in conn.execute("SELECT name FROM tags"))
class TestBulkLibraryTags(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = PromptDatabase(str(Path(self.temp.name) / "tags.db"))
self.epoch = self.db.get_library_options()["library_epoch"]
def tearDown(self):
self.db.close_all()
self.temp.cleanup()
def run_bulk(self, ids, tags, epoch=None):
return self.db.bulk_add_library_tags(
ids, tags, expected_epoch=epoch or self.epoch
)
def test_adds_only_missing_tags_and_counts_each_outcome(self):
a = save(self.db, "Alpha", tags=["red"])
b = save(self.db, "Beta", tags=["red", "blue"])
c = save(self.db, "Gamma")
before_b = updated_at(self.db, b)
result = self.run_bulk([a, b, c, 99999], [" red ", "blue", "Blue"])
self.assertEqual(
result,
{
"library_epoch": self.epoch,
"tags": ["red", "blue"],
"updated": 2,
"unchanged": 1,
"not_found": 1,
"over_limit": 0,
},
)
self.assertEqual(tags_of(self.db, a), ["blue", "red"])
self.assertEqual(tags_of(self.db, c), ["blue", "red"])
self.assertEqual(updated_at(self.db, b), before_b)
self.assertTrue(updated_at(self.db, a).endswith("+00:00"))
def test_a_case_variant_of_a_held_tag_is_not_added_and_names_stay_exact(self):
a = save(self.db, "Alpha", tags=["Portrait"])
b = save(self.db, "Beta")
result = self.run_bulk([a, b], ["portrait"])
# Alpha already holds it (casefold); Beta gets the name exactly as typed.
self.assertEqual((result["updated"], result["unchanged"]), (1, 1))
self.assertEqual(tags_of(self.db, a), ["Portrait"])
self.assertEqual(tags_of(self.db, b), ["portrait"])
self.assertEqual(tag_names(self.db), ["Portrait", "portrait"])
c = save(self.db, "Gamma", tags=["STRASSE"])
self.assertEqual(self.run_bulk([c], ["straße"])["unchanged"], 1)
def test_names_nothing_receives_are_never_inserted(self):
full = save(self.db, "Full", tags=[f"t{i}" for i in range(20)])
self.run_bulk([full, 99999], ["unused"])
self.assertNotIn("unused", tag_names(self.db))
def test_a_prompt_already_over_twenty_that_gains_nothing_is_unchanged(self):
over = save(self.db, "Over", tags=[f"t{i}" for i in range(22)])
result = self.run_bulk([over], ["t1"])
self.assertEqual((result["unchanged"], result["over_limit"]), (1, 0))
def test_image_and_pair_tags_are_untouched(self):
pair = self.db.save_prompt_pair("Cat", "Blur", tags=["pairtag"])
positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
with self.db.model.get_connection() as conn:
before = conn.execute("SELECT COUNT(*) FROM prompt_pair_tags").fetchone()[0]
self.run_bulk([positive], ["pairtag", "own"])
self.assertEqual(tags_of(self.db, positive), ["own", "pairtag"])
with self.db.model.get_connection() as conn:
after = conn.execute("SELECT COUNT(*) FROM prompt_pair_tags").fetchone()[0]
self.assertEqual(after, before)
def test_a_prompt_that_would_pass_twenty_tags_is_skipped_untouched(self):
full = save(self.db, "Full", tags=[f"t{i}" for i in range(19)])
other = save(self.db, "Other")
before = updated_at(self.db, full)
result = self.run_bulk([full, other], ["x", "y"])
self.assertEqual((result["updated"], result["over_limit"]), (1, 1))
self.assertEqual(len(tags_of(self.db, full)), 19)
self.assertEqual(updated_at(self.db, full), before)
self.assertEqual(tags_of(self.db, other), ["x", "y"])
# Exactly twenty is allowed.
self.assertEqual(self.run_bulk([full], ["x"])["updated"], 1)
def test_changed_prompts_become_registered_library_parents(self):
a = save(self.db, "Alpha")
self.run_bulk([a], ["new"])
with self.db.model.get_connection() as conn:
self.assertIsNotNone(
conn.execute(
"SELECT 1 FROM prompt_library_state WHERE prompt_id=?", (a,)
).fetchone()
)
def test_negative_only_prompts_are_not_found(self):
pair = self.db.save_prompt_pair("Cat", "Blur")
negative = self.db.get_prompt_pair(pair)["negative_prompt_id"]
result = self.run_bulk([negative], ["x"])
self.assertEqual((result["updated"], result["not_found"]), (0, 1))
self.assertEqual(tags_of(self.db, negative), [])
def test_restored_storage_writes_nothing_not_even_a_tag_name(self):
a = save(self.db, "Alpha")
rotate_history_epoch(self.db.model.get_connection())
with self.assertRaises(LibraryCommandError) as caught:
self.run_bulk([a], ["fresh"])
self.assertEqual(caught.exception.code, "storage-changed")
self.assertEqual(tags_of(self.db, a), [])
self.assertNotIn("fresh", tag_names(self.db))
def test_a_failure_mid_write_leaves_every_prompt_as_it_was(self):
ids = [save(self.db, f"P{i}") for i in range(3)]
with self.db.model.get_connection() as conn:
conn.execute(
"CREATE TRIGGER fail_third BEFORE INSERT ON prompt_tags "
f"WHEN NEW.prompt_id={ids[2]} BEGIN SELECT RAISE(ABORT, 'boom'); END"
)
with self.assertRaises(sqlite3.DatabaseError):
self.run_bulk(ids, ["x"])
self.assertEqual([tags_of(self.db, pid) for pid in ids], [[], [], []])
self.assertNotIn("x", tag_names(self.db))
def test_entries_are_counted_after_dedupe_and_the_limit_names_prompts(self):
a = save(self.db, "Alpha")
self.assertEqual(self.run_bulk([a], ["dup"] * 30 + ["Dup"])["updated"], 1)
with self.assertRaises(ValueError) as caught:
self.run_bulk([a], [f"t{i}" for i in range(101)])
self.assertEqual(str(caught.exception), "At most 20 tags can be added at once")
def test_many_ids_are_read_in_chunks(self):
ids = [save(self.db, f"Many {i}") for i in range(1000)]
self.assertEqual(self.run_bulk(ids, ["bulk"])["updated"], 1000)
def test_rejects_invalid_arguments(self):
a = save(self.db, "Alpha")
for ids, tags, epoch in (
([a], [], self.epoch),
([a], [" "], self.epoch),
([a], "red", self.epoch),
([a], ["x" * 51], self.epoch),
([a], ["bad\x01"], self.epoch),
([a], ["a,b"], self.epoch),
([a], [f"t{i}" for i in range(21)], self.epoch),
([a], ["ok"], "not-a-uuid"),
([], ["ok"], self.epoch),
):
with self.subTest(tags=tags, epoch=epoch, ids=ids):
with self.assertRaises(ValueError):
self.run_bulk(ids, tags, epoch)
class TestBulkLibraryTagsRoute(RouteCoverageCase):
def epoch(self):
return self.api.db.get_library_options()["library_epoch"]
async def test_fenced_form_adds_tags_and_reports_counts(self):
ids = [self._save_prompt(f"route {i}") for i in range(2)]
status, body = await self._json(
"POST",
URL,
json={"prompt_ids": ids, "tags": ["Fresh"], "expected_epoch": self.epoch()},
)
self.assertEqual(status, 200)
self.assertEqual(
body,
{
"success": True,
"library_epoch": self.epoch(),
"tags": ["Fresh"],
"updated": 2,
"unchanged": 0,
"not_found": 0,
"over_limit": 0,
},
)
async def test_restored_storage_answers_409_storage_changed(self):
pid = self._save_prompt("route restore")
stale = self.epoch()
new_epoch = rotate_history_epoch(self.api.db.model.get_connection())
status, body = await self._json(
"POST",
URL,
json={"prompt_ids": [pid], "tags": ["x"], "expected_epoch": stale},
)
self.assertEqual((status, body["code"]), (409, "storage-changed"))
self.assertEqual(body["library_epoch"], new_epoch)
async def test_fenced_form_validates_its_body(self):
pid = self._save_prompt("route validate")
epoch = self.epoch()
for body in (
{"prompt_ids": [pid], "tags": [], "expected_epoch": epoch},
{"prompt_ids": [pid], "tags": "x", "expected_epoch": epoch},
{"prompt_ids": [pid], "tags": ["x" * 51], "expected_epoch": epoch},
{"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": "nope"},
{"prompt_ids": [True], "tags": ["ok"], "expected_epoch": epoch},
{
"prompt_ids": list(range(1, 1002)),
"tags": ["ok"],
"expected_epoch": epoch,
},
{"prompt_ids": [pid], "expected_epoch": epoch},
{"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": epoch, "x": 1},
):
with self.subTest(body=body):
status, answer = await self._json("POST", URL, json=body)
self.assertEqual(status, 400)
self.assertFalse(answer["success"])
self.assertEqual(tags_of(self.api.db, pid), [])
async def test_fenced_form_reports_an_unexpected_failure(self):
pid = self._save_prompt("route failure")
self._break("bulk_add_library_tags")
status, body = await self._json(
"POST",
URL,
json={"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": self.epoch()},
)
self.assertEqual(status, 500)
self.assertFalse(body["success"])
async def test_legacy_form_refuses_a_body_that_is_not_an_object(self):
for raw in ("[1, 2]", "not json"):
with self.subTest(raw=raw):
status, body = await self._json(
"POST", URL, data=raw, headers={"Content-Type": "application/json"}
)
self.assertEqual(status, 400)
self.assertFalse(body["success"])
async def test_legacy_form_is_unchanged(self):
pid = self._save_prompt("route legacy")
status, body = await self._json(
"POST", URL, json={"prompt_ids": [pid], "tags": ["Legacy"]}
)
self.assertEqual((status, body["updated_count"]), (200, 1))
self.assertNotIn("library_epoch", body)
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -209,6 +209,38 @@ class TestMove(_MoveCase):
)
self.assertEqual(ll.read_marker(self.target)["state"], ll.MARKER_MOVED)
def test_the_workspace_key_moves_with_the_library(self):
# #430: open pages keep their scopes after the restart that follows.
from utils.workspace_keys import KEY_FILE_NAME, load_install_secret
secret = load_install_secret(self.ext)
result = self.move()
self.assertIn(KEY_FILE_NAME, result.files)
self.assertEqual(load_install_secret(self.target), secret)
if os.name == "posix":
mode = os.stat(os.path.join(self.target, KEY_FILE_NAME)).st_mode
self.assertEqual(mode & 0o777, 0o600)
def test_a_key_that_cannot_be_copied_never_fails_the_move(self):
from utils.workspace_keys import KEY_FILE_NAME, load_install_secret
load_install_secret(self.ext)
real = lm._hash_copy
def failing(source, destination):
if os.path.basename(source) == KEY_FILE_NAME:
raise OSError("disk error")
return real(source, destination)
with mock.patch.object(lm, "_hash_copy", failing):
with self.assertLogs("prompt_manager.database.library_move", "WARNING"):
result = self.move()
self.assertNotIn(KEY_FILE_NAME, result.files)
self.assertEqual(_rows(self.new_db), self.before["rows"])
self.assertFalse(
any(n.startswith(lm.TEMP_PREFIX) for n in os.listdir(self.target))
)
def test_new_files_are_private(self):
if os.name != "posix":
self.skipTest("POSIX permissions")
+247
View File
@@ -0,0 +1,247 @@
"""The per-install workspace secret (#430, utils/workspace_keys.py).
A ComfyUI restart must not change the gallery root scopes or the workspace
cursors while the output roots stay the same, so the secret that keys them is
persisted beside the database instead of drawn per process.
"""
import importlib
import json
import logging
import os
import stat
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import utils.workspace_keys as workspace_keys # noqa: E402
from utils.workspace_keys import ( # noqa: E402
KEY_FILE_NAME,
WorkspaceKeys,
gallery_root_scope,
load_install_secret,
workspace_keys_for,
)
REPO = Path(__file__).resolve().parents[1]
class TempFolderCase(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(folder.cleanup)
self.folder = Path(os.path.realpath(folder.name))
self.key_file = self.folder / KEY_FILE_NAME
def leftovers(self):
return sorted(p.name for p in self.folder.iterdir() if p.name != KEY_FILE_NAME)
class TestLoadInstallSecret(TempFolderCase):
def test_a_missing_file_is_created_with_64_hex_and_owner_only_mode(self):
secret = load_install_secret(str(self.folder))
self.assertEqual(len(secret), 32)
text = self.key_file.read_text(encoding="ascii")
self.assertEqual(text, secret.hex() + "\n")
self.assertEqual(self.leftovers(), [], "no temp file is left behind")
if os.name == "posix":
self.assertEqual(stat.S_IMODE(self.key_file.stat().st_mode) & 0o077, 0)
def test_an_existing_file_is_reused(self):
first = load_install_secret(str(self.folder))
self.assertEqual(load_install_secret(str(self.folder)), first)
def test_an_uppercase_or_unterminated_file_is_still_read(self):
self.key_file.write_text("AB" * 32, encoding="ascii")
self.assertEqual(
load_install_secret(str(self.folder)), bytes.fromhex("ab" * 32)
)
def test_a_corrupt_file_is_replaced_with_a_warning(self):
for content in (b"", b"short", b"zz" * 32, b"ab" * 33, b"\xff" * 64):
with self.subTest(content=content):
self.key_file.write_bytes(content)
with self.assertLogs("prompt_manager.api", logging.WARNING) as logs:
secret = load_install_secret(str(self.folder))
self.assertEqual(len(secret), 32)
self.assertEqual(
self.key_file.read_text(encoding="ascii"), secret.hex() + "\n"
)
self.assertIn("replaced", "\n".join(logs.output))
self.assertNotIn(secret.hex(), "\n".join(logs.output))
self.assertEqual(self.leftovers(), [])
def test_a_lost_creation_race_reads_the_winners_file(self):
winner = "cd" * 32 + "\n"
real_link = os.link
def racing_link(source, target, *args, **kwargs):
Path(target).write_text(winner, encoding="ascii")
return real_link(source, target, *args, **kwargs) # FileExistsError
with patch.object(workspace_keys.os, "link", racing_link):
secret = load_install_secret(str(self.folder))
self.assertEqual(secret, bytes.fromhex("cd" * 32))
self.assertEqual(self.key_file.read_text(encoding="ascii"), winner)
self.assertEqual(self.leftovers(), [])
def test_a_file_system_without_hard_links_falls_back_to_replace(self):
def no_link(*args, **kwargs):
raise OSError(1, "Operation not permitted")
with patch.object(workspace_keys.os, "link", no_link):
secret = load_install_secret(str(self.folder))
self.assertEqual(self.key_file.read_text(encoding="ascii"), secret.hex() + "\n")
self.assertEqual(self.leftovers(), [])
def test_an_unwritable_folder_returns_none_and_leaves_nothing(self):
def refuse(*args, **kwargs):
raise PermissionError(13, "Permission denied")
with patch.object(workspace_keys.os, "open", refuse):
with self.assertLogs("prompt_manager.api", logging.WARNING):
self.assertIsNone(load_install_secret(str(self.folder)))
self.assertFalse(self.key_file.exists())
with self.assertLogs("prompt_manager.api", logging.WARNING):
self.assertIsNone(load_install_secret(str(self.folder / "missing")))
def test_an_unreadable_key_path_returns_none(self):
self.key_file.mkdir() # a folder where the file should be
with self.assertLogs("prompt_manager.api", logging.WARNING):
self.assertIsNone(load_install_secret(str(self.folder)))
def test_a_refused_replace_of_a_corrupt_key_falls_back(self):
# Windows refuses os.replace while another process holds the file open.
self.key_file.write_text("broken", encoding="ascii")
def refuse(*args, **kwargs):
raise PermissionError(13, "Access is denied")
with patch.object(workspace_keys.os, "replace", refuse):
with self.assertLogs("prompt_manager.api", logging.WARNING):
self.assertIsNone(load_install_secret(str(self.folder)))
self.assertEqual(self.key_file.read_text(encoding="ascii"), "broken")
self.assertEqual(self.leftovers(), [])
def test_a_lost_race_against_an_unfinished_file_is_unusable_not_replaced(self):
def racing_link(source, target, *args, **kwargs):
Path(target).write_text("ab", encoding="ascii") # still being written
raise FileExistsError(target)
with patch.object(workspace_keys.os, "link", racing_link):
with self.assertLogs("prompt_manager.api", logging.WARNING):
self.assertIsNone(load_install_secret(str(self.folder)))
self.assertEqual(self.key_file.read_text(encoding="ascii"), "ab")
@unittest.skipUnless(os.name == "posix", "POSIX permission bits")
def test_an_existing_key_readable_by_others_is_tightened(self):
secret = load_install_secret(str(self.folder))
os.chmod(self.key_file, 0o644)
self.assertEqual(load_install_secret(str(self.folder)), secret)
self.assertEqual(stat.S_IMODE(self.key_file.stat().st_mode), 0o600)
def test_old_temp_files_are_swept_and_fresh_ones_kept(self):
old = self.folder / f".{KEY_FILE_NAME}.dead.tmp"
fresh = self.folder / f".{KEY_FILE_NAME}.live.tmp"
other = self.folder / "prompts.db"
for path in (old, fresh, other):
path.write_text("x")
os.utime(old, (1, 1))
load_install_secret(str(self.folder))
self.assertEqual(self.leftovers(), sorted([fresh.name, other.name]))
class TestWorkspaceKeys(TempFolderCase):
def test_purposes_are_domain_separated_and_stable(self):
keys = workspace_keys_for(str(self.folder / "prompts.db"))
self.assertTrue(keys.persisted)
self.assertNotEqual(keys.root_scope, keys.cursor)
again = workspace_keys_for(str(self.folder / "prompts.db"))
self.assertEqual(
(again.root_scope, again.cursor), (keys.root_scope, keys.cursor)
)
self.assertNotIn(
self.key_file.read_text().strip(),
(keys.root_scope.hex(), keys.cursor.hex()),
)
def test_another_database_folder_gets_other_keys(self):
other = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(other.cleanup)
first = workspace_keys_for(str(self.folder / "prompts.db"))
second = workspace_keys_for(os.path.join(other.name, "prompts.db"))
self.assertNotEqual(first.root_scope, second.root_scope)
def test_an_unusable_folder_falls_back_to_a_process_key_with_a_warning(self):
with patch.object(workspace_keys, "load_install_secret", return_value=None):
with self.assertLogs("prompt_manager.api", logging.WARNING) as logs:
keys = workspace_keys_for(str(self.folder / "prompts.db"))
self.assertFalse(keys.persisted)
self.assertEqual(len(keys.root_scope), 32)
self.assertIn("restart", "\n".join(logs.output))
def test_memory_and_blank_paths_never_write_into_the_working_folder(self):
for path in (":memory:", "", None):
with self.subTest(path=path):
with patch.object(workspace_keys, "load_install_secret") as load:
with self.assertLogs("prompt_manager.api", logging.WARNING):
keys = workspace_keys_for(path)
load.assert_not_called()
self.assertFalse(keys.persisted)
def test_the_scope_is_the_historic_payload_under_the_root_scope_key(self):
import hashlib
import hmac
keys = WorkspaceKeys(b"r" * 32, b"c" * 32, True)
payload = json.dumps(
["prompt-manager-gallery-root-v1", 3, "/x/ü"],
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
self.assertEqual(
gallery_root_scope(keys, "/x/ü", 3),
hmac.new(b"r" * 32, payload, hashlib.sha256).hexdigest(),
)
class TestRestartKeepsScopes(TempFolderCase):
"""A new process or a reloaded module derives the same scope (#430)."""
def test_a_module_reload_keeps_the_scope(self):
db = str(self.folder / "prompts.db")
before = gallery_root_scope(workspace_keys_for(db), "/out", 0)
reloaded = importlib.reload(workspace_keys)
self.addCleanup(importlib.reload, workspace_keys)
self.assertEqual(
reloaded.gallery_root_scope(reloaded.workspace_keys_for(db), "/out", 0),
before,
)
def test_a_new_process_derives_the_same_scope(self):
db = str(self.folder / "prompts.db")
before = gallery_root_scope(workspace_keys_for(db), "/out", 0)
code = (
"import sys\n"
"from utils.workspace_keys import gallery_root_scope, workspace_keys_for\n"
"print(gallery_root_scope(workspace_keys_for(sys.argv[1]), '/out', 0))\n"
)
# -I would drop the repo from sys.path; the child imports only stdlib code.
result = subprocess.run(
[sys.executable, "-c", code, db],
cwd=REPO,
capture_output=True,
text=True,
timeout=60,
check=True,
)
self.assertEqual(result.stdout.strip(), before)
if __name__ == "__main__":
unittest.main()
+39 -15
View File
@@ -1,9 +1,9 @@
{
"admin.js": {
"lines": 37,
"branches": 61,
"functions": 32,
"statements": 37
"lines": 47,
"branches": 64,
"functions": 50,
"statements": 47
},
"api-client.js": {
"lines": 98,
@@ -38,7 +38,7 @@
"focused-node.js": {
"lines": 97,
"branches": 87,
"functions": 90,
"functions": 91,
"statements": 97
},
"gallery-settings.js": {
@@ -48,10 +48,10 @@
"statements": 98
},
"gallery.js": {
"lines": 49,
"branches": 68,
"functions": 50,
"statements": 49
"lines": 54,
"branches": 72,
"functions": 57,
"statements": 54
},
"html-escape.js": {
"lines": 98,
@@ -61,7 +61,7 @@
},
"image-helpers.js": {
"lines": 96,
"branches": 91,
"branches": 92,
"functions": 98,
"statements": 96
},
@@ -266,7 +266,7 @@
"workspace/image-tagging.mjs": {
"lines": 97,
"branches": 92,
"functions": 88,
"functions": 89,
"statements": 97
},
"workspace/images-category.mjs": {
@@ -275,6 +275,12 @@
"functions": 98,
"statements": 98
},
"workspace/images-collections.mjs": {
"lines": 98,
"branches": 89,
"functions": 98,
"statements": 98
},
"workspace/images-export.mjs": {
"lines": 98,
"branches": 98,
@@ -299,6 +305,12 @@
"functions": 98,
"statements": 98
},
"workspace/images-source-recovery.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/images-state.mjs": {
"lines": 98,
"branches": 98,
@@ -347,6 +359,12 @@
"functions": 98,
"statements": 98
},
"workspace/inspector-collections.mjs": {
"lines": 98,
"branches": 92,
"functions": 98,
"statements": 98
},
"workspace/library-editor-state.mjs": {
"lines": 96,
"branches": 92,
@@ -371,6 +389,12 @@
"functions": 93,
"statements": 94
},
"workspace/library-selection.mjs": {
"lines": 98,
"branches": 86,
"functions": 84,
"statements": 98
},
"workspace/library-state.mjs": {
"lines": 98,
"branches": 95,
@@ -415,7 +439,7 @@
},
"workspace/local-resolver.mjs": {
"lines": 96,
"branches": 86,
"branches": 87,
"functions": 98,
"statements": 96
},
@@ -535,7 +559,7 @@
},
"workspace/system-diagnostics.mjs": {
"lines": 95,
"branches": 86,
"branches": 87,
"functions": 94,
"statements": 95
},
@@ -631,7 +655,7 @@
},
"workspace/vocabulary-dialog.mjs": {
"lines": 98,
"branches": 88,
"branches": 89,
"functions": 95,
"statements": 98
},
@@ -644,7 +668,7 @@
"workspace/vocabulary.mjs": {
"lines": 98,
"branches": 91,
"functions": 93,
"functions": 98,
"statements": 98
},
"workspace/workspace-dialogs.mjs": {
+213
View File
@@ -0,0 +1,213 @@
"""The per-install secret behind the workspace's gallery scopes and cursors (#430).
A page addresses a listed file as ``{root_index, root_scope, path}``. The root
scope is an HMAC over the root's index and canonical path, so the server can
refuse an action aimed at a root that was renumbered or replaced since the page
listed it (``py/api/images.py`` ``_gallery_scope_error``). The listing's keyset
cursors are MACed with a second key, so a forged position never reaches the
sort code.
Both keys used to be drawn per process, so every ComfyUI restart made every
open workspace tab stale. They now derive from one random secret persisted in
``workspace-scope.key`` beside the database (never in ``config.json`` and never
in the database, so a backup or an export does not carry it). When the folder
cannot hold it, the process falls back to a per-process secret, as before.
Standard library only: a fresh process can derive the same scope without
importing the API.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import re
import secrets
import stat
import time
from typing import NamedTuple
logger = logging.getLogger("prompt_manager.api")
KEY_FILE_NAME = "workspace-scope.key"
SECRET_BYTES = 32
_HEX = re.compile(r"[0-9a-fA-F]{64}")
# Bounded read: a valid file is 64 hex characters and a newline.
_MAX_FILE_BYTES = 128
_ROOT_SCOPE_PURPOSE = b"prompt-manager/gallery-root-scope/v1"
_CURSOR_PURPOSE = b"prompt-manager/workspace-cursor/v1"
_SCOPE_TAG = "prompt-manager-gallery-root-v1"
_TEMP_PREFIX = f".{KEY_FILE_NAME}."
_TEMP_SUFFIX = ".tmp"
_STALE_TEMP_SECONDS = 60
class WorkspaceKeys(NamedTuple):
"""The derived keys; ``persisted`` is False for a per-process fallback."""
root_scope: bytes
cursor: bytes
persisted: bool
def _read(path: str) -> bytes | None:
"""The secret in *path*, None when it is missing or not 64 hex characters.
Raises OSError for anything but a missing file (a folder, no permission).
"""
try:
with open(path, "rb") as handle:
raw = handle.read(_MAX_FILE_BYTES + 1)
except FileNotFoundError:
return None
try:
text = raw.decode("ascii").strip()
except UnicodeDecodeError:
return b""
return bytes.fromhex(text) if _HEX.fullmatch(text) else b""
def _write_exclusive(path: str, secret: bytes) -> None:
"""Create *path* (it must not exist) holding *secret*, owner-only, fsynced."""
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
descriptor = os.open(path, flags, 0o600)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write((secret.hex() + "\n").encode("ascii"))
handle.flush()
os.fsync(handle.fileno())
except BaseException:
_unlink(path)
raise
def _unlink(path: str) -> None:
try:
os.unlink(path)
except OSError:
pass
def _sweep_temps(directory: str) -> None:
"""Remove temp files a crashed first start left (older than a minute)."""
try:
names = os.listdir(directory)
except OSError:
return
cutoff = time.time() - _STALE_TEMP_SECONDS
for name in names:
if name.startswith(_TEMP_PREFIX) and name.endswith(_TEMP_SUFFIX):
path = os.path.join(directory, name)
try:
if os.stat(path).st_mtime < cutoff:
os.unlink(path)
except OSError:
pass
def _tighten(path: str) -> None:
"""On POSIX an existing key readable by group or others becomes 0o600."""
if os.name != "posix":
return # the file inherits the folder's ACL, as the database does
try:
if stat.S_IMODE(os.stat(path).st_mode) & 0o077:
os.chmod(path, 0o600)
except OSError:
pass
def _store(directory: str, path: str, secret: bytes, *, replace: bool) -> None:
"""Publish *secret* at *path*; a new file never overwrites a concurrent winner.
The secret is written to a temp file first. ``os.link`` then fails when
*path* exists, so of two first starts exactly one file wins
(FileExistsError for the other). A file system without hard links (FAT,
some shares) creates *path* with ``O_EXCL`` instead. *replace* (a corrupt
key) swaps the file with ``os.replace``; Windows refuses that while
another process holds the file open, which the caller treats as unusable.
"""
temp = os.path.join(
directory, f"{_TEMP_PREFIX}{secrets.token_hex(8)}{_TEMP_SUFFIX}"
)
_write_exclusive(temp, secret)
try:
if replace:
os.replace(temp, path)
return
try:
os.link(temp, path)
except FileExistsError:
raise
except OSError:
# Not atomic: a second first start reading mid-write would see a
# corrupt key. Rare (two first starts on FAT or a share at once);
# each process still uses the file it reads back.
_write_exclusive(path, secret)
finally:
_unlink(temp)
def load_install_secret(directory: str) -> bytes | None:
"""The persisted secret of *directory*, created on first use; None if unusable.
After any write the file is read back and its content is what counts, so
processes racing on a first start end with the one secret on disk.
"""
path = os.path.join(directory, KEY_FILE_NAME)
try:
_sweep_temps(directory)
current = _read(path)
if current:
_tighten(path)
return current
corrupt = current is not None
try:
_store(directory, path, secrets.token_bytes(SECRET_BYTES), replace=corrupt)
except FileExistsError:
pass # another process created it first: its file counts
stored = _read(path)
if not stored:
raise OSError(f"{KEY_FILE_NAME} is not readable after it was written")
if corrupt:
logger.warning(
f"{KEY_FILE_NAME} was not a valid key and was replaced; "
"open workspace pages must refresh once"
)
return stored
except OSError as exc:
logger.warning(f"Could not use {KEY_FILE_NAME}: {exc.__class__.__name__}")
return None
def _derive(secret: bytes, purpose: bytes) -> bytes:
return hmac.new(secret, purpose, hashlib.sha256).digest()
def workspace_keys_for(db_path: str | None) -> WorkspaceKeys:
"""The keys for the library whose database is *db_path* (blocking I/O)."""
secret = None
if isinstance(db_path, str) and db_path.strip() and db_path != ":memory:":
secret = load_install_secret(os.path.dirname(os.path.abspath(db_path)))
persisted = secret is not None
if not persisted:
logger.warning(
"Workspace scopes use a per-process key: open workspace pages must "
"refresh after a restart"
)
secret = secrets.token_bytes(SECRET_BYTES)
return WorkspaceKeys(
_derive(secret, _ROOT_SCOPE_PURPOSE),
_derive(secret, _CURSOR_PURPOSE),
persisted,
)
def gallery_root_scope(keys: WorkspaceKeys, canonical_root: str, index: int) -> str:
"""The 64-hex scope of the root at *index* (its canonical path string)."""
payload = json.dumps(
[_SCOPE_TAG, index, canonical_root], separators=(",", ":"), ensure_ascii=False
).encode("utf-8")
return hmac.new(keys.root_scope, payload, hashlib.sha256).hexdigest()
+2 -1
View File
@@ -40,7 +40,8 @@
videoAutoplay: false,
videoMute: true,
videoLoop: true,
workspaceLayout: "grid",
// #451: Images opens in Masonry unless this browser chose another layout.
workspaceLayout: "masonry",
workspaceDensity: "comfortable",
});
+2 -1
View File
@@ -78,7 +78,8 @@ export function parseCollections(data) {
}
/** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */
const nameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC');
export const collectionNameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC');
const nameKey = collectionNameKey;
const ordered = list => [...list].sort((a, b) => {
const left = nameKey(a.name), right = nameKey(b.name);
return left < right ? -1 : left > right ? 1 : a.id - b.id;
+18 -1
View File
@@ -8,6 +8,7 @@ import { showWorkspaceToast } from './toast.mjs';
import { openCopyFallback } from './workspace-dialogs.mjs';
import { displayPreferences } from './preferences.mjs';
import { fallBackToOriginal, thumbnailSource } from './thumbnail-support.mjs';
import { openCollectionsDialog } from './inspector-collections.mjs';
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
@@ -220,7 +221,12 @@ function prefixedChip(prefix, text) {
/** D124: the inspector's line while "Load metadata on selection" is off. */
export const DEFERRED_TEXT = 'Metadata loads when you ask for it.';
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback}) {
/**
* #331 D139: with the client store `collections`, a listed file's footer ends with "Collections…", which opens its
* 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}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
@@ -317,6 +323,17 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
});
footer.append(history);
}
const member = collections ? revealTarget(record.identity) : null;
if (member) {
const button = createButton('');
button.dataset.collections = '';
button.append(createWorkspaceIcon('folder'), ' Collections…');
// A re-render replaces the button: closing focuses the current one, else the inspector's heading.
const fallback = () => container.querySelector('button[data-collections]') ?? container.querySelector('h2');
contentScope.listen(button, 'click', () => openCollectionsDialog({scope, collections, target:member, trigger:button,
onChanged:onCollectionsChanged, focusFallback:fallback}));
footer.append(button);
}
if (!full && footer.childNodes.length) container.append(footer);
}
/**
+24 -4
View File
@@ -34,9 +34,13 @@ const MAX_SUGGESTIONS = 100;
const MAX_INSTRUCTION_CHARS = 4000;
const EDITABLE = new Set(['ready', 'generate-error', 'write-error', 'conflict']);
// Answers after which only a fresh observation can save (the capture or epoch is gone); Retry observes again (D116).
const REOPEN = {'storage-changed': 'storage-changed', 'file-changed': 'file-changed', 'source-changed': 'file-changed', 'capture-expired': 'capture-expired'};
const REOBSERVE = new Set(Object.values(REOPEN));
const REOBSERVE = new Set(['storage-changed', 'file-changed', 'capture-expired']);
// #430: a changed source (the page's root scope no longer names its root) is never retried as is: the page re-scopes
// the target (`retarget`) or the dialog offers a refresh.
const REOPEN = Object.freeze({...Object.fromEntries([...REOBSERVE].map(code => [code, code])), 'source-changed': 'source-changed'});
const RETRYABLE = new Set(['observe-error', 'generate-error', 'unknown-outcome', ...REOBSERVE]);
// States whose reason blocks Save (and Generate): the dialog shows it beside its actions (D147).
const BLOCKED = new Set(['observe-error', 'source-changed', 'unknown-outcome', ...REOBSERVE]);
// Generic failure envelopes whose fixed sentence the dialog words itself.
const GENERIC_CODES = new Set(['observe-failed', 'suggest-failed', 'save-failed', 'capture-error']);
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
@@ -269,6 +273,7 @@ function derive(state) {
canSave: editable,
canGenerate: editable && state.generateSupported,
canRetry: RETRYABLE.has(state.phase),
blocked: BLOCKED.has(state.phase),
};
}
@@ -291,7 +296,7 @@ function validatePreset(preset) {
*/
export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}, preset = null} = {}) {
requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required');
const imageTarget = validateTarget(target);
let imageTarget = validateTarget(target);
requireValue(SOURCES.has(source), 'Unknown tagging source');
requireValue(typeof onLateSave === 'function', 'onLateSave must be a function');
const chosen = validatePreset(preset);
@@ -364,6 +369,10 @@ export function createImageTaggingController({api, target, source = 'images', on
}
} catch (error) {
if (closed || serial !== openSerial) return;
if (isApiError(error) && error.code === 'source-changed') {
set({phase: 'source-changed', statusText: error.message});
return;
}
set({phase: 'observe-error', statusText: messageOf(error, COPY.observeFailed)});
return;
}
@@ -531,6 +540,16 @@ export function createImageTaggingController({api, target, source = 'images', on
return send(lastBody);
}
/**
* #430: the page found the file under a fresh `target` (its sources were renumbered): observe it there, keeping the
* typed draft. Refused (false) while a save is in flight or after close; a Generate for the old target is dropped.
*/
function retarget(next) {
if (closed || saving) return Promise.resolve(false);
imageTarget = validateTarget(next);
return open({keepDraft: observedOnce || Boolean(state.manual)}).then(() => true);
}
function cancel() {
if (closed) return;
set({closed: true});
@@ -556,7 +575,8 @@ export function createImageTaggingController({api, target, source = 'images', on
return {
get state() {return state;},
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose,
get target() {return imageTarget;},
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, retarget, cancel, dispose,
};
}
+68 -3
View File
@@ -24,6 +24,9 @@ import { showWorkspaceToast } from './toast.mjs';
import { cardTitle } from './images-state.mjs';
const MANUAL_HELP = 'Comma-separated. Generated suggestions are added only after you approve them.';
// #430: while the page looks for a renumbered source's file, and the preview that a refused capture leaves.
const FINDING_SOURCE = 'The source of this image changed. Finding it in the refreshed image collection…';
const PREVIEW_UNAVAILABLE = 'Preview unavailable';
const SCOPE_NOTE = 'Tags belong to this image. Resolve its prompt association separately in Needs review.';
// The option inputs per controller key; the classifier keys only exist for WD14 models.
const OPTION_IDS = Object.freeze({
@@ -194,6 +197,13 @@ function previewOf(image, state) {
if (video) { node.muted = true; node.preload = 'metadata'; node.playsInline = true; }
return node;
}
/** D147: the preview box a failed or refused capture leaves, saying so (the reason is beside the actions). */
function unavailablePreview() {
const node = el('div', PREVIEW_UNAVAILABLE, 'tag-image-preview tag-preview-empty');
node.setAttribute('role', 'img');
node.setAttribute('aria-label', PREVIEW_UNAVAILABLE);
return node;
}
/**
* Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
@@ -207,7 +217,8 @@ function previewOf(image, state) {
* Escape and × write nothing and call `onStop` once. Swapping the next image's dialog in is not a stop.
*/
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null, preset = null,
autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null}) {
autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null, onSourceChanged = null,
onRefresh = null}) {
const target = imageTarget(image);
if (!target) throw new TypeError('The image has no tagging target');
const review = mode === 'review';
@@ -216,7 +227,8 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
// Set once this dialog hands over to the session (Approve, Skip): its close is then not a Stop.
let advancing = false;
const saved = ({filename: name, tags, toast}) => {
const event = {target, filename: name, tags, source};
// A retargeted dialog (#430) reports the file under its current identity.
const event = {target: {...controller.target}, filename: name, tags, source};
announceSaved(event);
if (toast) showWorkspaceToast(toast);
onSaved?.(event);
@@ -280,6 +292,15 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
: (last ? 'Save and finish' : 'Save and next'));
if (review) actions.append(cancel, skip, primary);
else actions.append(cancel, primary);
// D147: the reason a state blocks Save (and Generate), on its own row above the buttons; for a changed source the
// page is asked to find the file again (`onSourceChanged`), else Refresh is offered.
const notice = el('div', undefined, 'tag-action-notice');
const noticeText = el('span');
noticeText.setAttribute('role', 'status');
const refresh = createButton(onRefresh ? 'Refresh image collection' : 'Reload page');
notice.append(noticeText);
const tried = new Set();
let finding = false, current = image;
const nodes = [heading];
if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
@@ -334,8 +355,45 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
closeWorkspaceDialog();
saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast});
}
/** #430: ask the page once per target where a renumbered source's file is now; a found file is retargeted. */
function findSource() {
const {root_index: index, root_scope: held} = controller.target;
const attempt = `${index}:${held}`;
if (!onSourceChanged || tried.has(attempt)) return;
tried.add(attempt);
finding = true;
Promise.resolve().then(() => onSourceChanged({...current, ...controller.target})).catch(() => null).then(fresh => {
finding = false;
if (!open || completed) return;
const next = imageTarget(fresh);
if (next && controller.state.phase === 'source-changed') {
current = fresh;
controller.retarget(next);
} else render(controller.state);
});
}
function renderNotice(state) {
const changed = state.phase === 'source-changed';
const text = !state.blocked ? '' : changed && finding ? FINDING_SOURCE : state.statusText;
setText(noticeText, text);
const offer = changed && !finding;
if (offer !== refresh.isConnected) {
if (offer) notice.append(refresh);
else refresh.remove();
}
// Its own first row; Retry still leads the buttons after it (D116).
if (text && actions.firstElementChild !== notice) actions.prepend(notice);
else if (!text && notice.isConnected) notice.remove();
// The empty preview of a refused or failed capture says why it is empty.
if (state.blocked && !state.image && preview.tagName !== 'DIV') {
const next = unavailablePreview();
preview.replaceWith(next);
preview = next;
}
}
function render(state) {
if (!open || completed) return;
if (state.phase === 'source-changed' && !finding) findSource();
if (state.image && state.image !== shownImage) renderImage(state.image);
// The observation's saved tags, a kept draft, or a conflict's current set ahead of the draft (I2).
if (state.draftRevision !== shownDraft) {
@@ -344,7 +402,8 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
}
if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
setText(count, state.countText);
setText(status, state.statusText);
// A blocking reason is shown once, beside the actions (D147).
setText(status, state.blocked ? '' : state.statusText);
setText(primary, review ? reviewLabel(state) : state.primaryLabel);
setDisabled(primary, !state.canSave);
// D90: while Approve's save is in flight the session cannot be left, so its count includes that save.
@@ -356,10 +415,16 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
retry.remove();
}
}
renderNotice(state);
if (state.phase === 'saved') complete(state);
}
cancel.addEventListener('click', () => { if (!disabled(cancel)) closeWorkspaceDialog(); });
refresh.addEventListener('click', () => {
closeWorkspaceDialog();
if (onRefresh) onRefresh();
else window.location.reload();
});
skip.addEventListener('click', () => {
if (!open || advancing || disabled(skip)) return;
advancing = true;
+8 -4
View File
@@ -4,7 +4,8 @@ import { countOf } from './plural.mjs';
* The Images selection bar's "Set category" (#332, D140; the concept's bulk-category → simpleInput). Apply sets each
* selected image's own category (`PUT /prompt_manager/images/category`); "Use prompt category" removes it after a
* confirmation (`DELETE`), so the image shows its prompt's category again. It never writes a prompt: prompt
* categories are set in the Prompt library editor and on the legacy admin page's "Set Category".
* categories are set in the Prompt library editor, the Prompt library's selection bar (#437, library-selection.mjs)
* and on the legacy admin page's "Set Category".
*/
export const CATEGORY_URL = '/prompt_manager/images/category';
export const IMAGE_CATEGORIES_URL = '/prompt_manager/images/categories';
@@ -20,11 +21,14 @@ export const CATEGORY_MAX_LENGTH = 100;
// One request names at most 500 images (DC2), as "Add tags" does.
export const MAX_CATEGORY_IMAGES = 500;
/** The success toast of a set: unchanged files count as set; files no source lists are named after. */
export function setOutcome(result, category) {
/**
* The success toast of a set: unchanged items count as set; items that could not be found are named after. `noun` is
* what was set (the Prompt library's selection bar passes "prompt", #437).
*/
export function setOutcome(result, category, noun = 'image') {
const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0;
const missing = Number(result?.not_found) || 0;
const done = `Category “${category}” set on ${countOf(updated + unchanged, 'image')}.`;
const done = `Category “${category}” set on ${countOf(updated + unchanged, noun)}.`;
return missing ? `${done} ${missing.toLocaleString()} could not be found.` : done;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* The Images selection bar's collection actions (#331 D138, design §7.2–7.4):
*
* - **Add to collection** (the concept's `bulk-collection` simpleInput, app.js a1392–1398): "Add selected images to
* collection", "Collection name", Add, with the existing names offered as suggestions. A name that matches a
* collection ignoring case adds to it; a new name creates it. The selection stays and the listing is not reloaded
* (an add never changes it).
* - **Remove from collection** (beyond the concept, only while a collection filter is active): no confirmation; the
* listing then reloads through `onChanged`, keeping the checked files still listed.
*
* The routes take at most 500 explicit files. "All N matching" (#392, D135) is accepted only when every match is
* loaded and checked and there are at most 500 of them; otherwise the action toasts "Select at most 500 images." and
* sends nothing (ruling for #331 PR 3: the collection routes have no filter body).
*/
import { bulkTargets } from './images-tagging-bulk.mjs';
import { ADD_DIALOG_ACTION, ADD_DIALOG_LABEL, ADD_DIALOG_TITLE, ADD_FAILED_TOAST, FILES_NOT_FOUND_TOAST, MAX_COLLECTION_NAME_LENGTH,
REMOVE_FAILED_TOAST, TOO_MANY_TOAST, addedMessage, isCollectionGone, normalizeCollectionName, removedMessage,
writeFailure } from './collections.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
/**
* The files an action sends for the checked `images` (listing order): `{targets}`, or `{refusal}` with the toast.
* With `allMatching`, `total` is the listing's match count; only a fully loaded match is sent.
*/
export function collectionTargets(images, { allMatching = false, total = images.length } = {}) {
if (allMatching && total !== images.length) return { refusal: TOO_MANY_TOAST };
const targets = bulkTargets(images);
if (!targets) return { refusal: TOO_MANY_TOAST };
return targets.length ? { targets } : { refusal: FILES_NOT_FOUND_TOAST };
}
/**
* Add to collection over `images` (the checked files, taken now). `collections` is the client store; `openInput` is
* the dialog seam. A stale source (409) calls `onSourceChanged()` after its toast, so the page reloads the listing.
*/
export function openAddToCollection({ scope, collections, images, trigger = null, allMatching = false, total = images.length,
notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = null }) {
const { targets, refusal } = collectionTargets(images, { allMatching, total });
if (refusal) { notify(refusal); return; }
openInput({ scope, trigger, title: ADD_DIALOG_TITLE, label: ADD_DIALOG_LABEL, action: ADD_DIALOG_ACTION,
maxLength: MAX_COLLECTION_NAME_LENGTH, suggestions: collections.collections.map(item => item.name),
onApply: async text => {
const name = normalizeCollectionName(text);
if (name === null) { notify(ADD_FAILED_TOAST); return; }
try {
const result = await collections.add({ name }, targets);
if (!scope.signal.aborted) notify(addedMessage(result));
} catch (error) {
if (scope.signal.aborted) return;
notify(writeFailure(error, ADD_FAILED_TOAST));
if (error?.code === 'source-changed') onSourceChanged?.();
}
} });
}
/**
* Remove the checked `images` from the open collection `collectionId`. Resolves once the toast is shown; a confirmed
* remove, or a collection found deleted meanwhile, calls `onChanged()` so the page lists it again.
*/
export async function removeFromCollection({ scope, collections, collectionId, images, allMatching = false, total = images.length,
notify = showWorkspaceToast, onChanged = null, onSourceChanged = null }) {
const { targets, refusal } = collectionTargets(images, { allMatching, total });
if (refusal) { notify(refusal); return; }
try {
const result = await collections.remove(collectionId, targets);
if (scope.signal.aborted) return;
notify(removedMessage(result.collection.name, result.removed));
onChanged?.();
} catch (error) {
if (scope.signal.aborted) return;
notify(writeFailure(error, REMOVE_FAILED_TOAST));
if (isCollectionGone(error)) onChanged?.();
else if (error?.code === 'source-changed') onSourceChanged?.();
}
}
+38 -2
View File
@@ -8,6 +8,7 @@ import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './work
import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-tagging-bulk.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs';
import { openAddToCollection, removeFromCollection } from './images-collections.mjs';
const CHECKBOX = '.image-overlays input[type="checkbox"]';
const CHECKBOX_LABEL = '.image-overlays > label';
@@ -42,9 +43,14 @@ function el(tag, text, className) {
* `clear` serve the grid's keys (images.mjs). `getListing()` gives `{total, hasMore, positiveCount}` and
* `getFilter()` the listing filter: once every loaded image is checked and more match, the bar offers "Select all N
* matching", whose actions send that filter to the server.
*
* Collections (#331 D138, design §7): with the client store `collections`, Add to collection follows Set category;
* while a collection filter is active (`collection`, its id) Remove from collection follows it, and a confirmed remove
* calls `onCollectionsChanged()` to reload the listing. A stale source on either calls `onSourceChanged()`.
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()) }) {
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
collection = null, onCollectionsChanged = null, onSourceChanged = 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.
@@ -75,7 +81,19 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
...matchingTarget() }));
bulk.append(addTags, autoTag);
}
bulk.append(setCategory, exportMetadata, clearButton);
bulk.append(setCategory);
if (collections) {
const addToCollection = createButton('Add to collection');
scope.listen(addToCollection, 'click', () => openAddToCollection({ scope, collections, images: checkedImages(),
trigger: addToCollection, ...collectionSelection(), onSourceChanged }));
bulk.append(addToCollection);
}
if (collections && collection) {
const removeButton = createButton('Remove from collection');
scope.listen(removeButton, 'click', () => removeChecked(removeButton));
bulk.append(removeButton);
}
bulk.append(exportMetadata, clearButton);
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
@@ -143,6 +161,24 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
function matchingTarget() {
return allMatching ? { filter: getFilter(), count: getListing().total } : {};
}
/** The collection actions' view of "all N matching" (#331 ruling): the loaded images and the match count. */
function collectionSelection() {
return { allMatching, total: allMatching ? getListing().total : checked.size };
}
/** Remove from collection: one write at a time; the selection stays (a reload keeps what is still listed). */
let removing = false;
async function removeChecked(button) {
if (removing) return;
removing = true;
const release = busy(button);
try {
await removeFromCollection({ scope, collections, collectionId: Number(collection), images: checkedImages(),
...collectionSelection(), onChanged: onCollectionsChanged, onSourceChanged });
} finally {
removing = false;
release();
}
}
/** Toggle one image on its own; it becomes the anchor. */
function toggle(key, on = !isChecked(key)) {
settleMatching();
@@ -0,0 +1,96 @@
/**
* Finding a file again after its source changed (#430).
*
* A listed file is addressed as `{root_index, root_scope, path}`. When the sources are renumbered every per-file
* action on it answers 409 `source-changed`. `rescopeImage` asks the server (POST /prompt_manager/images/rescope)
* for the file's current listing descriptor: the server maps the target only when its scope was issued for one of
* the current roots, so a removed or replaced source (or a changed key) answers null and the page offers Refresh.
*
* `createSourceRecovery` is the page's single flight: one rescope per held `(key, scope)` pair, its result shared
* by every caller (the inspector, the viewer and the Tag image dialog may all ask for the same file), and a pair
* already tried answers its first result, so a refusal can never loop. `apply(image, fresh, started)` is the page's
* part (refresh the collection, move the selection); `started` is the page generation when the rescope began, so a
* page whose filters changed meanwhile can leave itself alone. `reset()` (a manual Refresh) forgets every pair.
*/
import { imageKey } from './images-state.mjs';
export const RESCOPE_URL = '/prompt_manager/images/rescope';
// A page holds a handful of stale files at once; older pairs are forgotten first.
const MEMO_LIMIT = 64;
const hex64 = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
function validTarget(value) {
return value !== null && typeof value === 'object' && Number.isSafeInteger(value.root_index) && value.root_index >= 0 &&
hex64(value.root_scope) && typeof value.path === 'string' && value.path.length > 0;
}
/** The fresh descriptor of `image` (same path, current root and scope), else null. */
export async function rescopeImage(api, image, {signal} = {}) {
if (!validTarget(image)) return null;
let value;
try {
value = await api.post(RESCOPE_URL, {target: {root_index: image.root_index, root_scope: image.root_scope, path: image.path}},
{signal});
} catch (error) {
// 409 (the source is gone) or 404 (the file is gone): final, the caller offers Refresh. Anything else (no
// answer, a 5xx) is rethrown so the pair may be asked again.
if (error?.status === 404 || error?.status === 409) return null;
throw error;
}
const fresh = value?.success === true ? value.image : null;
if (!validTarget(fresh) || fresh.path !== image.path) return null;
return Object.freeze({...fresh});
}
export function createSourceRecovery({api, signal, apply, generation = () => 0}) {
// An embedded gallery's own page adapter may have no `post`: its rescope then simply finds nothing.
if (typeof apply !== 'function') throw new TypeError('createSourceRecovery needs apply');
const memo = new Map();
function recover(image) {
if (!validTarget(image)) return Promise.resolve(null);
const pair = `${imageKey(image)}|${image.root_scope}`;
if (memo.has(pair)) return memo.get(pair);
const started = generation();
const pending = rescopeImage(api, image, {signal})
.then(fresh => (fresh && !signal?.aborted ? apply(image, fresh, started) : null))
.then(result => result ?? null, () => {
if (memo.get(pair) === pending) memo.delete(pair);
return null;
});
memo.set(pair, pending);
if (memo.size > MEMO_LIMIT) memo.delete(memo.keys().next().value);
return pending;
}
return {recover, reset: () => memo.clear()};
}
/**
* The Images page's `apply` (#430). Keys are `root_index:path`, so after a renumbering nothing from the old list is
* kept by key (a checked or selected key could name a same-named file in another source): the viewer closes, the
* checks clear, a Source filter on the old index follows the file, and only the file itself stays selected while the
* collection lists again. An unchanged numbering re-reads the loaded range in place. `page` holds the page's hooks.
*/
export function recoveredSource(page) {
return async (image, fresh, started) => {
// The held scope still names its root: nothing is stale (a media error had another cause).
if (fresh.root_index === image.root_index && fresh.root_scope === image.root_scope) return null;
if (!page.current(started)) return fresh;
const selected = page.selected(image);
if (fresh.root_index === image.root_index) {
if (selected) page.inspect(fresh);
await page.stream.refresh().catch(() => {});
return fresh;
}
page.closeViewer();
page.clearChecks();
const {filters} = page;
if (filters.root === String(image.root_index)) filters.root = String(fresh.root_index);
else if (filters.root !== 'all') { filters.root = 'all'; filters.subfolder = ''; }
if (selected) page.inspect(fresh);
else page.dropSelection();
page.updateScope();
page.stream.reset(filters, {load:page.listsNow()});
page.rediscover();
return fresh;
};
}
+22 -6
View File
@@ -1,15 +1,31 @@
import { noun } from './plural.mjs';
/** Identity is the source and relative path; content hashes may be shared by files. */
/** The layout and size a saved view, an embedded gallery and a page without readable preferences use. */
const FIXED_VIEW = Object.freeze({ layout: 'grid', density: 'comfortable' });
/**
* The layout and thumbnail size an Images visit starts with (#249 System S3). The last visit (the current view) keeps
* its own; a saved view starts as before, at Grid and Comfortable; any other new visit starts from System ›
* Appearance's defaults (`preferences`, preferences.mjs values, or null when unavailable).
* The defaults a new Images visit starts from: System › Appearance's (`preferences`, preferences.mjs values; Masonry
* when the layout is missing, #451), or the fixed Grid/Comfortable when they are unavailable (null).
*/
export function visitDefaults(preferences) {
if (!preferences) return { ...FIXED_VIEW };
return { layout: preferences.workspaceLayout || 'masonry', density: preferences.workspaceDensity || 'comfortable' };
}
/**
* The layout and thumbnail size an Images visit starts with (#249 System S3). A saved view starts as before, at Grid
* and Comfortable; any other new visit starts from `visitDefaults(preferences)`. The last visit (`prior`, the current
* view) keeps a value its toolbar changed; a value it kept from the defaults it started from (`prior.defaults`) follows
* defaults saved since, in this tab or another (#451). A prior without `defaults` keeps its own values.
*/
export function initialImagesView({ prior = null, saved = null, preferences = null } = {}) {
if (prior) return { layout: prior.layout || 'grid', density: prior.density || 'comfortable' };
if (saved || !preferences) return { layout: 'grid', density: 'comfortable' };
return { layout: preferences.workspaceLayout || 'grid', density: preferences.workspaceDensity || 'comfortable' };
if (!prior) return saved ? { ...FIXED_VIEW } : visitDefaults(preferences);
const own = { layout: prior.layout || FIXED_VIEW.layout, density: prior.density || FIXED_VIEW.density };
if (!prior.defaults || !preferences) return own;
const current = visitDefaults(preferences);
const follow = key => own[key] === prior.defaults[key] ? current[key] : own[key];
return { layout: follow('layout'), density: follow('density') };
}
export function imageKey(image) {
+2 -2
View File
@@ -14,9 +14,9 @@ export const REFRESH_FAILED = 'Images could not be refreshed after the tag save.
/** Open Tag image for `image` when `action` is its card's Tag button (without selecting the card, as the concept's
* `image-tag` does); false for any other control. */
export function openCardTagging({api, scope, image, action, embedded}) {
export function openCardTagging({api, scope, image, action, embedded, onSourceChanged = null, onRefresh = null}) {
if (!isTagButton(action)) return false;
openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action});
openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action, onSourceChanged, onRefresh});
return true;
}
+63 -10
View File
@@ -1,5 +1,5 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, listingFilter } from './images-state.mjs';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, visitDefaults, listingFilter } from './images-state.mjs';
import { createImageCard } from './image-card.mjs';
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
@@ -22,6 +22,7 @@ import { displayPreferences } from './preferences.mjs';
import { onImageTagsSaved } from './image-tagging.mjs';
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
import { createSourceRecovery, recoveredSource } from './images-source-recovery.mjs';
export { onFavoriteChange } from './images-favorites.mjs';
/** The concept's panel without an image (app.js updateImages a456–458). */
@@ -68,6 +69,7 @@ const RETRY_RESCAN = 'Rescan from Import to retry.';
// The concept's empty collection (app.js updateImages).
const NO_MATCHES = 'No matching images. Try another search or category.';
const RETRY_REFRESH = 'Refresh image collection to retry.';
const CURSOR_STALE = 'The image collection changed. Refresh image collection to continue.';
/** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */
function loadFailure(sourcesRetry, categoriesFailed) {
if (sourcesRetry && categoriesFailed) return `Sources, folders and categories could not be loaded. ${sourcesRetry}`;
@@ -114,10 +116,12 @@ export function selectedImage() {
* Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is
* off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls
* `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a
* deleted collection, D140) the page lists nothing and says so.
* deleted collection, D140) the page lists nothing and says so. `collections` (the shell's client store) adds the
* selection bar's Add to collection, and Remove from collection while a collection is open (D138).
*/
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null} = {}) {
favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null,
collections = null} = {}) {
const scope = createScope();
const dispose = () => scope.dispose();
parentScope.signal.addEventListener('abort', dispose, {once:true});
@@ -128,7 +132,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// System › Appearance: the display defaults this visit starts from (preferences.mjs); null without gallery-settings.js.
const preferences = displayPreferences();
const inspection = createInspectionController({api, autoLoad:preferences?.autoLoadMetadata !== false, cache:preferences?.cacheMetadata !== false});
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
// The panel's listed file: a changed source is looked up once, the panel's Refresh stays the fallback (#430).
let selectedImage = null;
scope.cleanup(inspection.subscribe(next => {
detailInspector?.setState(next);
if (next.refreshRequired && next.code === 'source-changed' && selectedImage && imageKey(selectedImage) === selection) {
recovery.recover(selectedImage);
}
}));
scope.cleanup(() => inspection.dispose());
scope.cleanup(() => detailInspector?.dispose());
// A Tags & categories Browse (browseImages) is handed to this mount once: a fresh Images visit filtered by its
@@ -145,6 +156,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
// toolbar to change them, so it keeps its fixed Grid/Comfortable. The metadata and card defaults apply everywhere.
const start = initialImagesView({prior, saved, preferences:embedded ? null : preferences});
// The defaults this visit started from: a return follows defaults saved since unless the toolbar changed a value (#451).
const startDefaults = visitDefaults(embedded ? null : preferences);
let density = start.density;
let metrics = {...INITIAL_METRICS};
let layout = start.layout, selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
@@ -171,7 +184,7 @@ 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, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
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;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
@@ -293,12 +306,22 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
getFilter:() => {
const filter = listingFilter(filters), scope = state?.items[0]?.root_scope;
return filter.root !== undefined && typeof scope === 'string' ? {...filter, root_scope:scope} : filter;
}});
},
// #331 D138: Add to collection, and Remove from collection on an open collection, which then lists it again.
collections, collection:filters.collection || null, onCollectionsChanged:collectionChanged,
onSourceChanged:refreshInspectionCollection});
if (bar) main.append(bar.element);
main.append(body);
// #331 D139: a change the inspector or the viewer confirmed that touches the open collection lists it again; the
// viewer reports only once it closes.
const inspectorCollections = embedded ? null : collections;
const collectionsChangedBy = ({added, removed}) => {
if (filters.collection && [...added, ...removed].includes(Number(filters.collection))) collectionChanged();
};
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
collections:inspectorCollections,onCollectionsChanged:collectionsChangedBy,
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => {
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onMediaError:image => recovery.recover(image),onRestoreFocus:image => {
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
if (index >= 0) focusImage(index);else focusFallback.focus({preventScroll:true});
}});
@@ -310,9 +333,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
function inspect(image) {
detailInspector?.dispose();
selection = imageKey(image);
selectedImage = image;
for (const video of details.querySelectorAll('video')) releaseVideo(video);
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection});
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection,
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy});
detailInspector.setState(inspection.state);
inspection.select(image);
for (const [key, card] of rendered) markSelected(card, key === selection);
@@ -350,7 +375,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const action = event.target.closest('.image-card-actions button');
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})) inspect(image);
else if (action && !openCardTagging({api, scope, image, action, embedded, onSourceChanged:recovery.recover,
onRefresh:refreshInspectionCollection})) inspect(image);
});
function renderVisible() {
@@ -498,6 +524,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
function continuationText(next) {
if (recovering) return 'Images paused until sources and folders are available.';
if (collectionGone(next)) return '';
if (next.error?.code === 'cursor-stale') return CURSOR_STALE;
if (next.error) return 'Images could not be loaded. Retry or rescan the collection.';
if (next.loading && !next.loaded) return 'Loading images…';
if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`;
@@ -535,6 +562,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
setText(message, continuationText(next));
setAttr(message, 'role', next.error && !gone ? 'alert' : 'status');
retry.hidden = !next.error || gone;
// A cursor the server can no longer verify is never resent: Retry becomes the collection refresh (#430).
setText(retry, next.error?.code === 'cursor-stale' ? 'Refresh image collection' : 'Retry');
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));
if (onCollectionMissing && filters.collection && isCollectionGone(next.error) && reportedMissing !== next.error) {
@@ -579,7 +608,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}
}
scope.listen(more, 'click', pressLoad);
scope.listen(retry, 'click', pressLoad);
scope.listen(retry, 'click', () => (state?.error?.code === 'cursor-stale' ? refreshInspectionCollection() : pressLoad()));
function maybeLoad() {
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('#viewer-dialog[open]')) return;
const bounds = continuation.getBoundingClientRect();
@@ -607,8 +636,18 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
details.replaceChildren(noSelection());
}
selection = null;
selectedImage = null;
}
// Bumped by every reset: a source recovery that began before it leaves the page alone (#430).
let generation = 0;
const recovery = createSourceRecovery({api, signal:scope.signal, generation:() => generation, apply:recoveredSource({
selected:image => selection === imageKey(image), inspect, dropSelection, stream, filters, updateScope,
closeViewer:() => { mediaViewer.stopSlideshow(); main.querySelector('#viewer-dialog[open]')?.close(); },
clearChecks:() => bar?.clear(), listsNow:() => !recovering,
current:started => !scope.signal.aborted && started === generation,
rediscover:() => loadOptions().then(found => { if (found) renderLoadStatus(); })})});
function reset() {
generation += 1;
mediaViewer.stopSlideshow();
main.querySelector('#viewer-dialog[open]')?.close();
dropSelection({keepPanel:recovering});
@@ -710,8 +749,22 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const band = parseFloat(getComputedStyle(optionsStatus).scrollMarginBlockEnd) || 0;
if (bottom > innerHeight - band && top < innerHeight) optionsStatus.scrollIntoView({block:'nearest', inline:'nearest'});
}
/**
* A confirmed collection change that may change this listing (a remove from the open collection): the loaded range
* is read again in place, keeping the scroll and the checked files still listed. A failed re-read lists the page
* again from its start, whose failure shows the usual Retry (or the deleted-collection page).
*/
async function collectionChanged() {
if (scope.signal.aborted || !filters.collection || recovering) return;
try {
await stream.refresh();
} catch {
if (!scope.signal.aborted) reset();
}
}
async function refreshInspectionCollection() {
if (scope.signal.aborted) return;
recovery.reset();
// Numeric roots may now name another source; retire the old stream first.
recovering = true;
filters.root = 'all';filters.subfolder = '';
+1 -1
View File
@@ -410,7 +410,7 @@ function mountWorkspace() {
if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') {
mountNewPromptAction(main, api, view);
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
collection, onCollectionMissing, onSaveView:saveView('images', view), activity});
collection, onCollectionMissing, onSaveView:saveView('images', view), activity, collections});
return;
}
if (route.id === 'library') {
+1
View File
@@ -21,6 +21,7 @@ export class InspectionSourceChangedError extends Error {
super(SOURCE_CHANGED);
this.name = 'InspectionSourceChangedError';
this.refreshRequired = true;
this.code = 'source-changed';
}
}
+189
View File
@@ -0,0 +1,189 @@
/**
* The shared inspector's "Collections…" dialog (#331 D139, design §8), beyond the concept: one listed file's
* collections as a checklist in the sidebar's order, a New collection field, then Cancel and Save.
*
* - The file's membership is read when the dialog opens ("Loading collections…"); a failed read says "Collections
* could not be loaded." with Retry, and Save waits for a read that worked.
* - Each row is the concept's checkbox row (`label.tag-suggestion`, app.js a1128). With no collection the list says
* "No collections yet. Name one below."
* - Save sends one request per change, in order: the new name, each newly checked collection, each unchecked one.
* Nothing changed sends nothing and closes. All sent: the dialog closes with "Collections updated.". A failure keeps
* it open with "The collections could not be updated. Try again." and the membership read again; the changes that
* went through still reach `onChanged({added, removed})` (collection ids), as a full success does, but only once the
* dialog closes: a page that lists the file again (a collection page) would otherwise remove the dialog before its
* failure is read. Save waits for the membership read again.
* - A New collection name equal to a listed collection ignoring case is that collection's checkbox, checked. A name
* that cannot be a collection name is refused on the field; nothing is sent and the checkbox edits stay.
* - Focus starts on the field and moves to the first checkbox once they are listed; closing returns it to the
* trigger, or to `focusFallback()` when an inspector re-render removed the trigger.
*/
import { createButton } from '../shared/toolkit/core.js';
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { COLLECTIONS_UPDATED_TOAST, MAX_COLLECTION_NAME_LENGTH, MEMBERSHIP_FAILED_TEXT, MEMBERSHIP_LOADING_TEXT,
NO_COLLECTIONS_TEXT, UPDATE_FAILED_TEXT, collectionNameKey, normalizeCollectionName } from './collections.mjs';
export const COLLECTIONS_DIALOG_TITLE = 'Collections';
export const NEW_COLLECTION_LABEL = 'New collection';
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* Open the dialog for `target` (`{root_index, root_scope, path}`) with the client store `collections`. `openDialog`
* and `closeDialog` are the dialog seams; `notify` the toast.
*/
export function openCollectionsDialog({ scope, collections, target, trigger = null, notify = showWorkspaceToast, onChanged = null,
focusFallback = null, openDialog = openContentDialog, closeDialog = closeWorkspaceDialog }) {
const local = new AbortController();
const active = () => !scope.signal.aborted && !local.signal.aborted;
let member = null, saving = false, listed = false;
// The confirmed changes, reported once when the dialog closes (review M2).
const changes = { added: [], removed: [] };
const status = el('p', '', 'small');
const retry = createButton('Retry');
const list = el('div');
list.setAttribute('role', 'group');
list.setAttribute('aria-label', COLLECTIONS_DIALOG_TITLE);
const input = el('input');
input.value = '';
input.maxLength = MAX_COLLECTION_NAME_LENGTH;
input.autocomplete = 'off';
input.addEventListener('input', () => input.setCustomValidity?.(''));
const problem = el('p', '', 'small');
problem.setAttribute('role', 'alert');
problem.hidden = true;
const actions = el('div', undefined, 'dialog-actions');
const cancel = createButton('Cancel');
const save = createButton('Save', 'ca-primary');
actions.append(cancel, save);
const blocked = () => member === null || saving;
const paintSave = () => {
if (blocked()) save.setAttribute('aria-disabled', 'true');
else save.removeAttribute('aria-disabled');
};
retry.addEventListener('click', () => load());
cancel.addEventListener('click', () => { if (!saving) closeDialog(); });
save.addEventListener('click', () => { if (!blocked()) submit(); });
/** One checkbox row per collection, checked where the file is a member. */
function renderList() {
list.replaceChildren();
if (!collections.collections.length) {
list.append(el('p', NO_COLLECTIONS_TEXT, 'small'));
return;
}
for (const item of collections.collections) {
const row = el('label', undefined, 'tag-suggestion');
const box = el('input');
Object.assign(box, { type: 'checkbox', id: `collection-choice-${item.id}`, checked: member.has(item.id) });
box.dataset.collectionId = String(item.id);
row.htmlFor = box.id;
row.append(box, el('span', item.name));
list.append(row);
}
}
function showStatus(text, failed) {
status.textContent = text;
status.setAttribute('role', failed ? 'alert' : 'status');
status.hidden = !text;
retry.hidden = !failed;
}
/** Save waits while the membership is read (again). */
function loading() {
member = null;
paintSave();
list.replaceChildren();
showStatus(MEMBERSHIP_LOADING_TEXT, false);
}
async function load() {
loading();
try {
const ids = await collections.membership(target);
if (!active()) return;
member = new Set(ids);
showStatus('', false);
renderList();
// The first listing takes focus from the untouched field to the first checkbox (review L6).
if (!listed && !input.value) firstBox().focus();
listed = true;
} catch {
if (!active()) return;
showStatus(MEMBERSHIP_FAILED_TEXT, true);
}
paintSave();
}
const boxesOf = () => [...list.children].map(row => row.children?.[0]).filter(box => box?.dataset?.collectionId);
const firstBox = () => boxesOf()[0] ?? input;
/**
* The requests Save sends, in order; null when the new name cannot be a collection name. A name equal to a listed
* collection's ignoring case is that collection's checkbox, checked (review L4).
*/
function plan() {
const typed = input.value.trim();
const name = typed ? normalizeCollectionName(typed) : '';
if (name === null) return null;
const named = name ? collections.collections.find(item => collectionNameKey(item.name) === collectionNameKey(name)) : null;
const steps = name && !named ? [{ add: { name } }] : [];
const boxes = boxesOf().map(box => ({ id: Number(box.dataset.collectionId), checked: box.checked }))
.map(box => (box.id === named?.id ? { ...box, checked: true } : box));
for (const box of boxes) if (box.checked && !member.has(box.id)) steps.push({ add: { collectionId: box.id } });
for (const box of boxes) if (!box.checked && member.has(box.id)) steps.push({ remove: box.id });
return steps;
}
async function submit() {
problem.textContent = '';
problem.hidden = true;
const steps = plan();
if (steps === null) {
// Review L5: refused on the field; nothing is sent and the checkbox edits stay.
input.setCustomValidity?.(UPDATE_FAILED_TEXT);
input.reportValidity?.();
input.focus();
return;
}
if (steps.length === 0) { closeDialog(); return; }
saving = true;
paintSave();
let failed = false;
for (const step of steps) {
try {
if (step.add) changes.added.push((await collections.add(step.add, [target])).collection.id);
else changes.removed.push((await collections.remove(step.remove, [target])).collection.id);
} catch {
failed = true;
break;
}
if (!active()) return;
if (step.add?.name) input.value = '';
}
if (!active()) return;
saving = false;
if (!failed) {
closeDialog();
notify(COLLECTIONS_UPDATED_TOAST);
return;
}
problem.textContent = UPDATE_FAILED_TEXT;
problem.hidden = false;
// Review M1: Save waits from here until the membership is read again. A collection deleted meanwhile is
// re-read by the store; the list then shows what the server holds.
loading();
await collections.settled();
if (active()) await load();
}
function closed() {
local.abort();
if (changes.added.length || changes.removed.length) onChanged?.({ added: [...changes.added], removed: [...changes.removed] });
// The shell focuses a trigger still on the page; one an inspector re-render replaced falls back (review L6).
if (trigger && !trigger.isConnected) focusFallback?.()?.focus();
}
showStatus('', false);
openDialog({ scope, title: COLLECTIONS_DIALOG_TITLE, trigger, canClose: () => !saving, onClose: closed, focusTarget: input,
nodes: [status, retry, list, field(NEW_COLLECTION_LABEL, input, 'collection-new'), problem, actions] });
load();
}
+280
View File
@@ -0,0 +1,280 @@
import { createButton } from '../shared/toolkit/core.js';
import { selectionLabel, toggleSelection } from './images-state.mjs';
import { applyKeys, rangeKeys } from './images-multiselect.mjs';
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, setOutcome } from './images-category.mjs';
import { NO_TAGS, parseBulkTags } from './images-tagging-bulk.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
import { countOf } from './plural.mjs';
import { showWorkspaceToast } from './toast.mjs';
/**
* The Prompt library's selection and its bulk actions (D148, D151, beyond the concept): "Add tags" (#442, D151) and "Set
* category" (#437), the legacy admin page's bulk Add Tags and Set Category in the workspace. Built from the concept's
* Images parts only: each card's `.image-overlays` checkbox and the `.bulkbar` row ("N selected", the actions in the
* Images bar's order, Clear). The selection is a set of
* prompt ids over the shelf's loaded items, so windowed-out cards keep their state; Shift+click on a checkbox applies
* the anchor's range (the Images helpers, D135). The dialog, the range helpers, the count and the toast text are the
* Images bar's own; one bar core shared with images-selection.mjs is a follow-up (D148).
*
* Apply posts `{prompt_ids, category|tags, expected_epoch}` to `POST /prompt_manager/bulk/category` or `/bulk/tags`:
* the epoch the shelf was read under, so a restored storage answers 409 `storage-changed` and nothing is written.
* Add tags writes prompt tags only, never image tags or auto-tags.
*/
export const CATEGORY_URL = '/prompt_manager/bulk/category';
export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' });
// The route's cap (`BULK_CATEGORY_MAX_PROMPTS`): more opens nothing.
export const MAX_CATEGORY_PROMPTS = 1000;
export const TOO_MANY_PROMPTS = 'Select at most 1,000 prompts.';
export const STORAGE_CHANGED = 'Prompt storage was restored since this page loaded. No category was changed.';
export const UNCONFIRMED = 'The category change could not be confirmed. The Library was reloaded.';
export const NONE_FOUND = 'None of the selected prompts could be found. The Library was reloaded.';
export const CONTROL_CHARACTERS = 'A category cannot contain control characters.';
export { CATEGORY_FAILED };
export const TAGS_URL = '/prompt_manager/bulk/tags';
export const TAG_OPTIONS_URL = '/prompt_manager/tags';
export const PROMPT_TAGS_DIALOG = Object.freeze({ title: 'Tag selected prompts', label: 'Tags, separated by commas', action: 'Apply' });
// What the editor's save accepts for one prompt (`validate_tags`): the route refuses more.
export const MAX_PROMPT_TAGS = 20;
export const MAX_TAG_LENGTH = 50;
export const TAG_PROBLEMS = Object.freeze({ none: NO_TAGS, many: 'Enter at most 20 tags.',
long: 'A tag can have at most 50 characters.', control: 'A tag cannot contain control characters.' });
export const TAGS_FAILED = 'Tags could not be added. Try again.';
export const TAGS_STORAGE_CHANGED = 'Prompt storage was restored since this page loaded. No tags were added.';
export const TAGS_UNCONFIRMED = 'Adding the tags could not be confirmed. The Library was reloaded.';
// Answers that may follow a committed write (the Library editor's unknown outcome): no status, or a gateway's.
const UNCONFIRMED_STATUSES = new Set([502, 503, 504]);
const CHECKBOX = '.image-overlays input[type="checkbox"]';
const CARD = 'article[data-prompt-id]';
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* What a settled write means for the page: `toast` to show, whether the write may have `committed` (the page then
* invalidates its cached Library views) and whether the shelf must `reload` (which clears the selection).
*/
export function writeOutcome(result, failure, kind = 'category') {
const copy = WRITES[kind];
if (failure === null && result?.success) {
const found = ['updated', 'unchanged', 'over_limit'].reduce((sum, key) => sum + (Number(result[key]) || 0), 0);
if (!found) return { toast: NONE_FOUND, committed: false, reload: true };
return { toast: copy.success(result), committed: (Number(result.updated) || 0) > 0, reload: true };
}
if (failure?.code === 'storage-changed') return { toast: copy.storage, committed: false, reload: true };
const status = failure?.status;
if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: copy.unconfirmed, committed: true, reload: true };
return { toast: copy.failed, committed: false, reload: false };
}
/** The Add tags toast (#442): what was added, then what already had them, was skipped or could not be found. */
export function tagsOutcome(result) {
const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0;
const skipped = Number(result?.over_limit) || 0, missing = Number(result?.not_found) || 0;
const names = Array.isArray(result?.tags) ? result.tags.join(', ') : '';
const parts = [];
if (updated) parts.push(`Added “${names}” to ${countOf(updated, 'prompt')}.`);
if (unchanged) parts.push(`${countOf(unchanged, 'prompt')} already had ${updated ? 'them' : `“${names}”`}.`);
if (skipped) parts.push(`${countOf(skipped, 'prompt')} would pass ${MAX_PROMPT_TAGS} tags and ${skipped === 1 ? 'was' : 'were'} skipped.`);
if (missing) parts.push(`${missing.toLocaleString('en-US')} could not be found.`);
return parts.join(' ');
}
const WRITES = Object.freeze({
category: { success: result => setOutcome(result, result.category, 'prompt'), storage: STORAGE_CHANGED,
unconfirmed: UNCONFIRMED, failed: CATEGORY_FAILED },
tags: { success: tagsOutcome, storage: TAGS_STORAGE_CHANGED, unconfirmed: TAGS_UNCONFIRMED, failed: TAGS_FAILED },
});
/**
* "Select all N loaded" (#444 ruling, D151; the legacy page's select-all checkbox): offered while some loaded prompts
* are unchecked and no more are loaded than an action accepts. It sits where D135's "Select all N matching" sits on
* Images, but that offer appears once every loaded image is checked; this one reaches only the loaded prompts.
*/
export function offersLoaded(selected, keys) {
return selected.size > 0 && keys.length <= MAX_CATEGORY_PROMPTS && keys.some(key => !selected.has(key));
}
export const loadedOffer = count => `Select all ${count.toLocaleString('en-US')} loaded`;
/** The Add tags field's check before Apply: the parsed names, as `validate_tags` would judge them. */
export function tagsProblem(text) {
const tags = parseBulkTags(text);
if (!tags.length) return TAG_PROBLEMS.none;
if (tags.length > MAX_PROMPT_TAGS) return TAG_PROBLEMS.many;
if (tags.some(tag => tag.length > MAX_TAG_LENGTH)) return TAG_PROBLEMS.long;
if (tags.some(tag => /[\x00-\x1f]/.test(tag))) return TAG_PROBLEMS.control;
return null;
}
/** The field's check before Apply: what `validate_category` refuses beyond the length the field already limits. */
export function categoryProblem(value) {
return /[\x00-\x1f]/.test(value) ? CONTROL_CHARACTERS : null;
}
/**
* Mount the selection on the shelf `grid`. `getItems()` is the shelf's loaded items in order, `getEpoch()` the
* storage epoch they were read under, `getCategories()` the Library's category list (the field's suggestions).
* `onCommitted(kind, receipt)` (`category` or `tags`) runs after a write that may have changed prompts, even once the page is gone (the page
* invalidates its cached Library views); `onReload()` then reloads the mounted shelf (which calls `clear`). Focus moves
* to `focusFallback` when it was on the bar the reload hides.
*/
export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted,
onReload }) {
let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false;
const element = el('div');
element.id = 'library-bulkbar';
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const addTags = createButton('Add tags');
const setCategory = createButton('Set category');
const clearButton = createButton('Clear');
const selectLoaded = createButton('');
const actions = [addTags, setCategory, selectLoaded];
bulk.append(count, addTags, setCategory, clearButton);
scope.listen(selectLoaded, 'click', () => {
if (writing) return;
selectAll();
// The pressed offer left the bar: focus moves to its first action, as after D135's offer.
addTags.focus({ preventScroll: true });
});
scope.listen(addTags, 'click', () => openTags());
scope.listen(setCategory, 'click', () => openCategory());
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
// Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear.
scope.listen(element, 'keydown', event => {
if (event.key !== 'Escape' || !checked.size) return;
event.preventDefault();
clear();
focusFallback.focus({ preventScroll: true });
});
const ids = () => getItems().map(item => item.id);
function render() {
if (!checked.size) { bulk.remove(); return; }
const label = selectionLabel(checked.size);
if (count.textContent !== label) count.textContent = label;
const keys = ids();
if (offersLoaded(checked, keys)) {
const offer = loadedOffer(keys.length);
if (selectLoaded.textContent !== offer) selectLoaded.textContent = offer;
if (count.nextElementSibling !== selectLoaded) count.after(selectLoaded);
} else selectLoaded.remove();
if (bulk.parentNode !== element) element.append(bulk);
}
function paint() {
for (const box of grid.querySelectorAll(CHECKBOX)) {
const on = checked.has(Number(box.closest(CARD).dataset.promptId));
if (box.checked !== on) box.checked = on;
}
}
function update(next) { checked = next; paint(); render(); }
function clear() {
anchor = null;
if (checked.size) update(Object.freeze(new Set()));
}
/** Every loaded prompt, windowed-out cards included; the anchor stays. */
function selectAll() {
update(applyKeys(checked, ids(), true));
}
// The checkbox toggles natively; a Shift+click applies the anchor's range with the box's new state.
scope.listen(grid, 'click', event => {
const box = event.target.closest('.image-overlays > label');
if (box && event.shiftKey) shiftBox = Number(box.closest(CARD).dataset.promptId);
});
scope.listen(grid, 'change', event => {
if (!event.target.matches(CHECKBOX)) return;
const id = Number(event.target.closest(CARD).dataset.promptId), on = event.target.checked;
if (shiftBox === id) {
const keys = rangeKeys(ids(), anchor, id);
if (anchor === null || keys.length === 1) anchor = id;
update(applyKeys(checked, keys, on));
} else {
anchor = id;
update(toggleSelection(checked, id, on));
}
shiftBox = null;
});
/** The card's overlay: the concept's `.image-overlays > label > input`, checked from the selection. */
function overlayFor(item, name) {
const overlays = el('div', undefined, 'image-overlays'), label = el('label'), box = el('input');
box.type = 'checkbox';
box.checked = checked.has(item.id);
box.setAttribute('aria-label', `Select ${name}`);
label.append(box);
overlays.append(label);
return overlays;
}
/** The selected loaded prompts and the shelf epoch, or null after a toast says why nothing can be sent. */
function target(failed) {
if (writing) return null;
const loaded = new Set(ids()), selected = [...checked].filter(id => loaded.has(id));
if (selected.length > MAX_CATEGORY_PROMPTS) { showWorkspaceToast(TOO_MANY_PROMPTS); return null; }
const epoch = getEpoch();
if (!selected.length || typeof epoch !== 'string') { showWorkspaceToast(failed); return null; }
return { prompt_ids: selected, expected_epoch: epoch };
}
function openCategory() {
const chosen = target(CATEGORY_FAILED);
if (!chosen) return;
openInputDialog({ scope, trigger: setCategory, title: PROMPT_CATEGORY_DIALOG.title, label: PROMPT_CATEGORY_DIALOG.label,
action: PROMPT_CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, suggestions: getCategories(),
validate: categoryProblem, onApply: category => write(CATEGORY_URL, { ...chosen, category }, 'category') });
}
/** Add tags (#442): the tag vocabulary as suggestions, the editor's list; a failed read opens without them. */
async function openTags() {
const chosen = target(TAGS_FAILED);
if (!chosen) return;
let suggestions = null;
try {
const result = await api.get(TAG_OPTIONS_URL, { signal: scope.signal, cache: 'no-store' });
if (Array.isArray(result?.tags)) suggestions = result.tags.filter(tag => typeof tag === 'string');
} catch {
// Suggestions are auxiliary; the field works without them.
}
if (scope.signal.aborted) return;
openInputDialog({ scope, trigger: addTags, ...PROMPT_TAGS_DIALOG, suggestions, validate: tagsProblem,
onApply: text => write(TAGS_URL, { ...chosen, tags: parseBulkTags(text) }, 'tags') });
}
/** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */
async function write(url, body, kind) {
writing = true;
for (const button of actions) button.setAttribute('aria-disabled', 'true');
let result = null, failure = null;
try {
result = await api.post(url, body);
} catch (error) {
failure = error;
} finally {
writing = false;
for (const button of actions) button.removeAttribute('aria-disabled');
}
const outcome = writeOutcome(result, failure, kind);
// A left page still invalidates what it cached, so a return never shows the old categories.
if (outcome.committed) onCommitted(kind, result?.success ? result : { phase: 'unconfirmed' });
if (scope.signal.aborted) return;
const focused = element.contains(document.activeElement);
showWorkspaceToast(outcome.toast);
if (!outcome.reload) return;
onReload();
if (focused) focusFallback.focus({ preventScroll: true });
}
render();
return {
element,
overlayFor,
isChecked: id => checked.has(id),
hasSelection: () => checked.size > 0,
clear,
selectAll,
/** A loaded batch: the count and the offer follow it. */
listingChanged: () => { if (checked.size) render(); },
/** Whether a Set category write is still in flight. */
writing: () => writing,
};
}
+10 -5
View File
@@ -19,7 +19,7 @@ function validatePage(page,cursor) {
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
}
export function createLibraryStream({api,onChange=()=>{},restore}) {
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
let query=parameters({},40),request,disposed=false,seen=new Set();
if(restore) {
query=parameters(restore.filters,40);const saved=restore.state;
@@ -27,7 +27,7 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
const firstPagePending=saved?.variantTotal===null && Array.isArray(saved.items) && !saved.items.length && saved.total===0 && saved.cursor===null && saved.hasMore===true;
if(!firstPagePending) validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent');
state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
state=Object.freeze({...saved,epoch:saved.epoch ?? null,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
}
const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);};
async function loadMore() {
@@ -37,14 +37,19 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
try {
const page=await api.get(`/prompt_manager/library?${params}`,{signal:current.signal});
if(disposed || current!==request) return 0;
validatePage(page,cursor);const incoming=[];const added=new Set();
validatePage(page,cursor);
// #437: the storage epoch the shelf's items were read under, which a bulk write sends as its fence; a later
// page from a restored storage is refused (its items would belong to another database).
const epoch=typeof page.library_epoch==='string'?page.library_epoch:null;
if(state.epoch!==null && epoch!==state.epoch) throw new TypeError('Prompt storage changed while the library was loading');
const incoming=[];const added=new Set();
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
for(const value of added) seen.add(value);
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
}
return {get state(){return state;},loadMore,async reset(filters={}) {
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
},dispose(){disposed=true;request?.abort();request=null;}};
}
/**
+24 -9
View File
@@ -15,6 +15,7 @@ import {pageHeading} from './heading.mjs';
import {mountToolbar} from './toolbar.mjs';
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';
let shelfVisit=null;
@@ -30,6 +31,8 @@ const metadataDrafts=new Map();
const archivedVisible=new Map();
// The concept's three sorts first, then the Library's appended Oldest first (D14).
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
const CARD_CONTROLS='button, input[type="checkbox"]';
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.';
function el(tag,text,className) {
@@ -171,6 +174,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
// The count is announced when it changes; a reload keeps the last count shown, so nothing else re-announces it.
captionCount.setAttribute('role','status');
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
// #437 D148: select prompts and set their category (the legacy admin's Set Category); a write reloads the shelf.
let categoryNames=[];
const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();}});
const message=el('p','','library-status');message.setAttribute('role','status');
const continuation=el('div',undefined,'library-continuation');
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
@@ -201,7 +209,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
scope.listen(exportButton,'click',exportLibrary);
headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.');
// Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5).
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation);main.append(section);
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,selection.element,caption,grid,continuation);main.append(section);
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
@@ -246,7 +254,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive='';
const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history='';
actions.append(send,explore);footer.append(facts,actions);
article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks};
article.append(selection.overlayFor(item,name),art,content,footer);article.dataset.index=index;return {node:article,blocks};
}
function captureAnchor() {
// Mounted rows can already differ from estimates before their observer batch.
@@ -340,10 +348,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
if(changed) {pendingAnchor ||= anchor;schedule();}
}):null;
measurementObserver?.observe(grid);
function focusCard(index,last=false) {
/** Focus a card's first or `last` control (Tab across the window edge), or its title (`title`: Home/End/arrows). */
function focusCard(index,last=false,title=false) {
if(index<0 || index>=state.items.length) return;
if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node;
const controls=card.querySelectorAll('button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
const controls=card.querySelectorAll(CARD_CONTROLS);(title?card.querySelector('.card-title > button'):last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
}
// The title and Explore history open the prompt's history; the footer send sends the full positive (D23).
scope.listen(grid,'click',event=>{
@@ -355,15 +364,19 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
scope.listen(grid,'keydown',event=>{
const card=event.target.closest('article[data-prompt-id]');if(!card) return;
const index=Number(card.dataset.index);
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));}
// D151 (#444 ruling): Ctrl/Cmd+A selects every loaded prompt and Escape clears, as on Images (focus stays).
if((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase()==='a') {event.preventDefault();selection.selectAll();return;}
if(event.key==='Escape' && selection.hasSelection()) {event.preventDefault();selection.clear();return;}
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1),false,true);}
else if(event.key==='Tab') {
const controls=card.querySelectorAll('button');const next=index+(event.shiftKey?-1:1);
const controls=card.querySelectorAll(CARD_CONTROLS);const next=index+(event.shiftKey?-1:1);
const edge=event.shiftKey?controls[0]:controls[controls.length-1];
if(event.target===edge && state.items[next] && !cards.has(state.items[next].id)) {event.preventDefault();focusCard(next,event.shiftKey);}
}
});
function render(next) {
if(items!==next.items) {
const batch=items!==next.items;
if(batch) {
pendingAnchor ||= captureAnchor();items=next.items;indexesById=new Map(items.map((item,index)=>[item.id,index]));dirty=true;
if(!items.length) {
anchor=null;pendingAnchor=null;heights.clear();layout={rows:[],height:0};
@@ -372,6 +385,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
} else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;}
}
state=next;
// A loaded batch: the selection bar's count and Select all N loaded follow it (D151).
if(batch) selection.listingChanged();
if(next.variantTotal!==null) {
const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
@@ -392,10 +407,10 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
optionsRequest?.abort();const request=optionsRequest=new AbortController();
try {
const data=await api.get('/prompt_manager/library/options',{signal:request.signal});if(scope.signal.aborted || optionsRequest!==request) return;
toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
categoryNames=[...data.categories];toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent=CATEGORIES_FAILED;optionsStatus.hidden=false;}}
}
const reset=()=>{restoreScrollY=null;toolbar.setActiveFilters(refinements());return stream.reset(filters);};
const reset=()=>{restoreScrollY=null;selection.clear();toolbar.setActiveFilters(refinements());return stream.reset(filters);};
/** A toolbar report; a debounced search that lands after a select already applied the same values reloads nothing. */
function applyToolbar(value) {
const next={text:value.q.trim(),category:value.category,sort:value.sort};
+1 -1
View File
@@ -4,7 +4,7 @@
// tests/test_local_inspect_parity.py compares every answer with the server's on the same records. Values follow
// Python's model (py-json.mjs): dict order, int versus float, code-point lengths, `str.strip()`.
//
// Task 8 ported execution evidence and graph tracing for one sampler; Task 9 adds candidate selection among several
// Task 8 ported execution evidence and graph tracing for one sampler; Task 9 added candidate selection among several
// samplers and detailer stages (local-resolver-stages.mjs) and A1111 `parameters` (local-parameters.mjs). No DOM.
import { PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyStr, pyGet } from './py-json.mjs';
import {
+15 -4
View File
@@ -19,7 +19,12 @@ function element(tag, className, text) {
return node;
}
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
/**
* #331 D139: with the client store `collections`, the viewer's inspector offers "Collections…"; the changes it confirms
* reach `onCollectionsChanged({added, removed})` once the viewer closes, so the viewer keeps its place while open.
*/
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,adapter = rootedFileAdapter,
collections = null,onCollectionsChanged = null}) {
const scope = createScope();
let active = null, retryVersion = 0;
const dispose = () => scope.dispose();
@@ -36,7 +41,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
active?.close(false);
const {items,selectedKey,trigger = document.activeElement} = options;
let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false;
let originalDownload = null, inspectionState = inspectionController.state;
let originalDownload = null, inspectionState = inspectionController.state, collectionChanges = null;
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
if (!mounted) { initial = [state,selected]; return; }
update(state,selected);
@@ -99,7 +104,11 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
onLoad:() => inspectionController.load?.(),
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined,collections,
onCollectionsChanged:({added,removed}) => {
collectionChanges ??= {added:[],removed:[]};
collectionChanges.added.push(...added);collectionChanges.removed.push(...removed);
}});
function closeViewer(restore = true) {
if (closed) return;
closed = true;active = null;local.dispose();
@@ -108,6 +117,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
else onRestoreFocus?.(entry);
}
if (!scope.signal.aborted) onClose?.();
if (collectionChanges && !scope.signal.aborted) onCollectionsChanged?.(collectionChanges);
}
active = {close:closeViewer,stop:() => session.stop()};
local.cleanup(() => {
@@ -148,7 +158,8 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
src = url.href;
}
const node = element(selected.is_video ? 'video' : 'img');
mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);});
// #430: the page may find the file under a renumbered source (`onMediaError`).
mediaLocal.listen(node,'error',() => {if (current()) {session.failed(key);onMediaError?.(selected);}});
if (selected.is_video) {
node.controls = true;node.playsInline = true;node.preload = 'metadata';
const preferences = readViewerPreferences();
+5 -4
View File
@@ -332,8 +332,9 @@ export const INPUT_SUGGESTIONS_ID = 'workspace-input-suggestions';
* reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field.
* `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field
* reports the message (its native validity) until it is edited. `suggestions` (text list) offers values in a
* `<datalist>` under the field; `secondary` (`{label, onClick}`) adds a button between Cancel and the action that
* closes the dialog and runs `onClick` (#332).
* `<datalist>` under the field (Set category's categories, #332; Add to collection's names, #331 D138); the field
* still takes any text. `secondary` (`{label, onClick}`) adds a button between Cancel and the action that closes the
* dialog and runs `onClick` (#332).
*/
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null,
validate = null, suggestions = null, secondary = null }) {
@@ -400,13 +401,13 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
* another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false,
* × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it).
*/
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) {
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null, focusTarget = null }) {
requireScope(scope);
if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title');
if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes');
if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function');
if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function');
show(title, nodes, { scope, trigger, onClose, canClose });
show(title, nodes, { scope, trigger, onClose, canClose, focusTarget });
}
export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' +
+16
View File
@@ -169,6 +169,13 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.masonry-grid .image-open video { aspect-ratio: var(--media-ratio); object-fit: contain; }
.list-grid .image-open video { height: 95px; aspect-ratio: auto; }
/* #451 D157: a cover crop of a generated image keeps its top (heads, faces) instead of its middle. The vertical 20%
acts only when the image is taller than its box; a wider image overflows sideways and stays centred. Masonry shows
the whole image (contain) and keeps the centre. */
.image-grid:not(.masonry-grid) .image-open :is(img, video), .card-art img, .detail-cover, .tag-image-preview {
object-position: 50% 20%; /* D157 */
}
/* Images viewer (#249 C6): the concept's `#viewer-dialog` shell (s1008–1058, ≤700 s1456–1474). #249 D34: real media,
titles and states that the concept's one demo image never shows. */
/* #249 D34: a real title can wrap (long linked titles and filenames). Above 700px the dialog stacks its header and the
@@ -264,6 +271,9 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
/* #249 D23: a parent with one preview, or none, fills the art row instead of leaving the concept's second cell empty. */
.prompt-card .card-art > :only-child { grid-column: 1 / -1; /* D23 */ }
.prompt-card .card-art > .small { display: grid; place-content: center; min-width: 0; color: var(--ca-muted); /* D23 */ }
/* #437 D148: the card's selection checkbox is the concept's Images `.image-overlays`, placed over the art as on an image
card; `.media-art` carries the concept's `position: relative` there, the prompt card carries it here. */
.prompt-card { position: relative; /* D148 */ }
/* Prompt library shelf (#249 PR E). #249 D3: rows stay windowed, as the Images grid's do; the concept's `.library-grid`
tracks and breakpoints lay them out, each row spans them, and appended rows never anchor the scroll. The row's
@@ -453,3 +463,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
/* #332 D146: the category chip names its origin (" · this image", " · prompt", " · recipe") in muted text that never
splits; at 390px the chip may wrap between the category and its origin. */
.chip .chip-source { color: var(--ca-muted); white-space: nowrap; }
/* D147 (#430): a state that blocks Save shows its reason on its own row above the sticky actions, with Refresh for a
changed source; the disabled actions look disabled; a refused capture's empty preview says so. */
.tagging-modal .dialog-actions [aria-disabled="true"], .tagging-modal .tag-model-panel [aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D147 */ }
.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 */ }