diff --git a/TESTING.md b/TESTING.md index e3f2d556..5d895379 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1088,7 +1088,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. @@ -1430,8 +1431,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, @@ -1447,9 +1450,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 @@ -1943,8 +1946,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 @@ -2035,7 +2039,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 | @@ -2142,6 +2146,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/`. 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/` 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 @@ -2225,6 +2458,61 @@ 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 "), 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. 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 + Ctrl/Cmd+A, Shift+arrow, long-press or "Select all N matching" on the Library. +- 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. Set category is `aria-disabled` while the 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. + ### Prompt history page and recorded deviations (#249 PR F-a) The history page (`#/library/<id>`) takes the concept's heading, summary, negative @@ -4949,7 +5237,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 @@ -4967,7 +5255,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 @@ -6070,9 +6359,10 @@ 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 and tag stay on the legacy admin page. 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. diff --git a/database/library_commands.py b/database/library_commands.py index caf6c713..878a7e98 100644 --- a/database/library_commands.py +++ b/database/library_commands.py @@ -21,6 +21,8 @@ _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 class LibraryCommandError(Exception): @@ -85,6 +87,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 +317,49 @@ 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), + } diff --git a/docs/application-redesign.md b/docs/application-redesign.md index 536464d0..a47c3997 100644 --- a/docs/application-redesign.md +++ b/docs/application-redesign.md @@ -13,6 +13,7 @@ code. This plan proposes implementation order and acceptance gates. - [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–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 diff --git a/py/api/prompts.py b/py/api/prompts.py index 8f5a1554..09001dcd 100644 --- a/py/api/prompts.py +++ b/py/api/prompts.py @@ -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 @@ -1180,6 +1182,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 +1220,40 @@ class PromptRoutesMixin: status=500, ) + 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. + """ + fields = {"prompt_ids", "category", "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( + self.db.bulk_set_library_category, + prompt_ids, + data["category"], + 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_category_by_filter(self, data: dict) -> web.Response: """Set the category on every linked positive a listing filter admits. diff --git a/tests/browser/collections-concept.spec.mjs b/tests/browser/collections-concept.spec.mjs new file mode 100644 index 00000000..65986be0 --- /dev/null +++ b/tests/browser/collections-concept.spec.mjs @@ -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'])); +}); diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 9d8bd1fe..fa740476 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -149,9 +149,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|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';}; @@ -161,10 +162,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. @@ -264,7 +266,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})=>{ @@ -292,8 +294,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','section.tag-section.linked-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'); @@ -304,7 +306,7 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await expect(details.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > 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(); @@ -331,9 +333,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'); diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index 6b41ca70..b2156f52 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -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) { diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 0131fa50..d2bcd606 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -51,11 +51,12 @@ function withCategoryScope(inventory) { } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D146 and D154, whose labelled tag - * sections the concept page gets at runtime from `withTagSections`); everything else must + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139, D146 and D154, whose labelled + * tag sections the concept page gets at runtime from `withTagSections`); 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; @@ -113,6 +114,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 in D154's labelled sections (`sections`) 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 @@ -132,8 +149,9 @@ async function plainViewerChips(page,sections) { 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 restoreSections();await restoreSource();await restoreTargets(); @@ -172,13 +190,16 @@ async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) { return {viewerDetails,viewerPlain,fileControls}; } async function inventory(page,concept=false,sections=PROMPT_TAGS) { - // D146: the concept panel's category chip with its visible origin, and D154's labelled tag sections (by default the - // fixture's D39 positive tags under "Prompt tags"), laid out by the concept page itself. + // D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history; D154's + // labelled tag sections (by default the fixture's D39 positive tags under "Prompt tags"), laid out by the concept page. const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null; + const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null; const restoreSections=concept?await withTagSections(page,'#image-inspector',sections):null; if(concept)await settle(page); const {panel}=await inspectorOf(page); - await restoreSections?.();await restoreSource?.(); + await restoreSections?.(); + await restoreCollections?.(); + await restoreSource?.(); const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept,null,sections); return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls}; } @@ -352,7 +373,7 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3'); await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']); await expect(tags.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 @@ -439,6 +460,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(); diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs index 8e85bae8..33f6bbff 100644 --- a/tests/browser/library-concept.spec.mjs +++ b/tests/browser/library-concept.spec.mjs @@ -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 two actions both bars have (Set category, Clear). + */ +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=['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 both actions').toEqual(['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}); diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs index b87a55b4..a77564ed 100644 --- a/tests/browser/workspace-collections.spec.mjs +++ b/tests/browser/workspace-collections.spec.mjs @@ -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); +}); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 78e5baff..0cb08c9a 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -1791,7 +1791,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(); diff --git a/tests/browser/workspace-library-selection.spec.mjs b/tests/browser/workspace-library-selection.spec.mjs new file mode 100644 index 00000000..48335b0d --- /dev/null +++ b/tests/browser/workspace-library-selection.spec.mjs @@ -0,0 +1,177 @@ +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'); + +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'); + await expect(bar(page).getByRole('button')).toHaveText(['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'); +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 5ef235f3..3ddfdeae 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -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']); diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs index 3dde4517..245bfc13 100644 --- a/tests/browser/workspace-tagging-bulk.spec.mjs +++ b/tests/browser/workspace-tagging-bulk.spec.mjs @@ -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 }) => { diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index 88f9bd87..7d082a7b 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -441,6 +441,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 => { diff --git a/tests/js/workspace-images-category.test.js b/tests/js/workspace-images-category.test.js index e037e22e..1cded9e0 100644 --- a/tests/js/workspace-images-category.test.js +++ b/tests/js/workspace-images-category.test.js @@ -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.'); +}); diff --git a/tests/js/workspace-images-selection.test.js b/tests/js/workspace-images-selection.test.js new file mode 100644 index 00000000..4590bc4c --- /dev/null +++ b/tests/js/workspace-images-selection.test.js @@ -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]); +}); diff --git a/tests/js/workspace-library-selection.test.js b/tests/js/workspace-library-selection.test.js new file mode 100644 index 00000000..ad771f9a --- /dev/null +++ b/tests/js/workspace-library-selection.test.js @@ -0,0 +1,44 @@ +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); +}); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index 2442364f..f5969dac 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -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; +}); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index ad4d8641..a776db41 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -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); diff --git a/tests/test_bulk_library_category.py b/tests/test_bulk_library_category.py new file mode 100644 index 00000000..f780e054 --- /dev/null +++ b/tests/test_bulk_library_category.py @@ -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() diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index e414e85e..965573d9 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -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, diff --git a/web/js/workspace/collections.mjs b/web/js/workspace/collections.mjs index 827b3c16..6491536a 100644 --- a/web/js/workspace/collections.mjs +++ b/web/js/workspace/collections.mjs @@ -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; diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 9c5ed03f..e35551a2 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -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.'; @@ -228,7 +229,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; @@ -322,6 +328,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); } /** diff --git a/web/js/workspace/images-category.mjs b/web/js/workspace/images-category.mjs index 307e8eb9..ae892a5a 100644 --- a/web/js/workspace/images-category.mjs +++ b/web/js/workspace/images-category.mjs @@ -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; } diff --git a/web/js/workspace/images-collections.mjs b/web/js/workspace/images-collections.mjs new file mode 100644 index 00000000..69806cae --- /dev/null +++ b/web/js/workspace/images-collections.mjs @@ -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?.(); + } +} diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index 78957d04..217e32a2 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -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(); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index f35e42a3..bfde7afe 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.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}); @@ -302,10 +304,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 +334,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 +747,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(); diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 81d1b132..ad50c0db 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -410,7 +410,7 @@ function mountWorkspace() { if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') { mountNewPromptAction(main, api, view); images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites', - collection, onCollectionMissing, onSaveView:saveView('images', view), activity}); + collection, onCollectionMissing, onSaveView:saveView('images', view), activity, collections}); return; } if (route.id === 'library') { diff --git a/web/js/workspace/inspector-collections.mjs b/web/js/workspace/inspector-collections.mjs new file mode 100644 index 00000000..87c8598f --- /dev/null +++ b/web/js/workspace/inspector-collections.mjs @@ -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(); +} diff --git a/web/js/workspace/library-selection.mjs b/web/js/workspace/library-selection.mjs new file mode 100644 index 00000000..e487cf7c --- /dev/null +++ b/web/js/workspace/library-selection.mjs @@ -0,0 +1,180 @@ +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 { openInputDialog } from './workspace-dialogs.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +/** + * The Prompt library's selection and its one bulk action, "Set category" (#437, D148, beyond the concept): the + * legacy admin page's "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 action, 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, expected_epoch}` to `POST /prompt_manager/bulk/category`: the epoch the shelf + * was read under, so a restored storage answers 409 `storage-changed` and nothing is written. + */ +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 }; +// 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) { + if (failure === null && result?.success) { + const found = (Number(result.updated) || 0) + (Number(result.unchanged) || 0); + if (!found) return { toast: NONE_FOUND, committed: false, reload: true }; + return { toast: setOutcome(result, result.category, 'prompt'), committed: true, reload: true }; + } + if (failure?.code === 'storage-changed') return { toast: STORAGE_CHANGED, committed: false, reload: true }; + const status = failure?.status; + if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: UNCONFIRMED, committed: true, reload: true }; + return { toast: CATEGORY_FAILED, committed: false, reload: false }; +} + +/** 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(receipt)` 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 setCategory = createButton('Set category'); + const clearButton = createButton('Clear'); + bulk.append(count, setCategory, clearButton); + 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; + 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())); + } + // 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; + } + + function openCategory() { + if (writing) return; + const loaded = new Set(ids()), selected = [...checked].filter(id => loaded.has(id)); + if (selected.length > MAX_CATEGORY_PROMPTS) { showWorkspaceToast(TOO_MANY_PROMPTS); return; } + const epoch = getEpoch(); + if (!selected.length || typeof epoch !== 'string') { showWorkspaceToast(CATEGORY_FAILED); 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({ prompt_ids: selected, category, expected_epoch: epoch }) }); + } + /** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */ + async function write(body) { + writing = true; + setCategory.setAttribute('aria-disabled', 'true'); + let result = null, failure = null; + try { + result = await api.post(CATEGORY_URL, body); + } catch (error) { + failure = error; + } finally { + writing = false; + setCategory.removeAttribute('aria-disabled'); + } + const outcome = writeOutcome(result, failure); + // A left page still invalidates what it cached, so a return never shows the old categories. + if (outcome.committed) onCommitted(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), + clear, + /** Whether a Set category write is still in flight. */ + writing: () => writing, + }; +} diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index 7ba2b791..900c944f 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -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;}}; } /** diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 1a263ce5..907c34f0 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -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:receipt=>invalidateLibraryAfterWrite({kind:'bulk-category',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,9 +364,9 @@ 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));} + 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);} } @@ -392,10 +401,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}; diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 106932af..10645720 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -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(() => { diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 0d6d4643..25a1d5ef 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -332,8 +332,9 @@ export const INPUT_SUGGESTIONS_ID = 'workspace-input-suggestions'; * reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field. * `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field * reports the message (its native validity) until it is edited. `suggestions` (text list) offers values in a - * `<datalist>` under the field; `secondary` (`{label, onClick}`) adds a button between Cancel and the action that - * closes the dialog and runs `onClick` (#332). + * `<datalist>` under the field (Set category's categories, #332; Add to collection's names, #331 D138); the field + * still takes any text. `secondary` (`{label, onClick}`) adds a button between Cancel and the action that closes the + * dialog and runs `onClick` (#332). */ export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null, validate = null, suggestions = null, secondary = null }) { @@ -400,13 +401,13 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe * another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false, * × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it). */ -export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) { +export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null, focusTarget = null }) { requireScope(scope); if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title'); if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes'); if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function'); if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function'); - show(title, nodes, { scope, trigger, onClose, canClose }); + show(title, nodes, { scope, trigger, onClose, canClose, focusTarget }); } export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' + diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index edd9a2a7..57ae93ce 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -269,6 +269,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