Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9fa0682b | ||
|
|
02190d03a8 | ||
|
|
40dc0e5417 | ||
|
|
8b12e95bc6 | ||
|
|
ac378f07d3 | ||
|
|
827de84f40 | ||
|
|
9eb5e9453d | ||
|
|
f5856076ce | ||
|
|
4040007264 |
+374
-16
@@ -1087,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.
|
||||
@@ -1395,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
|
||||
@@ -1429,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,
|
||||
@@ -1446,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
|
||||
@@ -1679,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:
|
||||
@@ -1914,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
|
||||
@@ -2006,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 |
|
||||
@@ -2113,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
|
||||
@@ -2196,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
|
||||
@@ -3346,7 +3697,11 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
mid-response is not discarded by the page: the server's 15-minute expiry removes it. When a restore the page saw running
|
||||
finishes, the cached Images and Library visits are dropped, the sidebar counts and
|
||||
saved views are read again and the open route is shown again (the Activity job view
|
||||
stays). A refused file toasts "This file can't be restored: <validator reason>.". A
|
||||
stays). #415: an open review (`openReviewDialog`) then closes as dismissed, since its plan no
|
||||
longer describes storage; while any other dialog is open, all of that waits until the last
|
||||
one has closed, so a dialog keeps what was typed and its next write meets the restored-storage
|
||||
refusal, as when the page never saw the restore. `followRestore` (tests/browser/unseen-restore.mjs)
|
||||
makes the page see a restore run and finish deterministically. A refused file toasts "This file can't be restored: <validator reason>.". A
|
||||
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
|
||||
long file name keeps the concept's two equal columns at phone width.
|
||||
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
|
||||
@@ -4916,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
|
||||
@@ -4934,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
|
||||
@@ -6037,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+60
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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']));
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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});
|
||||
@@ -252,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});
|
||||
|
||||
@@ -52,3 +52,43 @@ export async function unseenRestore(page, restoring) {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
// A restore the open page does see run and finish (#415), driven by the test instead of the 15 s poll's timing. The
|
||||
// job is shaped as the server's (py/api/backup_restore.py: kind `restore`, one `errors` group, not cancellable).
|
||||
const RESTORE_JOB_ID = '415415415415415415415415415415ab';
|
||||
const restoreJob = state => ({ id: RESTORE_JOB_ID, kind: 'restore', title: 'Database restore', state, started_at: 1,
|
||||
finished_at: state === 'running' ? null : 2, progress: 0, status_line: state === 'running' ? '' : 'Restored the test snapshot.',
|
||||
processed: 0, total: 0, counters: { errors: 0 }, scope: {}, listed: { errors: 0 }, log_length: 0, cancellable: false });
|
||||
/** The Activity pill's link while the page follows that restore. */
|
||||
export const SEEN_RESTORE_PILL = `#/activity/${RESTORE_JOB_ID}`;
|
||||
/** The toast the page shows when it sees that restore finish (activity-kinds.mjs `restore.finishToast`). */
|
||||
export const SEEN_RESTORE_TOAST = 'Database restore finished · Restored the test snapshot.';
|
||||
|
||||
/**
|
||||
* Call before the page's first navigation, after `armUnseenRestores(page)` (a later route wins). The page's first
|
||||
* job-list read lists a restore job running, so its Activity store follows it; its job stream is refused, so the store
|
||||
* reads the list again, and every later read is held until `finish()`, which answers them with the job finished and
|
||||
* resolves once one of those answers reached the page. The real restore the test runs meanwhile goes through the API.
|
||||
*/
|
||||
export async function followRestore(page) {
|
||||
let release;
|
||||
const released = new Promise(resolve => { release = resolve; });
|
||||
let first = true;
|
||||
await page.route(`${JOBS}/*/events`, route => route.abort().catch(() => {}));
|
||||
await page.route(JOBS, async route => {
|
||||
if (first) {
|
||||
first = false;
|
||||
return route.fulfill({ json: { success: true, jobs: [restoreJob('running')] } }).catch(() => {});
|
||||
}
|
||||
await released;
|
||||
// A read the page has aborted since (a newer read supersedes it) can no longer be answered.
|
||||
return route.fulfill({ json: { success: true, jobs: [restoreJob('finished')] } }).catch(() => {});
|
||||
});
|
||||
return {
|
||||
async finish() {
|
||||
const answered = page.waitForResponse(response => new URL(response.url()).pathname === '/prompt_manager/jobs');
|
||||
release();
|
||||
await answered;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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]');
|
||||
@@ -814,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);
|
||||
@@ -1791,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();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
@@ -454,6 +454,37 @@ async function leaveAndReturn(page,id) {
|
||||
}
|
||||
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
|
||||
|
||||
// #415: a restore the page sees finish while New prompt is open keeps the dialog and its entries; the Library is shown
|
||||
// again from the restored data only once the dialog has closed.
|
||||
test('a restore seen finishing keeps New prompt open and reloads the Library after it closes',async({page,request})=>{
|
||||
const f=page.fixture;const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK();
|
||||
const buffer=await snapshot.body();
|
||||
const current=await detail(request,f.positive_id,'active');
|
||||
await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{title:'Retitled elsewhere'}}})).toBeOK();
|
||||
const restoreRun=await followRestore(page);
|
||||
await page.goto(`${workspace}#/library`);const card=page.locator(`article[data-prompt-id="${f.positive_id}"]`);
|
||||
await expect(card).toContainText('Retitled elsewhere');
|
||||
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
|
||||
await headingActions(page).getByRole('button',{name:'+ New prompt',exact:true}).click();
|
||||
const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
await field(dialog,'Title').fill('Typed before restore');await field(dialog,'Positive prompt').fill('A prompt typed while storage was restored');
|
||||
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer}}})).toBeOK();
|
||||
page.writes.length=0;
|
||||
await restoreRun.finish();
|
||||
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(field(dialog,'Title')).toHaveValue('Typed before restore');
|
||||
await expect(field(dialog,'Positive prompt')).toHaveValue('A prompt typed while storage was restored');
|
||||
await expect(card).toContainText('Retitled elsewhere');
|
||||
await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
|
||||
await expect(button(dialog,'Save')).toBeDisabled();expect(page.writes).toHaveLength(1);
|
||||
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(card).not.toContainText('Retitled elsewhere');await expect(card).toContainText('Editor portrait');
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
expect(page.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
||||
const actions=headingActions(page).locator('button');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept } from './concept-inventory.mjs';
|
||||
import { followRestore, SEEN_RESTORE_PILL, SEEN_RESTORE_TOAST } from './unseen-restore.mjs';
|
||||
|
||||
// System › Backup & export (#249 System S9 Task 24) on the S8 backend and the concept-shaped fixture: Back up and its
|
||||
// status line (D103), the restore review (D105) and its job, the exports, Import / inspect and the recovery policy
|
||||
@@ -265,6 +266,24 @@ test('a finished restore shows the open page again from the restored data', asyn
|
||||
expect(await libraryTotal(request)).toBe(restoredTotal);
|
||||
});
|
||||
|
||||
// #415: an open dialog waits out a finished restore, except a review: the plan it staged no longer describes storage,
|
||||
// so it closes as dismissed (its stage discarded), as before.
|
||||
test('a restore seen finishing elsewhere closes an open restore review and discards its stage', async ({ page, request }) => {
|
||||
const bytes = await backupBytes(request);
|
||||
const restoreRun = await followRestore(page);
|
||||
await openBackups(page);
|
||||
await expect(page.locator('#activity-pill')).toHaveAttribute('href', SEEN_RESTORE_PILL);
|
||||
await chooseBackup(page, bytes);
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||||
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
|
||||
const discarded = page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) && response.request().method() === 'DELETE');
|
||||
await restoreRun.finish();
|
||||
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
|
||||
expect((await discarded).status()).toBe(200);
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
|
||||
});
|
||||
|
||||
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
|
||||
const before = await serverJson(request, '/__test__/system/digest');
|
||||
await openBackups(page);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
|
||||
|
||||
@@ -552,6 +552,31 @@ test('conflict and restore states keep the dialog and write nothing',async({page
|
||||
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// #415: a restore the page sees finish while Create tag is open keeps the dialog and its entries; the page is shown
|
||||
// again from the restored data only once the dialog has closed.
|
||||
test('a restore seen finishing keeps an open dialog and reloads the page after it closes',async({page,request})=>{
|
||||
const saved=await backup(request);
|
||||
await expect(await request.put('/prompt_manager/tags/mist',{data:{new_name:'fog'}})).toBeOK();
|
||||
const restoreRun=await followRestore(page);const writes=trackWrites(page);await openPage(page);
|
||||
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
|
||||
await expect(tagRow(page,'fog')).toHaveCount(1);
|
||||
const box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
|
||||
await field(box,'Tag name').fill('sunrise');
|
||||
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer:saved}}})).toBeOK();
|
||||
await restoreRun.finish();
|
||||
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
|
||||
await expect(box).toBeVisible();await expect(field(box,'Tag name')).toHaveValue('sunrise');
|
||||
await expect(tagRow(page,'fog')).toHaveCount(1);
|
||||
await button(box,'Apply').click();
|
||||
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
|
||||
expect(await actionNames(box)).toEqual(['Reload','Cancel','Apply']);
|
||||
expect(tagNames(await snapshot(request))).not.toContain('sunrise');
|
||||
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
|
||||
await expect(tagRow(page,'mist')).toHaveCount(1);await expect(tagRow(page,'fog')).toHaveCount(0);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('lost response is outcome-unknown',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);
|
||||
await page.route('**/prompt_manager/vocabulary/tags/*',async route=>{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -192,6 +192,71 @@ test('views reload once after a restore this page saw finish; a failed restore c
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
// #415: a fake document whose open dialogs the test sets, and a MutationObserver stand-in it triggers.
|
||||
function dialogPage() {
|
||||
const page = { open: 0, observers: new Set() };
|
||||
page.doc = { documentElement: {}, querySelector: selector => (selector === 'dialog[open]' && page.open > 0 ? {} : null) };
|
||||
page.Observer = class {
|
||||
constructor(callback) { this.callback = callback; }
|
||||
observe(target, options) { this.target = target; this.options = options; page.observers.add(this); }
|
||||
disconnect() { page.observers.delete(this); }
|
||||
};
|
||||
page.mutate = open => { page.open = open; for (const observer of [...page.observers]) observer.callback([]); };
|
||||
return page;
|
||||
}
|
||||
|
||||
test('the restore reload runs at once when no dialog is open', async () => {
|
||||
const { createRestoreReload } = await backups;
|
||||
const page = dialogPage();
|
||||
let reloads = 0;
|
||||
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
|
||||
reload.request();
|
||||
assert.equal(reloads, 1);
|
||||
assert.equal(page.observers.size, 0, 'nothing waits');
|
||||
reload.request();
|
||||
assert.equal(reloads, 2);
|
||||
});
|
||||
|
||||
test('the restore reload waits for every open dialog to close, then runs once (#415)', async () => {
|
||||
const { createRestoreReload } = await backups;
|
||||
const page = dialogPage();
|
||||
page.open = 1;
|
||||
let reloads = 0;
|
||||
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
|
||||
reload.request();
|
||||
reload.request();
|
||||
assert.equal(reloads, 0, 'an open dialog holds the reload');
|
||||
assert.equal(page.observers.size, 1, 'one observer for repeated requests');
|
||||
const [observer] = page.observers;
|
||||
assert.equal(observer.target, page.doc.documentElement);
|
||||
assert.deepEqual(observer.options, { subtree: true, childList: true, attributes: true, attributeFilter: ['open'] });
|
||||
page.mutate(1);
|
||||
assert.equal(reloads, 0, 'a dialog swapped for another keeps it waiting');
|
||||
page.mutate(0);
|
||||
assert.equal(reloads, 1);
|
||||
assert.equal(page.observers.size, 0, 'the observer stops once it ran');
|
||||
page.mutate(0);
|
||||
assert.equal(reloads, 1);
|
||||
page.open = 1;
|
||||
reload.request();
|
||||
page.mutate(0);
|
||||
assert.equal(reloads, 2, 'a later restore waits again');
|
||||
});
|
||||
|
||||
test('disposing drops a waiting restore reload', async () => {
|
||||
const { createRestoreReload } = await backups;
|
||||
const page = dialogPage();
|
||||
page.open = 1;
|
||||
let reloads = 0;
|
||||
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
|
||||
reload.request();
|
||||
reload.dispose();
|
||||
assert.equal(page.observers.size, 0);
|
||||
page.mutate(0);
|
||||
assert.equal(reloads, 0);
|
||||
reload.dispose();
|
||||
});
|
||||
|
||||
test('a downloaded backup the server could not verify says so', async () => {
|
||||
const { downloadDatabaseBackup, BACKUP_DONE, backupUnverified } = await scan;
|
||||
const toasts = [];
|
||||
|
||||
@@ -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);
|
||||
@@ -908,6 +911,34 @@ test('openReviewDialog lays out heading, grid, options, sentence, backup box and
|
||||
}
|
||||
});
|
||||
|
||||
// #415: a finished restore ends an open review (its reviewed plan no longer describes storage) as a dismissal, and
|
||||
// leaves any other dialog, with what was typed in it, open.
|
||||
test('closeReviewDialog closes only an open review, as dismissed', async () => {
|
||||
const { openReviewDialog, openInputDialog, closeReviewDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
closeReviewDialog();
|
||||
const outcomes = [];
|
||||
openInputDialog({ scope, title: 'Save view', label: 'Name', value: 'typed', action: 'Save', onApply() {} });
|
||||
closeReviewDialog();
|
||||
assert.equal(modal(dom).open, true, 'an input dialog stays');
|
||||
assert.equal(byId(dom, 'action-input').value, 'typed');
|
||||
openReviewDialog({ scope, title: 'Restore preview', grid: [['Integrity', 'Verified']], cancelLabel: 'Cancel',
|
||||
primary: { label: 'Restore', danger: true }, onStart: () => outcomes.push('start'), onDismiss: () => outcomes.push('dismiss') });
|
||||
closeReviewDialog();
|
||||
assert.deepEqual(outcomes, ['dismiss']);
|
||||
assert.equal(modal(dom), undefined, 'the shell is gone');
|
||||
closeReviewDialog();
|
||||
assert.deepEqual(outcomes, ['dismiss']);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
closeWorkspaceDialog();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('review dialog backup box keeps the dialog open and toasts the outcome', async () => {
|
||||
const { openReviewDialog, closeWorkspaceDialog, SCAN_BACKUP_COPY } = await dialogsModule;
|
||||
const { downloadDatabaseBackup, BACKUP_DONE, BACKUP_FAILED } = await scanModule;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+18
@@ -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,
|
||||
@@ -353,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,
|
||||
@@ -377,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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
@@ -116,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});
|
||||
@@ -154,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;
|
||||
@@ -180,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');
|
||||
@@ -302,10 +306,20 @@ 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,onMediaError:image => recovery.recover(image),onRestoreFocus:image => {
|
||||
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
|
||||
@@ -322,7 +336,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
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);
|
||||
@@ -734,6 +749,19 @@ 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();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mountVocabulary } from './vocabulary.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading, pageHeading } from './heading.mjs';
|
||||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
import { closeReviewDialog, closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
|
||||
import { createCollections, collectionHash, openEditCollectionDialog, missingNote, DELETED_PAGE_TEXT, OPEN_IMAGES_LINK,
|
||||
LIST_FAILED_TEXT } from './collections.mjs';
|
||||
@@ -20,7 +20,7 @@ import { mountInspect } from './inspect.mjs';
|
||||
import { mountReview } from './review.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { mountSystem } from './system.mjs';
|
||||
import { onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
|
||||
import { createRestoreReload, onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
|
||||
import { mountLibraryBanner } from './library-location.mjs';
|
||||
import { detectJxlSupport } from './thumbnail-support.mjs';
|
||||
|
||||
@@ -184,13 +184,20 @@ function mountWorkspace() {
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
// S9: a finished restore replaced the library. The cached visits are dropped, the counts and saved views read
|
||||
// again, and the open page is shown again from the restored data; the Activity job view the restore opened stays.
|
||||
scope.cleanup(onRestoreFinished(activity, () => {
|
||||
// #415: an open review closes (its plan no longer describes storage); while any other dialog is open, all of it
|
||||
// waits until the last one has closed, so the dialog keeps what was typed.
|
||||
const restoreReload = createRestoreReload(() => {
|
||||
forgetImagesVisit();
|
||||
forgetLibraryVisit();
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
savedViews.load();
|
||||
collections.load();
|
||||
if (currentKey !== 'activity') navigateTo(window.location.hash);
|
||||
});
|
||||
scope.cleanup(() => restoreReload.dispose());
|
||||
scope.cleanup(onRestoreFinished(activity, () => {
|
||||
closeReviewDialog();
|
||||
restoreReload.request();
|
||||
}));
|
||||
// S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back:
|
||||
// it opens again over the page shown now, with Restore (again) and Discard; leaving it any other way discards it.
|
||||
@@ -403,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') {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;}};
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,7 +19,12 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,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(() => {
|
||||
|
||||
@@ -326,6 +326,43 @@ export function onRestoreFinished(activity, onFinished) {
|
||||
});
|
||||
}
|
||||
|
||||
const OPEN_DIALOG = 'dialog[open]';
|
||||
const DIALOG_MUTATIONS = Object.freeze({ subtree: true, childList: true, attributes: true, attributeFilter: ['open'] });
|
||||
|
||||
/**
|
||||
* #415: the S9 reload after a finished restore never runs under an open dialog, which it would close with whatever
|
||||
* was typed in it. `request()` runs `reload()` at once when no dialog is open; otherwise it runs once the last open
|
||||
* dialog has closed (repeated requests meanwhile run it once). The dialog keeps its entries, and its next write is
|
||||
* refused by the restored storage with its own note and Reload, as when this page never saw the restore. A
|
||||
* MutationObserver sees every dialog close: removing `open` and removing the node are synchronous, while the dialog
|
||||
* `close` event is a later task that never bubbles, and every dialog here is removed in the task that closes it.
|
||||
* `dispose()` drops a waiting reload. Returns `{request, dispose}`.
|
||||
*/
|
||||
export function createRestoreReload(reload, { doc = globalThis.document, Observer = globalThis.MutationObserver } = {}) {
|
||||
let observer = null;
|
||||
const dialogOpen = () => doc.querySelector(OPEN_DIALOG) !== null;
|
||||
const stop = () => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
};
|
||||
return {
|
||||
request() {
|
||||
if (observer) return;
|
||||
if (!dialogOpen()) {
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
observer = new Observer(() => {
|
||||
if (dialogOpen()) return;
|
||||
stop();
|
||||
reload();
|
||||
});
|
||||
observer.observe(doc.documentElement, DIALOG_MUTATIONS);
|
||||
},
|
||||
dispose: stop,
|
||||
};
|
||||
}
|
||||
|
||||
function surface(heading, ...nodes) {
|
||||
const section = el('section', undefined, 'surface');
|
||||
section.append(heading, ...nodes);
|
||||
|
||||
@@ -112,13 +112,13 @@ function bindScope(scope) {
|
||||
});
|
||||
}
|
||||
|
||||
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null }) {
|
||||
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null, review = false }) {
|
||||
shell ??= createShell();
|
||||
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
|
||||
bindScope(scope);
|
||||
// A swapped-out body is gone from the shell, so its owner releases it.
|
||||
const replaced = session?.onClose;
|
||||
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose };
|
||||
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose, review };
|
||||
replaced?.();
|
||||
shell.title.textContent = heading;
|
||||
shell.body.replaceChildren(...nodes);
|
||||
@@ -133,6 +133,14 @@ export function closeWorkspaceDialog() {
|
||||
shell = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #415: a finished restore ends an open record-changing review (`openReviewDialog`) as a dismissal, since the plan it
|
||||
* reviewed no longer describes storage and it holds nothing typed. Any other dialog stays open.
|
||||
*/
|
||||
export function closeReviewDialog() {
|
||||
if (session?.review) closeWorkspaceDialog();
|
||||
}
|
||||
|
||||
function fillSelect(select, key, options) {
|
||||
if (!Array.isArray(options) || options.length === 0 ||
|
||||
options.some(pair => !Array.isArray(pair) || typeof pair[0] !== 'string' || typeof pair[1] !== 'string')) {
|
||||
@@ -324,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 }) {
|
||||
@@ -392,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. ' +
|
||||
@@ -497,7 +506,7 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
|
||||
actions.append(cancel, start);
|
||||
nodes.push(actions);
|
||||
// Cancel, ×, Escape, a replacing dialog or the page leaving: everything but the primary is a dismissal.
|
||||
show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null });
|
||||
show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null, review: true });
|
||||
return {
|
||||
setValue(term, text) {
|
||||
const dd = values.get(term);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user