diff --git a/TESTING.md b/TESTING.md index baf3a301..23d7eb8a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2442,10 +2442,35 @@ Recorded deviations on this page: view" filters are active, and plain "Filters" otherwise; the text is its accessible name, so the count is spoken. The concept has no indicator, and without one a Tags filter set by Tags & categories Browse was invisible on the shelf. The Library counts - Tags; Images counts Source, Folder, Media and Tags (each once). The toolbar category is + Tags, Folder and Tag match (D150); Images counts Source, Folder, Media and Tags (each once). The toolbar category is not a Refine filter and is not counted. The caption's count is a `role="status"` region, written only when it changes and kept shown while the shelf reloads, so Load more and scrolling never re-announce it. +- **D150** (#444, cutover #334): "Refine this view" on the Library holds the concept + Filters dialog's **Folder** ("All output folders", then each folder below an output + root indented by depth, read from `GET /prompt_manager/library/options?include=folders` + when the dialog opens; the chosen folder stays listed even when the list lacks it) and + **Tags**, plus **Tag match** ("All of these tags" / "Any of these tags" / "Prompts without tags", D149) drawn as the + concept's own select; the concept's "Prompt evidence" and prototype note are absent. + `GET /prompt_manager/library` takes `folder` (`/`-joined, `\` read as `/`; `.`, `..`, + empty segments and drives are 400) and `tag_mode` (`all` default, `any`, `untagged` from D149), and echoes + both; a client whose non-default value is not echoed (an un-restarted older server) + shows the read error, never the unfiltered shelf. A folder keeps a parent with one + linked image (`generated_images.prompt_id`) in it or below it under any spelling of + any output root (the root as configured or detected and after `realpath`, so a + symlinked output folder works): `file_path` (`normcase(normpath(abspath))`) is + compared with prefixes built by the same normalization, so case folding is the + platform's own, including non-ASCII on Windows. The folder is an outer AND around the + parent and pair arms; Any puts one `t.name IN (...)` in each arm, so a card matches + when the parent or one active pair carries any tag (text and category still AND + inside each arm); with one tag Any equals All. Filters counts Tags, Folder and a Tag + match other than All (D44). Saved Library views store `tag_mode` (`all|any|untagged`) and + `folder` (text 1024); older rows read the defaults. Tests: + `tests/test_library_folders.py` (pure prefixes and names with `posixpath`/`ntpath`, + drive, UNC, non-ASCII, symlinked root, the any-tag union), `test_api_library.py`, + `test_saved_views.py`, the library-state and saved-views JS units, and + `tests/browser/workspace-library-filters.spec.mjs` (behavior, the Refine dialog against + the concept Filters dialog, artifact captures). Windows: run `portability.yml` on the PR. `tests/browser/library-concept.spec.mjs` compares the shelf (#249 E3) on the concept-shaped fixture, which creates the concept's prompts last to first so Recently @@ -3682,10 +3707,40 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati details by default" (`sidebarCollapsedByDefault`; the viewer opens with `#viewer-details` hidden and a "Show details"/"Hide details" toggle with `aria-expanded`, the stage taking the whole width; amended by D155: on Inspect a file - the viewer opens with the details showing and the toggle reading "Hide details"), and the operation "Reset display + the viewer opens with the details showing and the toggle reading "Hide details"; amended by D156: the row moved + to the "Viewer" surface), and the operation "Reset display defaults" ("Restore this browser’s display defaults, then save. Other settings are kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only. The node search timeout shows the server's real value, not the sample 30. CSS under D97. +- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the + shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has + `button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so + the concept's `.dialog-header > button` and D34's coarse 40px rule cover it; 8px from + ✕). It names the view it switches to in its accessible name and `title` ("Full screen + view" / "Centered view") with a `maximize` / `minimize` outline icon, and keeps focus + after a switch. Full screen (`#viewer-dialog.viewer-fullscreen`) fills the browser + window edge to edge: `inset: 0`, no width or height cap, no margin, border or radius + (`.ca-tools #viewer-dialog.viewer-fullscreen[open]`, beating the concept's box, D34's + max-height and the ≤700px height); the layout inside is the concept's. Centered is the + concept's framed dialog, unchanged. In full screen the details stay toggleable: the + D97 "Show details"/"Hide details" toggle is in the toolbar whenever the view is full + screen or "Collapse viewer details by default" is on; going back to Centered without + that setting removes the toggle and shows the details. The view is not the browser's + Fullscreen API (Esc keeps one meaning, closing the viewer; WebKit has no element + fullscreen for a modal dialog; the workspace also runs in ComfyUI popups). The viewer + opens in the view System › Appearance › **Viewer** › "Open viewer in" (Centered, + Full screen) chooses: a browser display preference, `viewerOpenMode` (`centered` by + default, normalized in `gallery-settings.js`) in the shared `gallerySettings`; no + server setting. The button changes only the current view (navigation in the viewer + keeps it; the next open uses the setting). "Viewer" is its own surface after + "Browsing & display" ("How the image viewer opens; the button beside ✕ switches the + current view."), holding "Open viewer in" and the moved "Collapse viewer details by + default"; "Reset display defaults" still restores both. At ≤700px (the concept's + breakpoint, where the viewer is already 96vw × 94dvh) the button is hidden + (`display: none !important` against the concept's `.icon-btn` rule), popup windows + that narrow included; the stored view still applies there. Browser tests: + `tests/browser/workspace-viewer-mode.spec.mjs`; the concept comparisons add the + button to the concept's header at runtime and map the Viewer surface. - **D98** (Sources): each configured source's row has a plain Remove after its scan button; nothing changes until Save. A source added this session reads " · not saved yet" with Remove only until Save; a configured source the server cannot use @@ -6611,15 +6666,16 @@ 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 delete stays on the legacy admin page (bulk tags: -the Prompt library's selection bar, D151). A -prompt's category is changed in the Prompt library editor, on many prompts at once with -the Prompt library's selection bar (D148, #437), or with the legacy admin page's "Set -Category" (both `POST /prompt_manager/bulk/category`); +"Open existing tag manager" card for its own page; tag search and sort, the untagged +entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace +(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt +library's selection bar, D151). A prompt's category is changed in the Prompt library +editor, on many prompts at once with the Prompt library's selection bar (D148, #437), or +with the legacy admin page's "Set Category" (both `POST /prompt_manager/bulk/category`); the Images selection bar's Set category sets images' own categories only (D140, #332), and Manage category renames a value on prompts, recipes and images together (D144). The screen consumes only the `/prompt_manager/vocabulary/*` routes -above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough. +above and adds no route, so PR B needed no ComfyUI restart; D149 (#444) adds read +parameters to `GET tags` and `GET /prompt_manager/library`, which do. Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts, Images counting the files that carry the tag (`image_count`, #249 C2), and Actions with Browse and @@ -6671,6 +6727,36 @@ Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or m Browse sets the exact filter; singular "1 positive prompt"; the production list appends fixture-only tags after the concept's sample tags. +- **D149** (#444, cutover #334; the concept's renderTags has neither): the Tag vocabulary + surface shows, after its paragraph, an "Untagged prompts" `.operation` row ("N positive + prompts", Browse) and then the tag search and sort, drawn as the concept page toolbar's + `search.toolbar` > `label.search-field` (search icon, `input#tag-search` "Search tags…", + maxlength 200) and a `select#tag-sort` (Oldest first, the default and the old order; + Name A–Z; Name Z–A; Most prompts; Fewest prompts). Search is a literal, case-insensitive + substring on the server (`GET /prompt_manager/vocabulary/tags?search=&sort=`; ASCII-only + case folding, like SQLite `LIKE`/`NOCASE`), debounced 250 ms; it resets only the tag list + to page one, keeps through command re-reads, reads "No tags match this search." when + empty and is announced in a visually hidden status ("N tags"). Every sort breaks ties by + ascending id; a cursor belongs to one search and sort (scope-hashed; another is 400 with + "refresh"); a count sort's counts can move between pages only through a write, which + re-reads from page one. Each tags page reports `untagged_count` (eligible Library parents + with no tag and no active pair carrying one, the complement of every tag's Browse; legacy + counted every prompt without `prompt_tags` and hid the entry at 0; this row always + shows) and echoes `search` and `sort`. Browse opens the Library with `tag_mode=untagged` + (`GET /prompt_manager/library`, echoed as `tag_mode`; with a `tag` it is 400): "Filters · + 1", and Refine this view adds **Tag match** after Tags ("All of these tags" / "Any of these tags" (D150) / "Prompts + without tags", the latter disables Tags and drops them on Apply). A client whose request + the server did not echo (an un-restarted older server) shows the read error with Retry, + never an unfiltered list. Saved Library views store `tag_mode` (`all|any|untagged`, default + `all`); untagged with tags is refused on write and reads back as `all`; an older build + opens an untagged view as the whole Library. The concept comparison holds the row to the + concept's first category row, its top to the concept table's offset, the table to that + offset plus the additions' height (`d149`) and the toolbar to the concept Library + toolbar (`toolbarInventoryOf`), with a mutation check. Tests: + `tests/test_vocabulary.py` (`TestVocabularyTagSearchAndSort`, `TestLibraryTagMode`), + `test_api_vocabulary.py`, `test_api_library.py`, `test_saved_views.py`, the + vocabulary/library/saved-views JS units and `tests/browser/workspace-vocabulary-search.spec.mjs` + (behavior plus artifact captures). - **D143** (#332): category rows count the images whose own category is the value (`image_count`, the stored rows, missing files included): "N positive prompts · M images" beside prompts, "M images" for an image-only value, which follows every diff --git a/database/library.py b/database/library.py index 812b3faa..25ba6247 100644 --- a/database/library.py +++ b/database/library.py @@ -5,6 +5,7 @@ import sqlite3 from collections.abc import Iterator from contextlib import contextmanager +from .library_folders import folder_condition, list_library_folders from .library_tokens import library_edit_token from .prompt_archive import ARCHIVED, SHELVED @@ -128,7 +129,54 @@ def _archive(value: str) -> str: return ARCHIVE_SCOPES[value] -def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list]: +# How the Tags filter matches (#444): every tag (AND), any of them (OR), or +# parents without any tag. +TAG_MODES = ("all", "any", "untagged") +# A parent that no tag's Browse returns (#444 "Untagged prompts"): no tag of its +# own and no active pair carrying one. The vocabulary's untagged count uses it too. +UNTAGGED = ( + "NOT EXISTS(SELECT 1 FROM prompt_tags ut WHERE ut.prompt_id=p.id) " + "AND NOT EXISTS(SELECT 1 FROM prompt_pairs up JOIN prompt_pair_tags upt " + "ON upt.pair_id=up.id WHERE up.positive_prompt_id=p.id AND up.archived=0)" +) + + +def _tag_mode(tag_mode: object, tags: list[str]) -> str: + if not isinstance(tag_mode, str) or tag_mode not in TAG_MODES: + raise ValueError("tag_mode must be one of " + ", ".join(TAG_MODES)) + if tag_mode == "untagged" and tags: + raise ValueError("Untagged prompts cannot also filter by tags") + return tag_mode + + +def _tag_conditions(tags: list[str], any_tag: bool) -> tuple[list, list, list]: + """Parent-arm and pair-arm tag conditions and the tags' parameters.""" + parent_exists = ( + "EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id " + "WHERE pt.prompt_id=p.id AND t.name{test})" + ) + pair_exists = ( + "EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id " + "WHERE pt.pair_id=pp.id AND t.name{test})" + ) + if any_tag and tags: + # One condition per arm: the card, or one active pair, carries any tag. + test = " IN (" + ",".join("?" for _ in tags) + ")" + return [parent_exists.format(test=test)], [pair_exists.format(test=test)], tags + return ( + [parent_exists.format(test="=?") for _ in tags], + [pair_exists.format(test="=?") for _ in tags], + tags, + ) + + +def _filter( + text: str, + category: str | None, + tags: list[str], + tag_mode: str = "all", + folder_prefixes: list[str] | None = None, +) -> tuple[str, list]: if not isinstance(text, str) or len(text) > 500: raise ValueError("Search text must be a string of at most 500 characters") if category is not None and (not isinstance(category, str) or len(category) > 200): @@ -139,6 +187,8 @@ def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags) ): raise ValueError("Invalid library tags") + mode = _tag_mode(tag_mode, tags) + untagged = mode == "untagged" parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], [] if text: parent.append("(p.text LIKE ? ESCAPE '\\' OR ls.title LIKE ? ESCAPE '\\')") @@ -153,17 +203,11 @@ def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list pair.append("pp.category=?") parent_params.append(category) pair_params.append(category) - for tag in tags: - parent.append( - "EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id " - "WHERE pt.prompt_id=p.id AND t.name=?)" - ) - pair.append( - "EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id " - "WHERE pt.pair_id=pp.id AND t.name=?)" - ) - parent_params.append(tag) - pair_params.append(tag) + parent_tags, pair_tags, tag_params = _tag_conditions(tags, mode == "any") + parent.extend(parent_tags) + pair.extend(pair_tags) + parent_params.extend(tag_params) + pair_params.extend(tag_params) condition = ( "(" + (" AND ".join(parent) or "1=1") @@ -175,7 +219,17 @@ def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list + " AND ".join(pair) + ")" ) - return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params] + if untagged: + # A whole-card condition: inside the parent-or-pair OR an untagged pair + # would let a tagged parent through. + condition = f"({condition}) AND {UNTAGGED}" + params = [*parent_params, *pair_params] + if folder_prefixes: + # A whole-card condition around both arms: images link to the parent. + folder, folder_params = folder_condition(folder_prefixes) + condition = f"({condition}) AND {folder}" + params.extend(folder_params) + return f"({_ELIGIBLE}) AND ({condition})", params class LibraryOperations: @@ -261,6 +315,8 @@ class LibraryOperations: text: str = "", category: str | None = None, tags: list[str] | None = None, + tag_mode: str = "all", + folder_prefixes: list[str] | None = None, sort: str = "newest", limit: int = 40, after: dict | None = None, @@ -271,7 +327,9 @@ class LibraryOperations: raise ValueError("Unknown library sort order") if not isinstance(shelf, str) or shelf not in PARENT_SCOPES: raise ValueError("shelf must be active or archived") - where, params = _filter(text, category, [] if tags is None else tags) + where, params = _filter( + text, category, [] if tags is None else tags, tag_mode, folder_prefixes + ) if after is not None: if ( not isinstance(after, dict) @@ -328,6 +386,8 @@ class LibraryOperations: "total": total, "variant_total": variant_total, "archived_total": archived_total, + # Echoed so a client can tell this read applied its tag match. + "tag_mode": tag_mode, "has_more": has_more, "next_key": next_key, } @@ -338,6 +398,8 @@ class LibraryOperations: text: str = "", category: str | None = None, tags: list[str] | None = None, + tag_mode: str = "all", + folder_prefixes: list[str] | None = None, sort: str = "newest", limit: int = 40, after: dict | None = None, @@ -351,6 +413,8 @@ class LibraryOperations: text=text, category=category, tags=tags, + tag_mode=tag_mode, + folder_prefixes=folder_prefixes, sort=sort, limit=limit, after=after, @@ -482,9 +546,13 @@ class LibraryOperations: "categories": [row["category"] for row in rows], } - def get_library_options(self) -> dict: + def get_library_options(self, folder_roots=None) -> dict: + """Categories; with *folder_roots* (root spellings) also ``folders``.""" with self.library_snapshot() as (conn, epoch, _ceiling): - return self._get_library_options(conn, epoch) + result = self._get_library_options(conn, epoch) + if folder_roots is not None: + result["folders"] = list_library_folders(conn, folder_roots, _ELIGIBLE) + return result def get_library_previews(self, positive_ids: list[int]) -> dict[int, list[dict]]: """Read at most two links per parent in one bounded page query.""" diff --git a/database/library_folders.py b/database/library_folders.py new file mode 100644 index 00000000..24767957 --- /dev/null +++ b/database/library_folders.py @@ -0,0 +1,111 @@ +"""Library output-folder filter (#444): folder names, root prefixes and listing. + +A folder is a ``/``-joined path relative to an output root, as Images' folder +filter and the legacy ``/prompt_manager/subfolders`` name them. A parent +matches a folder when one of its linked images (``generated_images.prompt_id``, +the link its previews and image count read) lies in that folder or below it, +under any spelling of any output root. + +Matching compares ``generated_images.file_path``, which is always +``normcase(normpath(abspath(path)))`` (``models.normalize_image_path``), with +prefixes built by the same normalization here, so case folding is the +platform's own on both sides (Windows folds non-ASCII too, unlike SQLite's +``lower()``) and the comparison is an exact ``substr`` with no wildcards. +""" + +import os +import sqlite3 + +_MAX_FOLDER = 1024 +_MAX_PREFIXES = 64 + + +def normalize_folder(value: object) -> str: + """The folder as ``a/b``: ``\\`` read as ``/``, outer slashes dropped. + + Empty means no folder filter. ``.``/``..``/empty segments, a drive or + control characters are refused: the value only ever names a folder below a + root, never a path of its own. + """ + if not isinstance(value, str) or len(value) > _MAX_FOLDER: + raise ValueError(f"folder must be text of at most {_MAX_FOLDER} characters") + folder = value.replace("\\", "/").strip("/") + if not folder: + return "" + parts = folder.split("/") + if ( + any(part in ("", ".", "..") for part in parts) + or ":" in parts[0] + or any(ord(char) < 32 or ord(char) == 127 for char in folder) + ): + raise ValueError("folder must be a folder below an output root") + return folder + + +def _base(root: object, pathmod) -> str: + return pathmod.normcase(pathmod.normpath(str(root))) + + +def folder_prefixes(roots, folder: str, pathmod=os.path) -> list[str]: + """One ``file_path`` prefix per root spelling for a normalized folder.""" + prefixes = [] + for root in roots: + joined = pathmod.join(_base(root, pathmod), *folder.split("/")) + prefix = pathmod.normcase(pathmod.normpath(joined)) + pathmod.sep + if prefix not in prefixes: + prefixes.append(prefix) + if not prefixes or len(prefixes) > _MAX_PREFIXES: + raise ValueError("No output folder to filter by") + return prefixes + + +def folder_condition(prefixes: list[str]) -> tuple[str, list]: + """A condition on alias ``p``: one linked image under any prefix.""" + tests = " OR ".join("substr(fg.file_path,1,?)=?" for _ in prefixes) + params = [value for prefix in prefixes for value in (len(prefix), prefix)] + return ( + "EXISTS(SELECT 1 FROM generated_images fg WHERE fg.prompt_id=p.id " + f"AND ({tests}))", + params, + ) + + +def relative_folder(image_path: str, roots, pathmod=os.path) -> str | None: + """The folder of *image_path* below the first root spelling holding it. + + The spelling of the stored path is kept (its case on Windows); ``None`` + for an image directly in a root or outside every root. + """ + directory = pathmod.normpath(pathmod.dirname(image_path)) + folded = pathmod.normcase(directory) + for root in roots: + base = _base(root, pathmod) + start = base if base.endswith(pathmod.sep) else base + pathmod.sep + if not folded.startswith(start): + continue + # normcase keeps the length for ordinary names; else show the folded form. + rest = (directory if len(directory) == len(folded) else folded)[len(start) :] + parts = rest.split(pathmod.sep) + if any(part in ("", ".", "..") for part in parts): + return None + return "/".join(parts) + return None + + +def list_library_folders( + conn: sqlite3.Connection, roots, eligible: str, pathmod=os.path +) -> list[str]: + """Every folder (and ancestor) holding an image of an eligible parent.""" + folders = set() + for (image_path,) in conn.execute( + "SELECT DISTINCT g.image_path FROM generated_images g " + "JOIN prompts p ON p.id=g.prompt_id WHERE " + eligible + ): + if not isinstance(image_path, str) or not image_path: + continue + folder = relative_folder(image_path, roots, pathmod) + if folder is None: + continue + parts = folder.split("/") + folders.update("/".join(parts[:index]) for index in range(1, len(parts) + 1)) + return sorted(folders, key=lambda value: (value.casefold(), value)) diff --git a/database/saved_views.py b/database/saved_views.py index 3a4d7afa..9d691f43 100644 --- a/database/saved_views.py +++ b/database/saved_views.py @@ -33,6 +33,9 @@ MAX_TAG_LENGTH = 50 _IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating") _LIBRARY_SORTS = ("newest", "oldest", "name", "rating") _MEDIA = ("all", "image", "video") +# How a Library view's Tags match (#444): every tag, any of them, or parents +# without tags ("untagged" never goes with tags; the listing refuses both). +_TAG_MODES = ("all", "any", "untagged") _CONTROL = re.compile(r"[\x00-\x1f\x7f]") _SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}") # A collection id as the listing's ``collection`` query takes it (#331): none @@ -67,6 +70,9 @@ _STATE_FIELDS = { "category": ("text", 100, ""), "sort": ("choice", _LIBRARY_SORTS, "newest"), "tags": ("tags", None, ()), + # #444: the Tags match and the output folder (relative, "/"-joined). + "tag_mode": ("choice", _TAG_MODES, "all"), + "folder": ("text", 1024, ""), }, } @@ -152,6 +158,8 @@ def encode_state(page: object, state: object) -> str: _validate_value(key, kind, limit, state[key]) if state.get("favorites") is True and state.get("collection"): raise SavedViewInvalid("A view cannot filter by favorites and a collection") + if state.get("tag_mode") == "untagged" and state.get("tags"): + raise SavedViewInvalid("A view cannot list untagged prompts and filter tags") encoded = json.dumps( {key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":") ) @@ -195,6 +203,9 @@ def normalize_state(page: str, raw: object) -> dict: # as the listing does for a URL with both (#331 D140). if state.get("favorites") is True and state.get("collection"): state["favorites"] = False + # Never written by this build either: the tags win. + if state.get("tag_mode") == "untagged" and state.get("tags"): + state["tag_mode"] = "all" return state diff --git a/database/vocabulary.py b/database/vocabulary.py index 79e76461..971112bd 100644 --- a/database/vocabulary.py +++ b/database/vocabulary.py @@ -20,7 +20,7 @@ counted, hashed into the token and renamed with the prompts and pairs. import sqlite3 -from .library import _ELIGIBLE, SHELVED_PAIR, _limit +from .library import _ELIGIBLE, SHELVED_PAIR, UNTAGGED, _escape, _limit from .prompt_archive import SHELVED from .library_commands import _TOKEN, LibraryCommandError, _validate_epoch from .library_tokens import canonical_token @@ -39,7 +39,7 @@ _IMAGE_FILES = ( ) -def _tag_rows(page: str) -> str: +def _tag_rows(page: str, order: str = "page.id") -> str: """Count rows for the registry rows selected by ``page`` (id, name).""" # Eligible parent components on the shelf carrying a tag in the page, # plus the positive parent of every active pair carrying it (always @@ -62,11 +62,53 @@ def _tag_rows(page: str) -> str: "COALESCE(MAX(images.n),0) AS image_count " "FROM page LEFT JOIN owners ON owners.tag_id=page.id " "LEFT JOIN images ON images.tag_id=page.id " - "GROUP BY page.id ORDER BY page.id" + "GROUP BY page.id ORDER BY " + order ) _TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?") +# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and +# "created" follow the id, so every order is total and keyset pages are exact. +TAG_SORTS = { + "created": (None, "ASC"), + "name": ("name COLLATE NOCASE", "ASC"), + "name-desc": ("name COLLATE NOCASE", "DESC"), + "prompts": ("prompt_count", "DESC"), + "prompts-asc": ("prompt_count", "ASC"), +} +_MAX_SEARCH = 200 +_TAG_SEARCH = "name LIKE ? ESCAPE '\\'" +# Every matching tag ranked by the prompt count its row shows (the owners rule +# of ``_tag_rows``), then one keyset page of it with its image counts. +_RANKED_TAG_PAGE = ( + "WITH owners(tag_id,pid) AS (" + "SELECT pt.tag_id,p.id FROM prompts p " + "JOIN prompt_tags pt ON pt.prompt_id=p.id WHERE " + _ELIGIBLE + " " + "AND " + SHELVED + " " + "UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp " + "JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id WHERE pp.archived=0 " + "AND " + SHELVED_PAIR + ")," + "counted AS (SELECT t.id,t.name,COUNT(DISTINCT o.pid) AS prompt_count " + "FROM tags t LEFT JOIN owners o ON o.tag_id=t.id WHERE {search} " + "GROUP BY t.id)," + "page AS (SELECT id,name,prompt_count FROM counted WHERE {after} " + "ORDER BY prompt_count {direction},id LIMIT ?)," + "images(tag_id,n) AS (" + _IMAGE_FILES + " " + "WHERE mit.tag_id IN (SELECT id FROM page) GROUP BY mit.tag_id) " + "SELECT page.id,page.name,page.prompt_count," + "COALESCE(images.n,0) AS image_count FROM page " + "LEFT JOIN images ON images.tag_id=page.id " + "ORDER BY page.prompt_count {direction},page.id" +) +_UNTAGGED_COUNT = ( + "SELECT COUNT(*) FROM prompts p WHERE (" + + _ELIGIBLE + + ") AND " + + UNTAGGED + # Untagged prompts opens the shelf, which hides archived parents (#443). + + " AND " + + SHELVED +) _TAG_ONE = _tag_rows("SELECT id,name FROM tags WHERE id=?") # Every owner kind a tag can sit on, for the delete confirmation. _TAG_IMPACT = ( @@ -219,6 +261,28 @@ def _tag_after(after: object) -> int: return after +def _tag_search(search: object) -> str: + if not isinstance(search, str) or len(search) > _MAX_SEARCH: + raise ValueError(f"Tag search must be text of at most {_MAX_SEARCH} characters") + return search.strip() + + +def _sorted_tag_after(after: object, sort: str) -> tuple | None: + """The ``[key, id]`` cursor of a name or prompt-count sort.""" + if after is None: + return None + key_type = str if sort.startswith("name") else int + if ( + not isinstance(after, list) + or len(after) != 2 + or type(after[0]) is not key_type + or type(after[1]) is not int + or after[1] <= 0 + ): + raise ValueError("Invalid vocabulary cursor") + return after[0], after[1] + + def _category_after(after: object) -> tuple[int, str] | None: if after is None: return None @@ -440,22 +504,80 @@ class VocabularyOperations: epoch: str, *, limit: int = 100, - after: int | None = None, + after: object = None, + search: str = "", + sort: str = "created", ) -> dict: + """One page of tags matching ``search`` (a literal, case-insensitive + substring) in ``sort`` order, with the untagged parents' count.""" _limit(limit) - start = _tag_after(after) - total = conn.execute("SELECT COUNT(*) FROM tags").fetchone()[0] - rows = [dict(row) for row in conn.execute(_TAG_PAGE, (start, limit + 1))] + search = _tag_search(search) + if not isinstance(sort, str) or sort not in TAG_SORTS: + raise ValueError("sort must be one of " + ", ".join(TAG_SORTS)) + where, params = "1=1", [] + if search: + where, params = _TAG_SEARCH, [f"%{_escape(search)}%"] + total, untagged = conn.execute( + f"SELECT (SELECT COUNT(*) FROM tags WHERE {where}),({_UNTAGGED_COUNT})", + params, + ).fetchone() + rows = [ + dict(row) for row in self._tag_page(conn, where, params, sort, after, limit) + ] has_more = len(rows) > limit rows = rows[:limit] + key, _direction = TAG_SORTS[sort] + next_key = None + if has_more: + last = rows[-1] + next_key = ( + last["id"] + if key is None + else [ + last["prompt_count" if key == "prompt_count" else "name"], + last["id"], + ] + ) return { "library_epoch": epoch, "tags": rows, "total": total, + "untagged_count": untagged, + # Echoed so a client can tell this read applied its search and sort. + "search": search, + "sort": sort, "has_more": has_more, - "next_key": rows[-1]["id"] if has_more else None, + "next_key": next_key, } + @staticmethod + def _tag_page(conn, where, params, sort, after, limit): + key, direction = TAG_SORTS[sort] + if key is None: + page = ( + f"SELECT id,name FROM tags WHERE {where} AND id>? ORDER BY id LIMIT ?" + ) + return conn.execute( + _tag_rows(page), [*params, _tag_after(after), limit + 1] + ) + cursor = _sorted_tag_after(after, sort) + operator = ">" if direction == "ASC" else "<" + keyset, keys = "1=1", [] + if cursor is not None: + keyset = f"({key}{operator}? OR ({key}=? AND id>?))" + keys = [cursor[0], cursor[0], cursor[1]] + if key == "prompt_count": + sql = _RANKED_TAG_PAGE.format( + search=where, after=keyset, direction=direction + ) + return conn.execute(sql, [*params, *keys, limit + 1]) + page = ( + f"SELECT id,name FROM tags WHERE {where} AND {keyset} " + f"ORDER BY {key} {direction},id LIMIT ?" + ) + order = f"page.{key} {direction},page.id" + return conn.execute(_tag_rows(page, order), [*params, *keys, limit + 1]) + def _vocabulary_categories( self, conn: sqlite3.Connection, @@ -499,9 +621,18 @@ class VocabularyOperations: ), } - def list_vocabulary_tags(self, *, limit: int = 100, after: int | None = None): + def list_vocabulary_tags( + self, + *, + limit: int = 100, + after: object = None, + search: str = "", + sort: str = "created", + ): with self.library_snapshot() as (conn, epoch, _ceiling): - return self._vocabulary_tags(conn, epoch, limit=limit, after=after) + return self._vocabulary_tags( + conn, epoch, limit=limit, after=after, search=search, sort=sort + ) def list_vocabulary_categories( self, *, limit: int = 100, after: list | None = None diff --git a/py/api/library.py b/py/api/library.py index 97453a76..02ca0088 100644 --- a/py/api/library.py +++ b/py/api/library.py @@ -21,9 +21,11 @@ from .pairs import pair_response try: from ...database.library import ARCHIVE_SCOPES from ...database.library_commands import LibraryCommandError + from ...database.library_folders import folder_prefixes, normalize_folder except ImportError: from database.library import ARCHIVE_SCOPES from database.library_commands import LibraryCommandError + from database.library_folders import folder_prefixes, normalize_folder _SECRET = os.urandom(32) @@ -290,17 +292,52 @@ class LibraryRoutesMixin: outputs = list(self._get_all_output_dirs()) return self._media_roots(outputs), len(outputs) + def _library_root_spellings(self) -> list[str]: + """Every spelling of each output root (#444 folder filter): the + validated, resolved root and each configured or detected path that + resolves to it, so images saved through a symlinked root match.""" + from ..config import GalleryConfig + + roots = [str(root) for root in self._get_all_output_dirs()] + candidates = [ + *GalleryConfig.MONITORING_DIRECTORIES, + self._find_comfyui_output_dir(), + *self._comfyui_output_dir_list(), + ] + spellings = [] + for root in roots: + for value in (root, *candidates): + if not value or not isinstance(value, (str, os.PathLike)): + continue + value = str(value) + same = value == root or os.path.normcase( + os.path.realpath(value) + ) == os.path.normcase(root) + if same and value not in spellings: + spellings.append(value) + return spellings + @pair_response async def get_library_options(self, request: web.Request) -> web.Response: - result = await self._run_in_executor(self.db.get_library_options) + include = request.query.get("include", "") + if include not in ("", "folders"): + raise ValueError("include must be empty or 'folders'") + if include: + # Folders are read only when Refine opens (a walk of linked paths). + roots = await self._run_in_executor(self._library_root_spellings) + result = await self._run_in_executor(self.db.get_library_options, roots) + else: + result = await self._run_in_executor(self.db.get_library_options) return web.json_response({"success": True, **result}) @pair_response async def list_library(self, request: web.Request) -> web.Response: + folder = normalize_folder(request.query.get("folder", "")) options = { "text": request.query.get("text", ""), "category": request.query.get("category"), "tags": sorted(set(request.query.getall("tag", []))), + "tag_mode": request.query.get("tag_mode", "all"), "sort": request.query.get("sort", "newest"), # #443: which shelf; part of the cursor scope, so a cursor never # crosses shelves. Not the variant ``archive`` scope of /{id}. @@ -308,21 +345,34 @@ class LibraryRoutesMixin: } limit = _page_limit(request) cursor = request.query.get("cursor") + roots = ( + await self._run_in_executor(self._library_root_spellings) if folder else [] + ) + prefixes = folder_prefixes(roots, folder) if folder else None def read() -> tuple[dict, dict]: with self.db.library_snapshot(include_ceiling=True) as snapshot: conn, epoch, ceiling = snapshot + # The folder and the roots it was resolved under join the scope. scope = _scope( - {"kind": "parents", "projection": 2, "epoch": epoch, **options} + { + "kind": "parents", + "projection": 2, + "epoch": epoch, + **options, + **({"folder": folder, "roots": roots} if folder else {}), + } ) result = self.db._list_library( conn, epoch, ceiling, **options, + folder_prefixes=prefixes, limit=limit, after=_decode(cursor, scope), ) + result["folder"] = folder records = self.db.get_library_previews( [parent["id"] for parent in result["items"]] ) diff --git a/py/api/vocabulary.py b/py/api/vocabulary.py index 8a579a57..dd58d80d 100644 --- a/py/api/vocabulary.py +++ b/py/api/vocabulary.py @@ -61,18 +61,25 @@ class VocabularyRoutesMixin: ) async def _vocabulary_page( - self, request: web.Request, kind: str, reader_name: str, name: bool + self, + request: web.Request, + kind: str, + reader_name: str, + name: bool, + options: dict | None = None, ) -> web.Response: limit = _vocabulary_limit(request) cursor = request.query.get("cursor") + options = options or {} def read() -> dict: # The snapshot stays open while the cursor is decoded and encoded, - # so its epoch scope is the epoch the page was read from. + # so its epoch scope is the epoch the page was read from. A tag + # search or sort joins the scope: its cursor fits no other order. with self.db.library_snapshot() as (conn, epoch, _ceiling): - scope = _scope({"kind": kind, "epoch": epoch}) + scope = _scope({"kind": kind, "epoch": epoch, **options}) result = getattr(self.db, reader_name)( - conn, epoch, limit=limit, after=_decode(cursor, scope) + conn, epoch, limit=limit, after=_decode(cursor, scope), **options ) result["next_cursor"] = _encode( result.pop("next_key"), scope, name=name @@ -84,8 +91,14 @@ class VocabularyRoutesMixin: @pair_response async def list_vocabulary_tags(self, request: web.Request) -> web.Response: + options = { + "search": request.query.get("search", "").strip(), + "sort": request.query.get("sort", "created"), + } + # Name keys are text: kept server side so the cursor stays small. + name = options["sort"].startswith("name") return await self._vocabulary_page( - request, "vocabulary-tags", "_vocabulary_tags", False + request, "vocabulary-tags", "_vocabulary_tags", name, options ) @pair_response diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index fa740476..c35e1ddd 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -558,7 +558,8 @@ test.describe('full inspector coarse pointer',()=>{ } await openSelected(page);const viewer=page.locator('dialog#viewer-dialog'); await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); - for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) { + // #450 D156: the view switch is hidden at 700px and below; a shown one is measured like ×. + for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button:visible').all()) { const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};}); expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80); } @@ -621,6 +622,11 @@ test('viewer matches the concept shell and toolbar order',async({page,request})= const production=await page.evaluate(VIEWER_SHELL); // C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into. production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details')); + // #450 D156: production's view switch sits right before × (hidden at 700px and below); the concept has none. + const modePaths=production.paths.filter(path=>path.includes('button#viewer-mode')); + expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']); + expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close'); + production.paths=production.paths.filter(path=>!modePaths.includes(path)); // #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow // reasons), and Download stays the original-file link; every other control keeps the concept's text and order. const appended=production.toolbar.slice(9); diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index 1d96d442..136cfb58 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -475,9 +475,9 @@ function measureAppearance(mainSelector) { } // D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them. +// D156 (#450) moved "Collapse viewer details by default" to the Viewer surface (workspace-viewer-mode.spec.mjs). const ADDED_APPEARANCE_ROWS = [ ['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', true], - ['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', false], ]; /** The concept's Appearance with the recorded deviations applied, given the server's real settings. */ @@ -488,10 +488,11 @@ function mapAppearanceConcept(reference, settings) { control: { ...toggleRow.control, id, checked } })); return { ...reference, - // #317: the server's thumbnail settings follow in their own surface ("Thumbnails", README: retained in Appearance). - layout: [reference.layout[0], 'section.surface', ...reference.layout.slice(1)], - // D97: two added toggles and the "Reset display defaults" operation close the surface. - surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] }, + // D156: the Viewer surface, then (#317) the server's thumbnail settings in their own surface ("Thumbnails", + // README: retained in Appearance). + layout: [reference.layout[0], 'section.surface', 'section.surface', ...reference.layout.slice(1)], + // D97: one added toggle (D156 moved the other) and the "Reset display defaults" operation close the surface. + surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.operation'] }, rows: [...reference.rows.map(row => row.control?.id === 'timeout' // The node search timeout is the server's real `result_timeout`, not the concept's sample 30. ? { ...row, control: { ...row.control, value: String(settings.result_timeout) } } : row), ...added], @@ -538,7 +539,7 @@ test('appearance comparison detects mutations', async ({ page, request }) => { // Unmapped, the concept differs only where D97 and the real timeout are recorded. expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.')) .filter((path, index, all) => all.indexOf(path) === index).sort()) - .toEqual(['layout.1', 'layout.2', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13', + .toEqual(['layout.1', 'layout.2', 'layout.3', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.length', 'surface.tree'].sort()); const measure = () => page.evaluate(measureAppearance, '#workspace-main'); const paths = async () => diffInventory(expected, await measure()).map(item => item.path); diff --git a/tests/browser/vocabulary-canonical-comparison.mjs b/tests/browser/vocabulary-canonical-comparison.mjs index b8e86b27..34c7a149 100644 --- a/tests/browser/vocabulary-canonical-comparison.mjs +++ b/tests/browser/vocabulary-canonical-comparison.mjs @@ -29,6 +29,19 @@ export function pageInventoryOf(main) { const rows=[...table.querySelectorAll('tbody tr')]; const firstCell=rows[0]?.querySelector('td'); const operations=[...categorySurface.querySelectorAll('.operation')].filter(visible); + const operationOf=row=>{const h3=row.querySelector('h3'),p=row.querySelector('p'),rowActions=row.querySelector('.row-actions');return { + ...css(row,'display','flexDirection','justifyContent','alignItems','columnGap','paddingTop','paddingBottom','borderTopWidth','borderTopStyle','borderTopColor'), + height:px(row.getBoundingClientRect().height),h3:{text:h3.textContent,...font(h3)},p:{text:p.textContent,...font(p)}, + actions:{...css(rowActions,'flexShrink','flexWrap','columnGap'),buttons:[...rowActions.querySelectorAll('button')].map(buttonOf)}};}; + // D149 (#444): production adds the Untagged prompts row and the tag search and sort above the table; the concept has + // neither. The toolbar is compared on its own (toolbarInventoryOf) with the concept's page toolbar as donor. + const untaggedRow=tagSurface.querySelector(':scope > .operation'); + const d149=untaggedRow?{untagged:operationOf(untaggedRow), + // It starts where the concept's table starts: right after the paragraph. + offset:px(untaggedRow.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top), + // The vertical space the two additions take: the table starts this much lower than after the paragraph alone. + // (Both offsets rounded first, so offset + shift is exactly the table's rounded offset.) + shift:px(wrap.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top)-px(untaggedRow.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top)}:null; return { heading:{...css(heading,'display','flexDirection','justifyContent','alignItems','columnGap','rowGap','marginBottom'), h1:{text:h1.textContent,tabIndex:h1.tabIndex,...font(h1)}, @@ -54,14 +67,26 @@ export function pageInventoryOf(main) { rows:rows.map(row=>{const cells=[...row.querySelectorAll('td')];return {cells:cells.length,height:px(row.getBoundingClientRect().height),tagLines:lines(cells[0]),tag:cells[0].textContent,prompts:cells[1].textContent, images:shownText(cells[2]),imagesHidden:cells[2].querySelector('.ca-visually-hidden')?.textContent??null, actions:[...cells[3].querySelectorAll('.row-actions button')].map(node=>node.textContent.trim())};})}, - operations:operations.map(row=>{const h3=row.querySelector('h3'),p=row.querySelector('p'),rowActions=row.querySelector('.row-actions');return { - ...css(row,'display','flexDirection','justifyContent','alignItems','columnGap','paddingTop','paddingBottom','borderTopWidth','borderTopStyle','borderTopColor'), - height:px(row.getBoundingClientRect().height),h3:{text:h3.textContent,...font(h3)},p:{text:p.textContent,...font(p)}, - actions:{...css(rowActions,'flexShrink','flexWrap','columnGap'),buttons:[...rowActions.querySelectorAll('button')].map(buttonOf)}};}), + operations:operations.map(operationOf), + d149, noPageScroll:document.documentElement.scrollWidth<=innerWidth+1, }; } +/** A `search.toolbar`'s tokens (D149): runs in the page on the concept's page toolbar and on the tag search and sort. */ +export function toolbarInventoryOf(root) { + const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));}; + const px=value=>Math.round(value); + const field=root.querySelector('.search-field'),input=field.querySelector('input'),select=root.querySelector('select'); + return {...css(root,'display','columnGap','rowGap','flexWrap','alignItems','marginBottom'), + field:{...css(field,'display','alignItems','columnGap','flexGrow','minWidth','paddingLeft','paddingRight','borderTopWidth','borderTopStyle', + 'borderTopColor','borderTopLeftRadius','backgroundColor'),icon:Boolean(field.querySelector(':scope > svg')),height:px(field.getBoundingClientRect().height)}, + input:{type:input.type,...css(input,'fontSize','minHeight','backgroundColor','borderTopWidth','color')}, + select:{...css(select,'fontSize','minHeight','backgroundColor','color','borderTopColor','borderTopLeftRadius','paddingLeft'), + height:px(select.getBoundingClientRect().height)}}; +} + + const countOf=(count,noun)=>`${count} ${noun}${count===1?'':'s'}`; /** @@ -81,12 +106,17 @@ export function withImageCounts(operations,{images={},only=[]}={}) { // Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal. // `imageCategories` (#332 D143) is the fixture's own image categories, none by default. -export function mapVocabularyConcept(reference,imageCategories={}) { +export function mapVocabularyConcept(reference,imageCategories={},{untaggedText=null,shift=0}={}) { const operations=reference.operations.map(row=>({...row,p:{...row.p, // Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243). text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})); - return {...reference, - table:{...reference.table,rows:reference.table.rows.map(row=>({...row, + // D149 (#444): the Untagged prompts row is drawn as the concept's first category row with its own text and a Browse + // action, and the table moves down by the space it and the tag search and sort take. + const first=reference.operations[0]; + const d149=untaggedText===null?null:{untagged:{...first,h3:{...first.h3,text:'Untagged prompts'},p:{...first.p,text:untaggedText}}, + offset:reference.table.wrap.offset,shift}; + return {...reference,d149, + table:{...reference.table,wrap:{...reference.table.wrap,offset:reference.table.wrap.offset+shift},rows:reference.table.rows.map(row=>({...row, // Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture // tags no image, so every Images count is 0 (#249 C2 retired #245 D3). images:'0'}))}, @@ -94,9 +124,14 @@ export function mapVocabularyConcept(reference,imageCategories={}) { }; } +/** The D149 mapping inputs: the fixture's Untagged prompts count text (none by default) and production's table shift. */ +export function d149Of(actual,untaggedText='0 positive prompts') { + return actual.d149?{untaggedText,shift:actual.d149.shift}:{}; +} + /** The page comparison: concept rows are the concept's sample tags; production appends fixture-only tags (D4). */ export function compareVocabularyPage(reference,actual,imageCategories={}) { - const expected=mapVocabularyConcept(reference,imageCategories); + const expected=mapVocabularyConcept(reference,imageCategories,d149Of(actual)); const shared=expected.table.rows.length; return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}), extra:actual.table.rows.slice(shared).map(row=>row.tag)}; diff --git a/tests/browser/workspace-library-filters.spec.mjs b/tests/browser/workspace-library-filters.spec.mjs new file mode 100644 index 00000000..075f76f5 --- /dev/null +++ b/tests/browser/workspace-library-filters.spec.mjs @@ -0,0 +1,128 @@ +// #444 (D150): the Library's output-folder filter and multi-tag OR in "Refine this view", against the real server, and +// the Refine dialog against the concept's Filters dialog (app.js:1345-1352). +import {writeFile} from 'node:fs/promises'; +import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; +import {test,expect} from './fixtures.mjs'; +import {captureCanonicalState} from './history-canonical-comparison.mjs'; +import {inventoryOf,diffInventory} from './library-editor-canonical-comparison.mjs'; + +const route='/prompt_manager/workspace#/library'; +const concept='/__test__/canonical/workspace'; +const cards=page=>page.locator('article[data-prompt-id]'); +const card=(page,text)=>cards(page).filter({has:page.getByText(text,{exact:true})}); +const toolbar=page=>page.locator('#workspace-main search.toolbar'); +const filtersButton=page=>toolbar(page).getByRole('button',{name:/^Filters(?: · \d)?$/}); +const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true}); +const folder=page=>refine(page).getByRole('combobox',{name:'Folder',exact:true}); +const tags=page=>refine(page).getByRole('textbox',{name:'Tags',exact:true}); +const match=page=>refine(page).getByRole('combobox',{name:'Tag match',exact:true}); +const apply=page=>refine(page).getByRole('button',{name:'Apply filters',exact:true}); +const caption=page=>page.locator('#workspace-main .section-caption > span').first(); + +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([]);}); + +async function open(page) { + await page.goto(route);await expect(caption(page)).toHaveText(/^3 positive prompts · /); +} +/** Open Refine and wait for the folder list it reads on opening. */ +async function openRefine(page) { + const folders=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/library/options'&&new URL(item.url()).searchParams.get('include')==='folders'); + await filtersButton(page).click();await expect(refine(page)).toBeVisible();await folders; +} + +test('Refine offers Folder, Tags and Tag match; a folder narrows the shelf to prompts with images there',async({page})=>{ + await open(page);await openRefine(page); + await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']); + // Both roots hold a "shared" folder; the root's own images name no folder. + await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']); + await expect(match(page).locator('option')).toHaveText(['All of these tags','Any of these tags','Prompts without tags']); + await folder(page).selectOption('shared');await apply(page).click(); + await expect(caption(page)).toHaveText(/^1 positive prompt · /); + await expect(cards(page)).toHaveCount(1);await expect(card(page,'Cat portrait')).toHaveCount(1); + await expect(filtersButton(page)).toHaveText('Filters · 1'); + await openRefine(page);await expect(folder(page)).toHaveValue('shared'); + await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click(); + await expect(caption(page)).toHaveText(/^3 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters'); +}); + +test('Tag match Any shows every prompt carrying one of the tags; changing only the match applies it',async({page})=>{ + await open(page);await openRefine(page); + await tags(page).fill('cats, dogs');await apply(page).click(); + await expect(caption(page)).toHaveText(/^0 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters · 1'); + await openRefine(page);await match(page).selectOption('any');await apply(page).click(); + await expect(caption(page)).toHaveText(/^2 positive prompts · /); + await expect(cards(page)).toHaveCount(2);await expect(card(page,'Cat portrait')).toHaveCount(1);await expect(card(page,'Dog landscape')).toHaveCount(1); + await expect(filtersButton(page)).toHaveText('Filters · 2'); +}); + +test('a saved view keeps its folder and tag match',async({page})=>{ + await open(page);await openRefine(page); + await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click(); + await expect(caption(page)).toHaveText(/^1 positive prompt · /); + await toolbar(page).getByRole('button',{name:'Save view',exact:true}).click(); + const save=page.getByRole('dialog',{name:'Save this view',exact:true}); + await save.getByRole('textbox',{name:'View name',exact:true}).fill('Shared cats or dogs'); + const created=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/saved-views'&&item.request().method()==='POST'); + await save.getByRole('button',{name:'Save',exact:true}).click(); + const body=(await created).request().postDataJSON(); + expect(body.state).toMatchObject({folder:'shared',tag_mode:'any',tags:['cats','dogs']}); + await page.goto('about:blank');await open(page); + await page.getByRole('navigation',{name:'Saved views',exact:true}).getByRole('link',{name:'Shared cats or dogs',exact:true}).click(); + await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 3'); +}); + +test('a chosen folder missing from the folder list stays listed and chosen in Refine',async({page})=>{ + await open(page);await openRefine(page);await folder(page).selectOption('shared');await apply(page).click(); + await expect(caption(page)).toHaveText(/^1 positive prompt · /); + // The folder has no image any more as far as the list knows (or the read failed): Apply must not drop it. + await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders:[]}})); + await openRefine(page); + await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']);await expect(folder(page)).toHaveValue('shared'); + await apply(page).click();await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 1'); +}); + +test('an older server that ignores the folder shows an error with Retry, never the whole shelf',async({page})=>{ + await open(page); + await page.route('**/prompt_manager/library?*',async route=>{ + const response=await route.fetch();const body=await response.json();delete body.folder;delete body.tag_mode; + await route.fulfill({response,json:body}); + }); + await openRefine(page);await folder(page).selectOption('shared');await apply(page).click(); + await expect(page.locator('#workspace-main').getByText('Prompt library could not be loaded. Retry.')).toBeVisible(); + await expect(cards(page)).toHaveCount(0); +}); + +// D150: the Refine dialog is the concept's Filters dialog: its Folder select and Tags input, plus Tag match drawn as the +// concept's own select; the concept's "Prompt evidence" (no data yet) and its prototype note are not shown. +function mapFiltersDialog(reference,{folders,matchOptions}) { + const [conceptFolder,,conceptTags]=reference.fields; + return {...reference, + fields:[{...conceptFolder,options:folders},{...conceptTags},{...conceptFolder,label:'Tag match',options:matchOptions}], + small:[], + // Production's Apply reads "Apply filters" (Images and Library share it, #249). + actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Apply tag filter'?{...item,text:'Apply filters'}:item)}}; +} +for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`Refine dialog matches the concept Filters dialog ${width} ${theme} (D150)`,async({page},testInfo)=>{ + await page.setViewportSize({width,height}); + await page.goto('about:blank');await page.goto(`${concept}#library`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); + await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible(); + const reference=await page.locator('#modal').evaluate(inventoryOf); + await captureCanonicalState(page,testInfo,`concept-filters-${width}-${theme}`,'#modal'); + await open(page);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openRefine(page); + await expect(folder(page).locator('option')).toHaveCount(2); + const actual=await refine(page).evaluate(inventoryOf); + await captureCanonicalState(page,testInfo,`production-refine-${width}-${theme}`,'dialog[open]'); + const expected=mapFiltersDialog(reference,{folders:[['','All output folders'],['shared','shared']],matchOptions:[['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]}); + const path=testInfo.outputPath('refine-inventory.json');await writeFile(path,JSON.stringify({reference,actual},null,2)); + await testInfo.attach('refine-inventory',{path,contentType:'application/json'}); + expect(reference.fields.map(item=>item.label)).toEqual(['Folder','Prompt evidence','Tags']); + expect(diffInventory(expected,actual)).toEqual([]); + await refine(page).getByRole('button',{name:'Close dialog',exact:true}).click(); + await openRefine(page);await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click(); + await expect(caption(page)).toHaveText(/^1 positive prompt · /);await page.mouse.move(0,0); + await captureCanonicalState(page,testInfo,`production-library-folder-any-${width}-${theme}`,'#workspace-main'); +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index b91a73df..a2ba6187 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -155,7 +155,8 @@ test('Retry re-reads a failed category list with the shelf',async({page})=>{ await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click(); await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0); const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' '); - await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3); + // Only the folder list Refine reads on opening (D150), never the shelf. + await page.waitForTimeout(400);expect(reads).toEqual(['/prompt_manager/library/options']);await expect(cards(page)).toHaveCount(3); }); test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{ @@ -200,7 +201,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy // D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted. await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']); await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click(); - await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio'); + await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio'); await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0); await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused(); await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3); diff --git a/tests/browser/workspace-saved-views.spec.mjs b/tests/browser/workspace-saved-views.spec.mjs index ca176c06..1b216a4d 100644 --- a/tests/browser/workspace-saved-views.spec.mjs +++ b/tests/browser/workspace-saved-views.spec.mjs @@ -208,7 +208,7 @@ test('a view stored under older rules still lists and opens with its usable valu await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) }); expect(await listed(request)).toEqual([ { id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } }, - expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [] } }), + expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }), ]); // A state that is no JSON object is never listed, and its link behaves like a missing view. const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' }); @@ -233,7 +233,7 @@ test('a view stored under older rules still lists and opens with its usable valu // D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved. test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => { const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } }); - const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [] } }); + const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } }); expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']); const listings = []; page.on('request', item => { @@ -257,7 +257,7 @@ test('a view saved under Highest rated opens on Recently added on Images and the test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => { const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } }); - const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } }); + const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }); await page.goto(`${workspace}#/images`); await search(page).fill('cat'); await expect(imageCards(page)).toHaveCount(1); @@ -300,7 +300,7 @@ test('a toolbar change while sources load keeps the saved Source and Folder (#24 }); test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => { - const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } }); + const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }); let releaseList = () => {}; const holdList = async () => { const held = new Promise(resolve => { releaseList = resolve; }); @@ -348,7 +348,7 @@ test('an unknown saved view opens Images, and a failed listing offers Retry', as await expect(page).toHaveURL(/#\/images$/); await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images'); - const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } }); + const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }); let failing = true; await page.route(`**${API}`, route => failing && route.request().method() === 'GET' ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' }) @@ -539,7 +539,7 @@ test('Edit renames an open view, and deletes it only after confirming inside the }); test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => { - const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } }); + const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }); await page.goto(`${workspace}#/saved/${view.id}`); await expect(heading(page)).toHaveText('Cats'); const edit = main(page).locator('.heading-actions > button').first(); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index 1ea7505d..6581fe85 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -287,7 +287,8 @@ test('Appearance › Thumbnails saves the server thumbnail settings and never co await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: false } })).toBeOK(); await expect(await request.post('/__test__/system/legacy-previews', { data: { count: 1 } })).toBeOK(); await page.goto(`${workspace}#/system/appearance`); - const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(1); + // #450 D156: the Viewer surface sits between Browsing & display and Thumbnails. + const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(2); await expect(surface.locator(':scope > h2')).toHaveText('Thumbnails'); await expect(surface.locator('.setting-row label')).toHaveText(['Thumbnail format', 'Thumbnail quality', 'Thumbnail size limit']); const format = surface.locator('#thumbnail-format'); diff --git a/tests/browser/workspace-viewer-mode.spec.mjs b/tests/browser/workspace-viewer-mode.spec.mjs new file mode 100644 index 00000000..704b89a4 --- /dev/null +++ b/tests/browser/workspace-viewer-mode.spec.mjs @@ -0,0 +1,142 @@ +// #450 (TESTING.md D156): every viewer has a full-screen toggle beside ✕. Full screen fills the browser window edge to +// edge, Centered is the concept's framed dialog; the button names the view it switches to. System › Appearance › +// Viewer › "Open viewer in" chooses how the viewer opens; the button changes the current view only. +import { test, expect } from './fixtures.mjs'; +import { seedConcept, openProduction } from './concept-inventory.mjs'; +import { parityPng } from './local-inspect-files.mjs'; + +test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' }); + +const CARDS = '.gallery-body > .image-grid > .media-card'; +const viewer = page => page.locator('dialog#viewer-dialog[open]'); +const modeButton = page => viewer(page).locator('.dialog-header > button#viewer-mode'); +const details = page => viewer(page).locator('#viewer-details'); + +test.beforeEach(async ({ request }, testInfo) => { testInfo.manifest = await seedConcept(request); }); + +async function openImages(page, { width = 1600, height = 1100, theme = 'dark' } = {}) { + await page.setViewportSize({ width, height }); + await openProduction(page, 'images', theme); + await page.locator(CARDS).first().locator('.image-open').click(); + await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible(); +} + +/** The dialog's box and edges against the window. */ +const frame = page => viewer(page).evaluate(node => { + const box = node.getBoundingClientRect(), style = getComputedStyle(node); + return { left: Math.round(box.left), top: Math.round(box.top), right: Math.round(innerWidth - box.right), + bottom: Math.round(document.documentElement.clientHeight - box.bottom), radius: style.borderTopLeftRadius, + border: style.borderTopWidth, fills: Math.abs(box.width - document.documentElement.clientWidth) <= 1 }; +}); + +test('the button beside ✕ switches between Centered and Full screen and names the view it switches to', async ({ page }) => { + await openImages(page); + const header = await viewer(page).locator('.dialog-header').evaluate(node => [...node.children].map(child => child.id || child.localName)); + expect(header).toEqual(['div', 'viewer-mode', 'viewer-close']); + await expect(modeButton(page)).toHaveAccessibleName('Full screen view'); + await expect(modeButton(page)).toHaveAttribute('title', 'Full screen view'); + await expect(modeButton(page).locator('svg.icon')).toHaveCount(1); + const centered = await frame(page); + expect(centered.left).toBeGreaterThan(0); + expect(centered.fills).toBe(false); + // Keyboard: the button is reachable and keeps focus after a switch. + await viewer(page).locator('#viewer-close').focus(); + await page.keyboard.press('Shift+Tab'); + await expect(modeButton(page)).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/); + await expect(modeButton(page)).toBeFocused(); + await expect(modeButton(page)).toHaveAccessibleName('Centered view'); + expect(await frame(page)).toEqual({ left: 0, top: 0, right: 0, bottom: 0, radius: '0px', border: '0px', fills: true }); + // The details stay toggleable in full screen. + const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true }); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await toggle.click(); + await expect(details(page)).toBeHidden(); + await viewer(page).getByRole('button', { name: 'Show details', exact: true }).click(); + await expect(details(page)).toBeVisible(); + // Back to Centered: today's dialog, with no toggle while the collapse setting is off. + await modeButton(page).click(); + await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/); + expect(await frame(page)).toEqual(centered); + await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0); + // Navigation keeps the current view; Esc closes from full screen; the next open is Centered again. + await modeButton(page).click(); + await viewer(page).getByRole('button', { name: 'Next media', exact: true }).click(); + await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/); + await viewer(page).getByRole('button', { name: 'Hide details', exact: true }).focus(); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + await page.locator(CARDS).first().locator('.image-open').click(); + await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/); +}); + +test('switching back to Centered keeps the collapse setting\'s toggle and hidden details (D97)', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true }))); + await page.setViewportSize({ width: 1600, height: 1100 }); + await openProduction(page, 'images', 'dark'); + await page.locator(CARDS).first().locator('.image-open').click(); + await expect(viewer(page)).toBeVisible(); + await expect(details(page)).toBeHidden(); + await modeButton(page).click(); + await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toBeVisible(); + await modeButton(page).click(); + await expect(details(page)).toBeHidden(); + await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toHaveAttribute('aria-expanded', 'false'); +}); + +test('System › Appearance › Viewer chooses how the viewer opens; the button changes only the current view', async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + await openProduction(page, 'system/appearance', 'dark'); + const surface = page.locator('#workspace-main section.surface').filter({ has: page.locator('h2', { hasText: /^Viewer$/ }) }); + await expect(surface.locator('.setting-row > div > :is(label, h3)')).toHaveText(['Open viewer in', 'Collapse viewer details by default']); + const select = surface.getByRole('combobox', { name: 'Open viewer in', exact: true }); + await expect(select).toHaveValue('centered'); + await select.selectOption('fullscreen'); + await page.getByRole('button', { name: 'Save preferences', exact: true }).click(); + await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || '{}').viewerOpenMode)).toBe('fullscreen'); + await page.evaluate(() => { location.hash = '#/images'; }); + await page.locator(CARDS).first().locator('.image-open').click(); + await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/); + await expect(modeButton(page)).toHaveAccessibleName('Centered view'); + await modeButton(page).click(); + await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings')).viewerOpenMode)).toBe('fullscreen'); +}); + +test('Inspect opens full screen with its details when that is the default (D155)', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ viewerOpenMode: 'fullscreen', sidebarCollapsedByDefault: true }))); + await page.setViewportSize({ width: 1600, height: 1100 }); + await openProduction(page, 'inspect', 'dark'); + await page.locator('#local-file').setInputFiles(parityPng('f02.png')); + await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/); + await expect(details(page)).toBeVisible(); + await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toBeVisible(); +}); + +test('the Library gallery viewer has the button too', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + await openProduction(page, `library/${testInfo.manifest.prompts[0].positive_id}`, 'dark'); + await page.locator('.media-card .image-open').first().click(); + await expect(modeButton(page)).toHaveAccessibleName('Full screen view'); +}); + +test('at 390px the viewer is already full-bleed and the button hides', async ({ page }) => { + await openImages(page, { width: 390, height: 844 }); + await expect(viewer(page).locator('#viewer-mode')).toBeHidden(); + await expect(viewer(page).locator('#viewer-close')).toBeVisible(); +}); + +test('full screen holds at 200% zoom and coarse pointers get a 40px target', async ({ page, browserName }) => { + await openImages(page); + await modeButton(page).click(); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + const zoomed = await frame(page); + expect(zoomed).toMatchObject({ left: 0, top: 0, right: 0, bottom: 0 }); + await page.evaluate(() => { document.documentElement.style.zoom = '1'; }); + if (await page.evaluate(() => matchMedia('(pointer: coarse)').matches)) { + const size = await modeButton(page).evaluate(node => [node.getBoundingClientRect().width, node.getBoundingClientRect().height]); + expect(Math.min(...size)).toBeGreaterThanOrEqual(40); + } + test.info().annotations.push({ type: 'engine', description: browserName }); +}); diff --git a/tests/browser/workspace-vocabulary-search.spec.mjs b/tests/browser/workspace-vocabulary-search.spec.mjs new file mode 100644 index 00000000..14d8967a --- /dev/null +++ b/tests/browser/workspace-vocabulary-search.spec.mjs @@ -0,0 +1,167 @@ +// #444 (D149): Tags & categories tag search, tag sort and the Untagged prompts row, against the real server. +import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; +import {test,expect} from './fixtures.mjs'; +import {armUnseenRestores} from './unseen-restore.mjs'; +import {captureCanonicalState} from './history-canonical-comparison.mjs'; + +const route='/prompt_manager/workspace#/tags'; +const main=page=>page.locator('#workspace-main'); +const tagSurface=page=>main(page).locator('.two-col > section.surface').first(); +const table=page=>main(page).getByRole('table',{name:'Reusable tags',exact:true}); +const rows=page=>table(page).locator('tbody tr'); +const names=page=>rows(page).locator('td:first-child'); +const tagRow=(page,name)=>rows(page).filter({has:page.locator('td:first-child').getByText(name,{exact:true})}); +const search=page=>page.getByRole('searchbox',{name:'Search tags',exact:true}); +const sort=page=>page.getByRole('combobox',{name:'Sort tags',exact:true}); +const untagged=page=>tagSurface(page).locator('.operation').filter({has:page.getByRole('heading',{level:3,name:'Untagged prompts',exact:true})}); +const status=page=>tagSurface(page).locator('search.vocabulary-tools [role="status"]'); +const shelf=page=>page.locator('article[data-prompt-id]'); +const filtersButton=(page,name)=>page.locator('#workspace-main search.toolbar').getByRole('button',{name,exact:true}); +const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true}); + +async function seed(request,data={}) { + const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json(); +} +let manifest; +test.beforeEach(async({page,request,browserName})=>{ + await startCoverage(page,browserName);await armUnseenRestores(page); + // Two minimal recipes without tags (the concept recipes all carry tags). + manifest=await seed(request,{extra_categories:2}); + page.vocabularyErrors=[];page.on('pageerror',error=>page.vocabularyErrors.push(error.message)); +}); +test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.vocabularyErrors).toEqual([]);}); + +async function open(page) { + await page.goto(route);await expect(rows(page)).toHaveCount(manifest.tags.length); +} + +test('the tag vocabulary surface orders its paragraph, Untagged prompts, the search and sort, then the table',async({page})=>{ + await open(page); + const order=await tagSurface(page).evaluate(node=>[...node.children].map(child=>child.matches('.operation')?'operation': + child.matches('search.toolbar')?'toolbar':child.matches('.table-wrap')?'table':child.tagName.toLowerCase()).slice(0,5)); + expect(order).toEqual(['h2','p','operation','toolbar','table']); + await expect(untagged(page).locator('p')).toHaveText('2 positive prompts'); + await expect(untagged(page).getByRole('button')).toHaveText(['Browse']); + await expect(search(page)).toHaveAttribute('placeholder','Search tags…'); + await expect(search(page)).toHaveAttribute('maxlength','200'); + await expect(sort(page).locator('option')).toHaveText(['Oldest first','Name A–Z','Name Z–A','Most prompts','Fewest prompts']); + await expect(sort(page)).toHaveValue('created'); + await expect(tagSurface(page).locator('label.search-field svg, label.search-field .ca-icon').first()).toBeVisible(); +}); + +test('search narrows the tags on the server, says when nothing matches and keeps the categories',async({page})=>{ + await open(page); + const categories=await main(page).locator('.two-col > section.surface').nth(1).locator('.operation h3').allTextContents(); + const reads=[];page.on('request',request=>{if(request.url().includes('/vocabulary/tags?'))reads.push(new URL(request.url()).searchParams);}); + await search(page).fill('LIGHT'); + await expect(names(page)).toHaveText(['warm_light','soft_light']); + await expect(status(page)).toHaveText('2 tags'); + expect(reads.at(-1).get('search')).toBe('LIGHT'); + // Literal: an underscore is not a wildcard. + await search(page).fill('a_c');await expect(rows(page)).toHaveCount(0); + await expect(tagSurface(page).locator('p.empty')).toHaveText('No tags match this search.'); + await expect(status(page)).toHaveText('No tags match this search.'); + await expect(main(page).locator('.two-col > section.surface').nth(1).locator('.operation h3')).toHaveText(categories); + await search(page).fill('');await expect(rows(page)).toHaveCount(manifest.tags.length); + await expect(status(page)).toHaveText(`${manifest.tags.length} tags`); +}); + +test('sort orders every tag by name or by prompts, ties in creation order',async({page})=>{ + await open(page); + const created=await names(page).allTextContents(); + await sort(page).selectOption('name'); + const byName=[...created].sort((a,b)=>a.toLowerCase()b.toLowerCase()?1:0); + await expect(names(page)).toHaveText(byName); + await sort(page).selectOption('name-desc');await expect(names(page)).toHaveText([...byName].reverse()); + await sort(page).selectOption('prompts'); + await expect(rows(page).first().locator('td:first-child')).toHaveText('cinematic'); + const counts=(await rows(page).locator('td:nth-child(2)').allTextContents()).map(Number); + expect(counts).toEqual([...counts].sort((a,b)=>b-a)); + await sort(page).selectOption('prompts-asc'); + const ascending=(await rows(page).locator('td:nth-child(2)').allTextContents()).map(Number); + expect(ascending).toEqual([...ascending].sort((a,b)=>a-b)); + await sort(page).selectOption('created');await expect(names(page)).toHaveText(created); +}); + +test('a rename while searching re-reads the same search and sort',async({page})=>{ + await open(page); + await sort(page).selectOption('name');await search(page).fill('light'); + await expect(names(page)).toHaveText(['soft_light','warm_light']); + await tagRow(page,'warm_light').getByRole('button',{name:'Rename / merge',exact:true}).click(); + const box=page.locator('dialog.vocabulary-dialog'); + await box.getByLabel('New name; existing names merge',{exact:true}).fill('amber_light'); + await box.getByRole('button',{name:'Apply',exact:true}).click();await expect(box).toHaveCount(0); + await expect(names(page)).toHaveText(['amber_light','soft_light']); + await expect(tagRow(page,'amber_light').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused(); + await expect(search(page)).toHaveValue('light');await expect(sort(page)).toHaveValue('name'); +}); + +test('Untagged prompts Browse opens the Library on exactly the prompts without tags',async({page})=>{ + await open(page); + await untagged(page).getByRole('button',{name:'Browse',exact:true}).click(); + await expect(page).toHaveURL(/#\/library$/); + await expect(main(page).getByRole('heading',{level:1})).toBeFocused(); + await expect(shelf(page)).toHaveCount(2); + await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(/^2 positive prompts · /); + for(const card of await shelf(page).all())await expect(card.locator('.tags .chip')).toHaveCount(0); + await filtersButton(page,'Filters · 1').click(); + await expect(refine(page).getByRole('combobox',{name:'Tag match',exact:true})).toHaveValue('untagged'); + await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toBeDisabled(); + // Changing only the Tag match applies it: the whole shelf returns. + await refine(page).getByRole('combobox',{name:'Tag match',exact:true}).selectOption('all'); + await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toBeEnabled(); + await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click(); + await expect(filtersButton(page,'Filters')).toBeVisible(); + await expect.poll(()=>shelf(page).count()).toBeGreaterThan(2); +}); + +test('Refine › Prompts without tags on the shelf drops typed tags and Clear restores All',async({page})=>{ + await page.goto('/prompt_manager/workspace#/library');await expect(shelf(page).first()).toBeVisible(); + await filtersButton(page,'Filters').click(); + await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill('cinematic'); + await refine(page).getByRole('combobox',{name:'Tag match',exact:true}).selectOption('untagged'); + await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click(); + await expect(shelf(page)).toHaveCount(2); + await filtersButton(page,'Filters · 1').click(); + await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(''); + await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click(); + await expect(filtersButton(page,'Filters')).toBeVisible();await expect.poll(()=>shelf(page).count()).toBeGreaterThan(2); +}); + +test('an older server that ignores the search shows an error with Retry, never an unfiltered list',async({page})=>{ + await open(page); + // An un-restarted server answers without the echo. + await page.route('**/prompt_manager/vocabulary/tags?*',async route=>{ + const response=await route.fetch();const body=await response.json();delete body.search;delete body.sort; + await route.fulfill({response,json:body}); + }); + await search(page).fill('light'); + await expect(tagSurface(page).locator('.vocabulary-error')).toBeVisible(); + await expect(rows(page)).toHaveCount(0); + await expect(tagSurface(page).getByRole('button',{name:'Retry',exact:true})).toBeVisible(); +}); + +test('the search and sort fit 390px without a page scroll',async({page})=>{ + await page.setViewportSize({width:390,height:844});await open(page); + const layout=await tagSurface(page).evaluate(node=>{ + const tools=node.querySelector('search.vocabulary-tools'),field=tools.querySelector('.search-field'),select=tools.querySelector('select'); + const box=node.getBoundingClientRect(),fieldBox=field.getBoundingClientRect(),selectBox=select.getBoundingClientRect(); + return {inside:fieldBox.left>=box.left&&selectBox.right<=box.right+0.5,fullField:Math.round(fieldBox.width)===Math.round(tools.getBoundingClientRect().width)}; + }); + expect(layout).toEqual({inside:true,fullField:true}); + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); +}); + +// Artifact-only captures for the maintainer's quick check (no pixel assertions): a search with a sort, the Library on +// Untagged prompts and its Refine dialog. +for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`D149 captures ${width} ${theme}`,async({page},testInfo)=>{ + await page.setViewportSize({width,height});await open(page); + await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); + await sort(page).selectOption('prompts');await search(page).fill('i');await expect(status(page)).toHaveText(/^\d+ tags$/); + await page.mouse.move(0,0); + await captureCanonicalState(page,testInfo,`production-tags-search-${width}-${theme}`,'#workspace-main'); + await untagged(page).getByRole('button',{name:'Browse',exact:true}).click();await expect(shelf(page)).toHaveCount(2); + await captureCanonicalState(page,testInfo,`production-library-untagged-${width}-${theme}`,'#workspace-main'); + await filtersButton(page,'Filters · 1').click();await expect(refine(page)).toBeVisible(); + await captureCanonicalState(page,testInfo,`production-refine-untagged-${width}-${theme}`,'dialog[open]'); +}); diff --git a/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs index ef5ab643..4901c3e4 100644 --- a/tests/browser/workspace-vocabulary.spec.mjs +++ b/tests/browser/workspace-vocabulary.spec.mjs @@ -3,7 +3,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from './fixtures.mjs'; import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs'; +import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,d149Of,toolbarInventoryOf} from './vocabulary-canonical-comparison.mjs'; const route='/prompt_manager/workspace#/tags'; // Prompts per concept tag (app.js:71-165): first appearance order, Library Browse counts. @@ -786,6 +786,15 @@ async function openProductionTags(page,theme,motion) { await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1); } const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf); +/** D149 donor: the concept's own page toolbar (Prompt library), whose search field and select the tag search reuses. */ +async function conceptToolbar(page,theme) { + await page.goto('about:blank');await page.goto(`${concept}#library`); + await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); + const inventory=await page.locator('#main search.toolbar').evaluate(toolbarInventoryOf); + await page.goto('about:blank');await page.goto(`${concept}#tags`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); + return inventory; +} +const tagToolbar=page=>page.locator('#workspace-main .two-col > section.surface:first-child > search.toolbar').evaluate(toolbarInventoryOf); /** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */ async function chromeOf(page,production) { const scope=page.locator(production?'#workspace-main':'#main'); @@ -805,7 +814,7 @@ async function chromeOf(page,production) { } /** Concept rows are the concept's own sample tags; production appends the fixture-only tags after them (D4 creation order). */ function comparePage(reference,actual) { - const expected=mapVocabularyConcept(reference); + const expected=mapVocabularyConcept(reference,{},d149Of(actual)); const shared=expected.table.rows.length; return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}), extra:actual.table.rows.slice(shared).map(row=>row.tag)}; @@ -818,13 +827,18 @@ for(const [width,height] of SIZES)for(const theme of ['dark','light'])test(`tags await openConceptTags(page,theme,motion);const reference=await pageOf(page,false); if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-tags-${width}-${theme}`,'#main'); const referenceChrome=await chromeOf(page,false); + const referenceTools=await conceptToolbar(page,theme); await openProductionTags(page,theme,motion);const actual=await pageOf(page,true); + const actualTools=await tagToolbar(page); if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-tags-${width}-${theme}`,'#workspace-main'); - result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true)}; + result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true), + toolbarDiff:diffInventory(referenceTools,actualTools)}; } const path=testInfo.outputPath('tags-geometry.json');await writeFile(path,JSON.stringify(result,null,2)); await testInfo.attach('tags-geometry',{path,contentType:'application/json'}); - for(const [motion,{reference,diff,extra,referenceChrome,actualChrome}] of Object.entries(result)) { + for(const [motion,{reference,diff,extra,referenceChrome,actualChrome,toolbarDiff}] of Object.entries(result)) { + // D149 (#444): the tag search and sort carry the concept page toolbar's tokens. + expect.soft(toolbarDiff,`${motion} tag search and sort against the concept toolbar`).toEqual([]); expect.soft(referenceChrome,`${motion} concept chrome`).toMatchObject({breadcrumb:'Tags',title:'Tags',ring:{visible:true},linkRing:{outlineOffset:'3px'}}); expect.soft(actualChrome,`${motion} breadcrumb, title and focus ring`).toEqual(referenceChrome); expect.soft(reference.heading.actions.buttons.map(item=>item.text),`${motion} concept heading actions`).toEqual(['+ Create tag','Auto-tag images']); @@ -894,6 +908,13 @@ test('mutation check: the comparison reports a changed surface padding and a cha expect(paths).toContain('surfaces.1.paddingLeft'); expect(paths).toContain('table.rows.0.actions.0'); expect(paths).toContain('table.rowActions.buttons.0.text'); + // D149: a moved Untagged prompts row and a restyled tag search are reported too. + await page.addStyleTag({content:'.vocabulary-main .surface > .operation{margin-top:6px!important}.vocabulary-tools .search-field{border-radius:9px!important}'}); + const moved=comparePage(reference,await pageOf(page,true)).diff.map(item=>item.path); + expect(moved).toContain('d149.offset'); + const tools=await conceptToolbar(page,'dark');await openProductionTags(page,'dark','no-preference'); + await page.addStyleTag({content:'.vocabulary-tools .search-field{border-radius:9px!important}'}); + expect(diffInventory(tools,await tagToolbar(page)).map(item=>item.path)).toContain('field.borderTopLeftRadius'); // The dialog comparison is not vacuous either: a removed Cancel and a recolored field are reported. const donors=await conceptDonors(page,'dark'); await openConceptTags(page,'dark','no-preference');await conceptDialogs.create(page); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 3dd4b920..dff63d85 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -472,7 +472,7 @@ test('System is native: the concept shell, section navigation, sections and the if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible(); if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2); expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`))) - .toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'div.save-row'], + .toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'section.surface', 'div.save-row'] /* D156: Browsing & display, Viewer, Thumbnails */, sources: ['div.notice', 'section.surface', 'div.save-row'], integrations: ['section.surface', 'section.surface', 'div.save-row'], maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'], diff --git a/tests/js/gallery-settings.test.js b/tests/js/gallery-settings.test.js index 54b22f4e..6b2666e8 100644 --- a/tests/js/gallery-settings.test.js +++ b/tests/js/gallery-settings.test.js @@ -79,6 +79,15 @@ test("the workspace keys have their own defaults and validation", () => { assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid"); }); +// #450 (D156): the viewer's default view, Centered unless Full screen was chosen. +test("the viewer open mode defaults to centered and accepts only its two values", () => { + const { VIEWER_OPEN_MODES } = require("../../web/js/gallery-settings.js"); + assert.deepEqual(VIEWER_OPEN_MODES, ["centered", "fullscreen"]); + assert.equal(DEFAULT_SETTINGS.viewerOpenMode, "centered"); + assert.equal(normalizeGallerySettings({ viewerOpenMode: "fullscreen" }).viewerOpenMode, "fullscreen"); + for (const value of ["full", true, 1, null]) assert.equal(normalizeGallerySettings({ viewerOpenMode: value }).viewerOpenMode, "centered"); +}); + // #330 S13: the legacy gallery's Page options edit only the page-only keys (PAGE_OPTION_KEYS); System › Appearance owns the rest, so a Save // or a Reset from the legacy page keeps every other stored key. function legacyGallery(stored, controls, extra = {}) { @@ -112,7 +121,8 @@ const PAGE_OPTION_CONTROLS = () => ({ test("a legacy gallery Save keeps the workspace keys and every other stored key", () => { const { manager, saved } = legacyGallery({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1, - imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list" }, + imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list", + viewerOpenMode: "fullscreen" }, PAGE_OPTION_CONTROLS()); manager.saveSettings(); const result = saved(); @@ -123,6 +133,7 @@ test("a legacy gallery Save keeps the workspace keys and every other stored key" assert.equal(result.showImageInfo, false); assert.equal(result.videoMute, false); assert.equal(result.thumbnailPromptDismissed, "sig"); + assert.equal(result.viewerOpenMode, "fullscreen"); // Page options no longer edit the default view (S13 G5), so a stored one stays. assert.equal(result.defaultViewMode, "list"); // The page-only keys come from the dialog. diff --git a/tests/js/helpers/mounted-dom.mjs b/tests/js/helpers/mounted-dom.mjs index 3b8bffc4..1d1530d5 100644 --- a/tests/js/helpers/mounted-dom.mjs +++ b/tests/js/helpers/mounted-dom.mjs @@ -10,6 +10,7 @@ export function mountedDom() { // As in the DOM, a string argument becomes a text node. static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;} append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}} + insertBefore(node,before){node.parent=this;const index=this.children.indexOf(before);this.children.splice(index<0?this.children.length:index,0,node);return node;} prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);} replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);} remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;} diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index cda08eb3..f81c1a9a 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -104,6 +104,31 @@ 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('tag mode any and a folder are sent, echoed and validated (#444)',async()=>{ + const {createLibraryStream,LIBRARY_TAG_MODES}=await source;assert.deepEqual(LIBRARY_TAG_MODES,['all','any','untagged']); + const calls=[];const pending=[{...page([item(1)]),tag_mode:'any',folder:'2026/Aug'},{...page([item(2)]),tag_mode:'all',folder:''},page([item(3)]),{...page([item(4)]),tag_mode:'all'}]; + const s=createLibraryStream({api:{get:async(url)=>{calls.push(new URL(url,'https://x').searchParams);return pending.shift();}}}); + assert.equal(await s.reset({tags:['a','b'],tagMode:'any',folder:'2026/Aug'}),1); + assert.equal(calls[0].get('tag_mode'),'any');assert.equal(calls[0].get('folder'),'2026/Aug');assert.deepEqual(calls[0].getAll('tag'),['a','b']); + // An older server that ignored them answers without the echo: an error, never the unfiltered shelf. + assert.equal(await s.reset({tags:['a','b'],tagMode:'any',folder:'2026/Aug'}),0);assert.ok(s.state.error instanceof TypeError); + // The defaults send neither, so they need no echo. + assert.equal(await s.reset({tagMode:'all',folder:''}),1);assert.equal(calls[2].has('tag_mode'),false);assert.equal(calls[2].has('folder'),false); + assert.equal(await s.reset({folder:'x'}),0);assert.ok(s.state.error instanceof TypeError,'a folder without its echo is refused'); + await assert.rejects(s.reset({tagMode:'or'}),TypeError);await assert.rejects(s.reset({folder:3}),TypeError); + assert.equal(calls.length,4);s.dispose(); +}); +test('tag mode untagged is sent, echoed and never combined with tags (#444)',async()=>{ + const {createLibraryStream}=await source;const calls=[];const pending=[{...page([item(1)]),tag_mode:'untagged'},page([item(2)]),page([item(3)])]; + const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}}); + assert.equal(await s.reset({tagMode:'untagged'}),1);assert.equal(new URL(calls[0],'https://x').searchParams.get('tag_mode'),'untagged'); + // An older server that ignored tag_mode answers without the echo: an error, never the whole Library. + assert.equal(await s.reset({tagMode:'untagged'}),0);assert.ok(s.state.error instanceof TypeError);assert.deepEqual(s.state.items,[]); + // The default sends no tag_mode, so it needs no echo. + assert.equal(await s.reset({tagMode:'all'}),1);assert.equal(new URL(calls[2],'https://x').searchParams.has('tag_mode'),false); + await assert.rejects(s.reset({tagMode:'untagged',tags:['a']}),TypeError);await assert.rejects(s.reset({tagMode:'or'}),TypeError); + assert.equal(calls.length,3);s.dispose(); +}); 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}]; diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index 628ccdd7..d9370950 100644 --- a/tests/js/workspace-media-viewer.test.js +++ b/tests/js/workspace-media-viewer.test.js @@ -188,6 +188,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi } finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); } }); +const left0 = dialog => dialog.children[1].children[0]; test('viewer renders the concept shell and toolbar order', async () => { const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs'); const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')]; @@ -199,7 +200,14 @@ test('viewer renders the concept shell and toolbar order', async () => { assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog'); const [header,layout] = dialog.children; assert.equal(header.className,'dialog-header'); - const [heading,close] = header.children, [title,subtitle] = heading.children; + const [heading,mode,close] = header.children, [title,subtitle] = heading.children; + // #450 D156: the view switch before ✕ names the view it switches to (Centered is the stored default). + assert.deepEqual([mode.tagName,mode.id,mode.className,mode.attributes['aria-label'],mode.title],['button','viewer-mode','icon-btn','Full screen view','Full screen view']); + mode.fire('click'); + assert.equal(mode.attributes['aria-label'],'Centered view'); + assert.ok(f.walk(left0(dialog),n => n.textContent === 'Hide details').length === 1,'full screen adds the details toggle'); + mode.fire('click'); + assert.equal(mode.attributes['aria-label'],'Full screen view'); assert.deepEqual([heading.tagName,title.tagName,title.id,title.textContent],['div','h2','viewer-title','a.png']); assert.deepEqual([subtitle.tagName,subtitle.className,subtitle.textContent],['p','muted small','One viewer · image, prompts, generation data, workflow']); assert.deepEqual([close.tagName,close.id,close.textContent,close.attributes['aria-label']],['button','viewer-close','×','Close image viewer']); diff --git a/tests/js/workspace-saved-views.test.js b/tests/js/workspace-saved-views.test.js index c5cb9d6a..bb9e0a50 100644 --- a/tests/js/workspace-saved-views.test.js +++ b/tests/js/workspace-saved-views.test.js @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const source = import('../../web/js/workspace/saved-views.mjs'); const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' }; -const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] }; +const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' }; test('a saved state always holds every page key and keeps only values its page accepts', async () => { const { normalizeViewState, SAVED_VIEW_PAGES } = await source; @@ -31,8 +31,8 @@ test('a saved state always holds every page key and keeps only values its page a assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }), { ...full, q: '', folder: '', tags: [] }); assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] }); - // Library has no source, folder or media, and no Name Z–A. - assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'] }); + // Library has no source or media, and no Name Z–A; its folder (#444) is the Library's own. + assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' }); assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' }); // The returned tags are a copy. const tags = ['one']; @@ -62,7 +62,7 @@ test('the listing keeps usable views in order and normalizes their state', async { id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } }, ] }); assert.deepEqual(views, [ - { id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } }, + { id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [], tag_mode: 'all', folder: '' } }, { id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } }, ]); assert.equal(parseSavedView(null), null); @@ -84,8 +84,8 @@ test('page filters and saved states convert both ways', async () => { assert.equal(imagesViewState({ ...filters, collection: undefined }).collection, ''); assert.equal(imagesViewState({ ...filters, collection: 7 }).collection, '7'); assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false); - const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] }; - assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] }); + const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tagMode: 'any', folder: '2026' }; + assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tag_mode: 'any', folder: '2026' }); assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf); }); @@ -216,6 +216,27 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5) assert.deepEqual(api.calls, []); }); +test('a Library view keeps its tag match and folder; an old view reads the defaults (#444)', async () => { + const { normalizeViewState, libraryFilters } = await source; + assert.deepEqual(normalizeViewState('library', { tag_mode: 'any', folder: '2026/Aug' }), { ...LIBRARY_DEFAULTS, tag_mode: 'any', folder: '2026/Aug' }); + assert.deepEqual(normalizeViewState('library', { tag_mode: 'or', folder: 'x'.repeat(1025) }), LIBRARY_DEFAULTS); + assert.deepEqual(libraryFilters({ q: 'a' }), { text: 'a', category: '', sort: 'newest', tags: [], tagMode: 'all', folder: '' }); +}); + +test('a Library view keeps untagged, and untagged never travels with tags (#444)', async () => { + const { normalizeViewState, libraryFilters, libraryViewState } = await source; + assert.equal(normalizeViewState('library', { tag_mode: 'untagged' }).tag_mode, 'untagged'); + assert.deepEqual(normalizeViewState('library', { tags: ['a'], tag_mode: 'untagged' }), { ...LIBRARY_DEFAULTS, tags: ['a'] }); + const shelf = { text: '', category: '', sort: 'newest', tags: [], tagMode: 'untagged', folder: '' }; + assert.equal(libraryViewState(shelf).tag_mode, 'untagged'); + assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf); +}); + +test('a shelf without the #444 filters saves their defaults', async () => { + const { libraryViewState } = await source; + assert.deepEqual(libraryViewState({ text: '', category: '', sort: 'newest', tags: [] }), LIBRARY_DEFAULTS); +}); + test('a view saved under the retired Highest rated sort opens on Recently added (D153)', async () => { const { normalizeViewState, imagesFilters, libraryFilters } = await source; assert.equal(normalizeViewState('images', { sort: 'rating' }).sort, 'newest'); diff --git a/tests/js/workspace-system-appearance.test.js b/tests/js/workspace-system-appearance.test.js index f45fb008..9c63763d 100644 --- a/tests/js/workspace-system-appearance.test.js +++ b/tests/js/workspace-system-appearance.test.js @@ -65,7 +65,7 @@ function fakeDOM() { const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true, imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true, - sidebarCollapsedByDefault: false }; + sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' }; async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER, hostFails = false } = {}) { const { mountAppearance } = await appearanceModule; @@ -80,9 +80,9 @@ async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: }); await drafts.load('browser'); const scope = createScope(); - const [surface] = mountAppearance(null, { scope, drafts }); - const byId = id => dom.find(surface, node => node.dataset?.setting === id); - return { dom, drafts, scope, surface, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); }, + const [surface, viewer] = mountAppearance(null, { scope, drafts }); + const byId = id => dom.find(surface, node => node.dataset?.setting === id) ?? dom.find(viewer, node => node.dataset?.setting === id); + return { dom, drafts, scope, surface, viewer, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); }, fixHost: () => { hostFails = false; } }; } @@ -98,13 +98,26 @@ test('the surface is the concept section with the concept rows in order, then th assert.deepEqual(titles, ['Default layout', 'Thumbnail size', 'Continuous browsing', 'Load metadata on selection', 'Cache metadata in this browser', 'Image loading quality', 'Autoplay videos', 'Mute videos by default', 'Loop videos', 'Show file paths', 'Open from ComfyUI', 'Node search auto-hide (seconds)', 'Show image details on cards', - 'Collapse viewer details by default', 'Reset display defaults']); + 'Reset display defaults']); const continuous = rows[2]; assert.deepEqual(dom.tags(continuous.children[0]), ['h3', 'p']); assert.equal(continuous.children[1].className, 'chip blue'); assert.equal(continuous.children[1].textContent, 'Infinite scroll'); assert.equal(rows.at(-1).className, 'operation'); - assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 13); + assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 12); + } finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); } +}); + +// #450 (D156, amends D97): a "Viewer" surface after "Browsing & display" holds the viewer's two defaults. +test('the Viewer surface holds Open viewer in and the moved collapse row', async () => { + const { VIEWER_ROWS } = await appearanceModule; + const { dom, viewer, scope } = await mounted(); + try { + assert.equal(viewer.className, 'surface'); + assert.equal(viewer.children[0].textContent, 'Viewer'); + assert.equal(viewer.children[1].textContent, 'How the image viewer opens; the button beside ✕ switches the current view.'); + assert.deepEqual(viewer.children.slice(2).map(row => row.children[0].children[0].textContent), ['Open viewer in', 'Collapse viewer details by default']); + assert.deepEqual(VIEWER_ROWS.map(row => row[0]), ['viewer-mode', 'viewer-details']); } finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); } }); @@ -127,9 +140,11 @@ test('added rows reach their consumers: each control edits its stored key with t byId(id).checked = false; byId(id).dispatch('change'); } for (const id of ['video-auto', 'viewer-details']) { byId(id).checked = true; byId(id).dispatch('change'); } + assert.deepEqual(byId('viewer-mode').children.map(option => [option.value, option.textContent]), [['centered', 'Centered'], ['fullscreen', 'Full screen']]); + byId('viewer-mode').value = 'fullscreen'; byId('viewer-mode').dispatch('change'); assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', workspaceDensity: 'large', imageQuality: 'low', autoLoadMetadata: false, cacheMetadata: false, videoMute: false, videoLoop: false, showFilePaths: false, showImageInfo: false, - videoAutoplay: true, sidebarCollapsedByDefault: true }); + videoAutoplay: true, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' }); assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' }); } finally { scope.dispose(); dom.restore(); } }); @@ -154,7 +169,7 @@ test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () => test('reset restores defaults without touching other keys', async () => { const { DISPLAY_KEYS } = await appearanceModule; - const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true }; + const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' }; const { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed }); try { await loadHost(); @@ -163,7 +178,7 @@ test('reset restores defaults without touching other keys', async () => { assert.equal(reset.textContent, 'Reset'); reset.click(); // #451: the default layout is Masonry. - assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false }); + assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' }); // The host fields and every key outside the display defaults are left as they are. assert.deepEqual(drafts.changes('host'), { result_timeout: 9 }); assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort()); diff --git a/tests/js/workspace-vocabulary-state.test.js b/tests/js/workspace-vocabulary-state.test.js index 5d9d8e80..b7392500 100644 --- a/tests/js/workspace-vocabulary-state.test.js +++ b/tests/js/workspace-vocabulary-state.test.js @@ -461,6 +461,57 @@ test('after dispose an unconfirmed write invalidates and a failed write does not } }); +test('a tag stream sends its search and sort, replaces them on reset and needs the server to echo them (#444)', async () => { + const {createVocabularyStream, TAG_SORTS} = await source; + assert.deepEqual(TAG_SORTS.map(([value]) => value), ['created', 'name', 'name-desc', 'prompts', 'prompts-asc']); + assert.deepEqual(TAG_SORTS.map(([, label]) => label), ['Oldest first', 'Name A–Z', 'Name Z–A', 'Most prompts', 'Fewest prompts']); + const echo = (page, search, sort) => ({...page, search, sort, untagged_count: 4}); + const {calls, api} = queued([ + echo(tagPage([tag(1)], 'next'), 'cat', 'name'), echo(tagPage([tag(2)]), 'cat', 'name'), + echo(tagPage([tag(3)]), '', 'created'), tagPage([tag(4)]), echo(tagPage([tag(5)]), 'dog', 'created'), + ]); + const stream = createVocabularyStream({api, kind: 'tags', query: {search: ' cat ', sort: 'name'}}); + await stream.loadMore(); + assert.equal(params(calls[0].url).get('search'), 'cat'); + assert.equal(params(calls[0].url).get('sort'), 'name'); + assert.equal(stream.state.untaggedCount, 4); + await stream.loadMore(); + assert.equal(params(calls[1].url).get('search'), 'cat'); + assert.equal(params(calls[1].url).get('cursor'), 'next'); + assert.deepEqual(stream.state.query, {search: 'cat', sort: 'name'}); + // The default query sends neither, so an older server answers it unchanged. + await stream.reset({search: '', sort: 'created'}); + assert.equal(params(calls[2].url).has('search'), false); + assert.equal(params(calls[2].url).has('sort'), false); + assert.deepEqual(stream.state.items.map(row => row.id), [3]); + // A server that ignored the search (no echo) is an error, never a wrong list. + await stream.reset({search: 'dog', sort: 'created'}); + assert.deepEqual(stream.state.items, []); + assert.ok(stream.state.error instanceof TypeError); + assert.equal(stream.state.untaggedCount, 4, 'the last known untagged count survives a reset'); + await stream.loadMore(); + assert.deepEqual(stream.state.items.map(row => row.id), [5]); + assert.throws(() => stream.reset({search: 'x', sort: 'count'}), TypeError); + assert.throws(() => createVocabularyStream({api, kind: 'categories', query: {search: 'x'}}), TypeError); + assert.throws(() => createVocabularyStream({api, kind: 'tags', query: {search: 3}}), TypeError); + stream.dispose(); +}); + +test('an invalid untagged_count rejects the page', async () => { + const {createVocabularyStream} = await source; + for (const untagged_count of [-1, 1.5, '2']) { + const stream = createVocabularyStream({api: queued([{...tagPage([tag(1)]), untagged_count}]).api, kind: 'tags'}); + await stream.loadMore(); + assert.ok(stream.state.error instanceof TypeError, String(untagged_count)); + assert.equal(stream.state.untaggedCount, null); + } +}); + +test('untaggedBrowseFilters opens the Library on prompts without tags (#444)', async () => { + const {untaggedBrowseFilters} = await source; + assert.deepEqual(untaggedBrowseFilters(), {text: '', category: '', tags: [], sort: 'newest', tagMode: 'untagged'}); +}); + test('browseFilters hands Library one exact constraint', async () => { const {browseFilters} = await source; assert.deepEqual(browseFilters('tags', 'a'), {text: '', category: '', tags: ['a'], sort: 'newest'}); diff --git a/tests/test_api_library.py b/tests/test_api_library.py index 0e650972..3b8da55f 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -60,6 +60,32 @@ class TestLibraryAPI(ImageAPITestCase): self.assertEqual(response.status, 400) self.assertNotIn(str(self.comfy_dir), json.dumps(result)) + async def test_untagged_tag_mode_lists_parents_without_tags(self): + self._save_prompt("plain one") + self._save_prompt("plain two") + self._save_prompt("tagged", tags=["t"]) + self.api.db.save_prompt_pair("pair tagged", "noise", tags=["p"]) + _, first = await self.get(limit=1, tag_mode="untagged") + self.assertEqual(first["total"], 2) + self.assertEqual(first["tag_mode"], "untagged") + _, second = await self.get( + limit=1, tag_mode="untagged", cursor=first["next_cursor"] + ) + self.assertEqual( + sorted([first["items"][0]["text"], second["items"][0]["text"]]), + ["plain one", "plain two"], + ) + _, default = await self.get(tag_mode="all") + self.assertEqual(default["total"], 4) + for params in ( + {"tag_mode": "untagged", "tag": "t"}, + {"tag_mode": "sql"}, + {"cursor": first["next_cursor"]}, + ): + response, result = await self.get(limit=1, **params) + self.assertEqual(response.status, 400, params) + self.assertEqual(result["success"], False) + async def test_outside_missing_and_unsupported_media_do_not_leak_or_scan(self): positive = self._save_prompt("Cat") outside = make_png(self.comfy_dir / "outside.png") @@ -97,6 +123,59 @@ class TestLibraryAPI(ImageAPITestCase): }, ) + async def test_folder_filter_lists_and_matches_prompts_by_output_folder(self): + deep = self._save_prompt("deep") + top = self._save_prompt("top") + other = self._save_prompt("other") + self._save_prompt("no images") + for prompt, parts in ((deep, ("a", "b")), (top, ("a",)), (other, ("ab",))): + path = make_png(self.output_dir.joinpath(*parts, f"{prompt}.png")) + self.api.db.link_image_to_prompt(prompt, str(path)) + response, options = await self.get("/options", include="folders") + self.assertEqual(response.status, 200) + self.assertEqual(options["folders"], ["a", "a/b", "ab"]) + _, plain = await self.get("/options") + self.assertNotIn("folders", plain) + _, first = await self.get(folder="a", limit=1) + self.assertEqual((first["total"], first["folder"]), (2, "a")) + _, second = await self.get(folder="\\a\\", limit=1, cursor=first["next_cursor"]) + self.assertEqual( + sorted([first["items"][0]["text"], second["items"][0]["text"]]), + ["deep", "top"], + ) + _, all_rows = await self.get() + self.assertEqual((all_rows["total"], all_rows["folder"]), (4, "")) + for params in ( + {"folder": "a/../ab"}, + {"folder": "C:/x"}, + {"folder": "b", "cursor": first["next_cursor"]}, + {"cursor": first["next_cursor"]}, + ): + response, result = await self.get(limit=1, **params) + self.assertEqual(response.status, 400, params) + self.assertEqual(result["success"], False) + response, _ = await self.get("/options", include="tags") + self.assertEqual(response.status, 400) + + async def test_any_tag_mode_is_sent_through_and_echoed(self): + self._save_prompt("a only", tags=["a"]) + self._save_prompt("b only", tags=["b"]) + self._save_prompt("neither") + _, result = await self.get(tag=["a", "b"], tag_mode="any") + self.assertEqual(result["tag_mode"], "any") + self.assertEqual( + sorted(i["text"] for i in result["items"]), ["a only", "b only"] + ) + _, both = await self.get(tag=["a", "b"]) + self.assertEqual((both["total"], both["tag_mode"]), (0, "all")) + _, first = await self.get(tag=["a", "b"], tag_mode="any", limit=1) + response, _ = await self.get( + tag=["a", "b"], limit=1, cursor=first["next_cursor"] + ) + self.assertEqual(response.status, 400) + response, _ = await self.get(tag_mode="or") + self.assertEqual(response.status, 400) + async def test_variant_cursor_is_positive_scoped_and_required_limits_are_bounded( self, ): diff --git a/tests/test_api_vocabulary.py b/tests/test_api_vocabulary.py index 9b6a23f8..9b3f3659 100644 --- a/tests/test_api_vocabulary.py +++ b/tests/test_api_vocabulary.py @@ -35,7 +35,17 @@ class TestVocabularyAPI(ImageAPITestCase): self.assertEqual(response.status, 200) self.assertEqual( set(first), - {"success", "library_epoch", "tags", "total", "has_more", "next_cursor"}, + { + "success", + "library_epoch", + "tags", + "total", + "untagged_count", + "search", + "sort", + "has_more", + "next_cursor", + }, ) self.assertEqual(first["library_epoch"], epoch) self.assertEqual(first["total"], 3) @@ -111,6 +121,41 @@ class TestVocabularyAPI(ImageAPITestCase): response, _ = await self.get("tags", limit=1, cursor=fresh["next_cursor"]) self.assertEqual(response.status, 200) + async def test_tag_search_and_sort_page_with_scoped_cursors(self): + self._save_prompt("One", tags=["beta", "alpha"]) + self._save_prompt("Two", tags=["beta"]) + self._save_prompt("Plain") + self.add_unused("gamma") + _, first = await self.get("tags", limit=1, sort="name", search="A") + self.assertEqual([row["name"] for row in first["tags"]], ["alpha"]) + self.assertEqual(first["total"], 3) + self.assertEqual(first["untagged_count"], 1) + self.assertEqual((first["search"], first["sort"]), ("A", "name")) + _, second = await self.get( + "tags", limit=1, sort="name", search="A", cursor=first["next_cursor"] + ) + self.assertEqual([row["name"] for row in second["tags"]], ["beta"]) + _, ranked = await self.get("tags", sort="prompts") + self.assertEqual( + [(row["name"], row["prompt_count"]) for row in ranked["tags"]], + [("beta", 2), ("alpha", 1), ("gamma", 0)], + ) + # A cursor belongs to one search and one sort. + for params in ( + {"sort": "name-desc", "search": "A"}, + {"sort": "name", "search": "B"}, + {"sort": "created", "search": "A"}, + ): + response, result = await self.get( + "tags", limit=1, cursor=first["next_cursor"], **params + ) + self.assertEqual(response.status, 400, params) + self.assertIn("refresh", result["error"]) + for params in ({"sort": "count"}, {"search": "x" * 201}): + response, result = await self.get("tags", **params) + self.assertEqual(response.status, 400, params) + self.assertEqual(result["success"], False) + async def test_read_failures_keep_the_read_envelope(self): with patch.object( self.api.db, diff --git a/tests/test_bulk_library_delete.py b/tests/test_bulk_library_delete.py index 26c4cece..35b9abe6 100644 --- a/tests/test_bulk_library_delete.py +++ b/tests/test_bulk_library_delete.py @@ -11,6 +11,7 @@ from pathlib import Path from database.execution_history_schema import rotate_history_epoch from database.library_commands import LibraryCommandError +from database.library_folders import folder_prefixes from database.operations import PromptDatabase from database.prompt_archive import TABLE as ARCHIVE from utils.hashing import generate_prompt_hash @@ -393,6 +394,46 @@ class TestArchivedReaders(LibraryDeleteCase): with self.assertRaises(ValueError): self.db.list_library(shelf="all") + def test_the_444_filters_and_tag_counts_hide_it(self): + # #444's folder filter, tag match and tag ranking keep #443's shelves. + bare, _bn, _bp = self.paired("Bare cat", "bare blur") + self.apply([bare]) + folder = self.root / "out" / "cats" + folder.mkdir(parents=True) + for pid, name in ((self.kept, "kept.png"), (self.gone, "gone.png")): + self.image(pid, str(Path("out") / "cats" / name)) + prefixes = folder_prefixes([str(self.root / "out")], "cats") + self.assertEqual( + self.ids_of(self.db.list_library(folder_prefixes=prefixes)), [self.kept] + ) + self.assertEqual( + self.ids_of( + self.db.list_library(folder_prefixes=prefixes, shelf="archived") + ), + [self.gone], + ) + self.assertEqual( + self.ids_of(self.db.list_library(tags=["k", "g"], tag_mode="any")), + [self.kept], + ) + self.assertEqual(self.ids_of(self.db.list_library(tag_mode="untagged")), []) + self.assertEqual( + self.ids_of(self.db.list_library(tag_mode="untagged", shelf="archived")), + [bare], + ) + tags = self.db.list_vocabulary_tags(sort="prompts") + self.assertEqual( + {row["name"]: row["prompt_count"] for row in tags["tags"]}, + {"k": 1, "g": 0}, + ) + self.assertEqual(tags["untagged_count"], 0) + self.assertEqual( + self.db.list_vocabulary_tags(search="g", sort="prompts")["tags"][0][ + "prompt_count" + ], + 0, + ) + def test_detail_and_options(self): self.assertTrue(self.db.get_library_positive(self.gone)["positive"]["archived"]) self.assertFalse( diff --git a/tests/test_library_folders.py b/tests/test_library_folders.py new file mode 100644 index 00000000..8a5913d0 --- /dev/null +++ b/tests/test_library_folders.py @@ -0,0 +1,205 @@ +"""#444: the Library's output-folder filter and multi-tag OR.""" + +import ntpath +import os +import posixpath +import tempfile +import unittest +from pathlib import Path + +from database.library_folders import ( + folder_prefixes, + normalize_folder, + relative_folder, +) +from database.operations import PromptDatabase +from utils.hashing import generate_prompt_hash + + +class TestFolderNames(unittest.TestCase): + def test_normalize_reads_backslashes_and_strips_outer_slashes(self): + self.assertEqual(normalize_folder(""), "") + self.assertEqual(normalize_folder("/"), "") + self.assertEqual(normalize_folder("2026\\08-Aug\\"), "2026/08-Aug") + self.assertEqual(normalize_folder("/a/b/"), "a/b") + self.assertEqual(normalize_folder("..foo/b"), "..foo/b") + + def test_normalize_refuses_anything_but_a_folder_below_a_root(self): + for value in ("a/../b", "./a", "a//b", "C:/x", "c:", "a\x00b", 3, "x" * 1025): + with self.subTest(value=value), self.assertRaises(ValueError): + normalize_folder(value) + + def test_posix_prefixes_are_exact_and_end_in_a_separator(self): + self.assertEqual( + folder_prefixes(["/out", "/data/out/"], "a/b", posixpath), + ["/out/a/b/", "/data/out/a/b/"], + ) + self.assertEqual(folder_prefixes(["/"], "a", posixpath), ["/a/"]) + self.assertEqual( + folder_prefixes(["/out", "/out/"], "a", posixpath), ["/out/a/"] + ) + with self.assertRaises(ValueError): + folder_prefixes([], "a", posixpath) + + def test_windows_prefixes_fold_case_including_non_ascii(self): + self.assertEqual( + folder_prefixes(["C:\\ComfyUI\\Output"], "Ä/Sub", ntpath), + ["c:\\comfyui\\output\\ä\\sub\\"], + ) + self.assertEqual(folder_prefixes(["D:\\"], "x", ntpath), ["d:\\x\\"]) + self.assertEqual( + folder_prefixes(["\\\\Server\\Share\\Out"], "a", ntpath), + ["\\\\server\\share\\out\\a\\"], + ) + # The stored file_path is normcase(normpath(abspath)), the same fold. + stored = ntpath.normcase(ntpath.normpath("C:/ComfyUI/output/Ä/sub/x.png")) + self.assertTrue( + stored.startswith( + folder_prefixes(["C:/ComfyUI/Output"], "ä/SUB", ntpath)[0] + ) + ) + + def test_relative_folder_keeps_the_stored_spelling(self): + self.assertEqual( + relative_folder("/out/2026/Aug/x.png", ["/out"], posixpath), "2026/Aug" + ) + self.assertIsNone(relative_folder("/out/x.png", ["/out"], posixpath)) + self.assertIsNone(relative_folder("/outside/x.png", ["/out"], posixpath)) + self.assertIsNone(relative_folder("/output2/a/x.png", ["/out"], posixpath)) + self.assertEqual( + relative_folder("/out/..foo/x.png", ["/out"], posixpath), "..foo" + ) + self.assertEqual( + relative_folder("C:\\Out\\Ä\\Sub\\x.png", ["c:\\out"], ntpath), "Ä/Sub" + ) + self.assertEqual(relative_folder("D:\\a\\x.png", ["D:\\"], ntpath), "a") + + +class TestLibraryFolderAndAnyTag(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + base = Path(os.path.realpath(self.temp.name)) + self.root = base / "output" + self.root.mkdir() + self.db = PromptDatabase(str(base / "folders.db")) + + def tearDown(self): + try: + self.db.close_all() + finally: + self.temp.cleanup() + + def save(self, text, **metadata): + return self.db.save_prompt( + text, prompt_hash=generate_prompt_hash(text), **metadata + ) + + def link(self, prompt_id, *parts): + path = self.root.joinpath(*parts) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x") + self.db.link_image_to_prompt(prompt_id, str(path)) + + def texts(self, **options): + return sorted(item["text"] for item in self.db.list_library(**options)["items"]) + + def in_folder(self, folder, roots=None): + return self.texts( + folder_prefixes=folder_prefixes(roots or [str(self.root)], folder) + ) + + def test_folder_matches_its_descendants_only(self): + self.link(self.save("deep"), "a", "b", "x.png") + self.link(self.save("top"), "a", "y.png") + self.link(self.save("sibling"), "ab", "z.png") + self.link(self.save("literal"), "100%_off", "w.png") + self.save("no images") + self.assertEqual(self.in_folder("a"), ["deep", "top"]) + self.assertEqual(self.in_folder("a/b"), ["deep"]) + self.assertEqual(self.in_folder("ab"), ["sibling"]) + self.assertEqual(self.in_folder("100%_off"), ["literal"]) + self.assertEqual(self.in_folder("1"), []) + cut = self.db.list_library( + folder_prefixes=folder_prefixes([str(self.root)], "a"), limit=1 + ) + self.assertEqual(cut["total"], 2) + + def test_folder_combines_with_text_category_and_tags(self): + self.link(self.save("red cat", category="Art", tags=["cat"]), "a", "1.png") + self.link(self.save("red dog", category="Art", tags=["dog"]), "a", "2.png") + self.link(self.save("red bird", category="Art", tags=["cat"]), "b", "3.png") + prefixes = folder_prefixes([str(self.root)], "a") + self.assertEqual( + self.texts(folder_prefixes=prefixes, tags=["cat"]), ["red cat"] + ) + self.assertEqual( + self.texts(folder_prefixes=prefixes, text="dog", category="Art"), + ["red dog"], + ) + + @unittest.skipIf(os.name == "nt", "symlinks need privileges on Windows") + def test_folder_listing_and_match_through_a_symlinked_root(self): + link = self.root.parent / "linked" + os.symlink(self.root, link) + prompt = self.save("via link") + (self.root / "a").mkdir() + (self.root / "a" / "x.png").write_bytes(b"x") + self.db.link_image_to_prompt(prompt, str(link / "a" / "x.png")) + spellings = [str(self.root), str(link)] + self.assertEqual(self.in_folder("a", spellings), ["via link"]) + self.assertEqual(self.in_folder("a", [str(self.root)]), []) + self.assertEqual(self.db.get_library_options(spellings)["folders"], ["a"]) + + def test_folder_listing_offers_ancestors_and_skips_outside_images(self): + self.link(self.save("deep"), "2026", "08-Aug", "x.png") + self.link(self.save("root image"), "y.png") + outside = Path(self.temp.name) / "elsewhere" / "z.png" + outside.parent.mkdir() + outside.write_bytes(b"x") + self.db.link_image_to_prompt(self.save("outside"), str(outside)) + options = self.db.get_library_options([str(self.root)]) + self.assertEqual(options["folders"], ["2026", "2026/08-Aug"]) + self.assertNotIn("folders", self.db.get_library_options()) + + def test_folder_combines_with_untagged(self): + self.link(self.save("plain in a"), "a", "1.png") + self.link(self.save("tagged in a", tags=["t"]), "a", "2.png") + self.link(self.save("plain in b"), "b", "3.png") + prefixes = folder_prefixes([str(self.root)], "a") + self.assertEqual( + self.texts(folder_prefixes=prefixes, tag_mode="untagged"), ["plain in a"] + ) + + def test_any_tag_mode_is_the_union_across_parent_and_pair(self): + self.save("parent a", tags=["a"]) + self.save("parent b", tags=["b"]) + self.save("both", tags=["a", "b"]) + self.save("neither", tags=["c"]) + self.db.save_prompt_pair("pair b", "noise", tags=["b"]) + split = self.save("split", tags=["a"]) + self.db.save_prompt_pair("split", "split noise", tags=["b"]) + archived = self.db.save_prompt_pair("archived b", "old", tags=["b"]) + self.db.archive_prompt_pair(archived) + self.assertIsInstance(split, int) + self.assertEqual( + self.texts(tags=["a", "b"], tag_mode="any"), + ["both", "pair b", "parent a", "parent b", "split"], + ) + self.assertEqual(self.texts(tags=["a", "b"], tag_mode="all"), ["both"]) + self.assertEqual(self.texts(tags=["a", "b"]), ["both"]) + for tags in (["a"], ["b"], ["c"]): + self.assertEqual( + self.texts(tags=tags, tag_mode="any"), self.texts(tags=tags) + ) + self.assertEqual( + self.texts(tags=["a", "b"], tag_mode="any", text="parent"), + ["parent a", "parent b"], + ) + self.assertEqual(self.texts(tag_mode="any"), self.texts()) + for mode in ("none", None, "ANY"): + with self.subTest(mode=mode), self.assertRaises(ValueError): + self.db.list_library(tag_mode=mode) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_saved_views.py b/tests/test_saved_views.py index 662e7573..010c64cb 100644 --- a/tests/test_saved_views.py +++ b/tests/test_saved_views.py @@ -42,7 +42,14 @@ IMAGES_STATE = { "favorites": False, "collection": "", } -LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []} +LIBRARY_STATE = { + "q": "", + "category": "", + "sort": "rating", + "tags": [], + "tag_mode": "untagged", + "folder": "2026/Aug", +} IMAGES_DEFAULTS = { "q": "", "category": "", @@ -54,7 +61,14 @@ IMAGES_DEFAULTS = { "favorites": False, "collection": "", } -LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []} +LIBRARY_DEFAULTS = { + "q": "", + "category": "", + "sort": "newest", + "tags": [], + "tag_mode": "all", + "folder": "", +} # A row saved by an older build: no favorites key, a retired key, a sort # that is no longer offered and an over-long folder. OLD_IMAGES_STATE = json.dumps( @@ -228,9 +242,17 @@ class TestSavedViewsApi(ImageAPITestCase): favorite = await self.create(state={"favorites": True}) self.assertIs(favorite["state"]["favorites"], True) for sort in ("newest", "oldest", "name", "rating"): - await self.create(page="library", state={"sort": sort, "tags": ["a b"]}) + await self.create( + page="library", + state={"sort": sort, "tags": ["a b"], "tag_mode": "all", "folder": ""}, + ) + for mode in ("untagged", "any"): + view = await self.create( + page="library", state={"tags": [], "tag_mode": mode} + ) + self.assertEqual(view["state"]["tag_mode"], mode) _, listed = await self.send("GET") - self.assertEqual(len(listed["views"]), 20) + self.assertEqual(len(listed["views"]), 22) async def test_write_requires_the_collection_key(self): # #331 D140: a view saved on a collection page keeps the collection id. @@ -675,6 +697,32 @@ class TestSavedViewsSchema(unittest.TestCase): ) self.assertEqual(normalize_state("history", {}), {}) + def test_library_tag_mode_and_folder_are_kept_with_defaults_for_old_rows(self): + # A row from before #444 has neither key. + old = {"q": "", "category": "", "sort": "rating", "tags": ["a"]} + self.assertEqual(normalize_state("library", old), {**LIBRARY_DEFAULTS, **old}) + self.assertEqual( + normalize_state("library", {"tag_mode": "or", "folder": 3}), + LIBRARY_DEFAULTS, + ) + for mode in ("all", "any", "untagged"): + self.assertEqual( + normalize_state("library", {"tag_mode": mode})["tag_mode"], mode + ) + with self.assertRaises(SavedViewInvalid): + encode_state("library", {**LIBRARY_STATE, "tag_mode": "or"}) + with self.assertRaises(SavedViewInvalid): + encode_state("library", {**LIBRARY_STATE, "folder": "x" * 1025}) + + def test_library_untagged_is_never_paired_with_tags(self): + # Never written by this build: the tags win, as the listing refuses both. + self.assertEqual( + normalize_state("library", {"tags": ["a"], "tag_mode": "untagged"}), + {**LIBRARY_DEFAULTS, "tags": ["a"]}, + ) + with self.assertRaises(SavedViewInvalid): + encode_state("library", {**LIBRARY_STATE, "tags": ["a"]}) + def test_validation_errors_are_saved_view_invalid(self): for call in ( lambda: validate_name(""), diff --git a/tests/test_vocabulary.py b/tests/test_vocabulary.py index 928619be..d3036371 100644 --- a/tests/test_vocabulary.py +++ b/tests/test_vocabulary.py @@ -364,6 +364,162 @@ class TestVocabularyReads(unittest.TestCase): self.assertTrue(conn.in_transaction) +class _ReadFixture(unittest.TestCase): + """TestVocabularyReads' database and helpers, without re-running its tests.""" + + setUp = TestVocabularyReads.setUp + tearDown = TestVocabularyReads.tearDown + save = TestVocabularyReads.save + read = TestVocabularyReads.read + add_unused = TestVocabularyReads.add_unused + all_tags = TestVocabularyReads.all_tags + + +class TestVocabularyTagSearchAndSort(_ReadFixture): + """#444: tag search, tag sort and the untagged prompts count.""" + + def names(self, **options): + rows, after = [], None + while True: + page = self.read("list_vocabulary_tags", limit=2, after=after, **options) + rows.extend(row["name"] for row in page["tags"]) + if not page["has_more"]: + return rows + after = page["next_key"] + + def seed_counts(self): + self.save("One", tags=["beta", "Alpha"]) + self.save("Two", tags=["beta", "gamma"]) + self.save("Three", tags=["beta"]) + self.add_unused("delta", "100%_off", "100x-off") + + def test_search_is_a_case_insensitive_literal_substring(self): + self.seed_counts() + self.assertEqual(self.names(search="ALP"), ["Alpha"]) + self.assertEqual(self.names(search="%"), ["100%_off"]) + self.assertEqual(self.names(search="_"), ["100%_off"]) + self.assertEqual( + self.names(search=" a "), ["beta", "Alpha", "gamma", "delta"] + ) + page = self.read("list_vocabulary_tags", search="a") + self.assertEqual(page["total"], 4) + self.assertEqual(self.read("list_vocabulary_tags", search="")["total"], 6) + + def test_each_sort_orders_rows_and_continues_without_gaps(self): + self.seed_counts() + self.assertEqual( + self.names(), ["beta", "Alpha", "gamma", "delta", "100%_off", "100x-off"] + ) + self.assertEqual(self.names(sort="created"), self.names()) + self.assertEqual( + self.names(sort="name"), + ["100%_off", "100x-off", "Alpha", "beta", "delta", "gamma"], + ) + self.assertEqual( + self.names(sort="name-desc"), + ["gamma", "delta", "beta", "Alpha", "100x-off", "100%_off"], + ) + # Ties keep creation order: Alpha and gamma (1), then the unused tags (0). + self.assertEqual( + self.names(sort="prompts"), + ["beta", "Alpha", "gamma", "delta", "100%_off", "100x-off"], + ) + self.assertEqual( + self.names(sort="prompts-asc"), + ["delta", "100%_off", "100x-off", "Alpha", "gamma", "beta"], + ) + self.assertEqual( + self.names(sort="name", search="a"), ["Alpha", "beta", "delta", "gamma"] + ) + + def test_count_sort_uses_the_row_counts(self): + self.save("Parent", tags=["direct"]) + self.db.save_prompt_pair("Owner", "Noise", tags=["pair"]) + self.db.save_prompt_pair("Owner two", "Noise two", tags=["pair"]) + archived = self.db.save_prompt_pair("Gone", "Gone noise", tags=["archived"]) + self.db.archive_prompt_pair(archived) + page = self.read("list_vocabulary_tags", sort="prompts") + self.assertEqual( + [(row["name"], row["prompt_count"]) for row in page["tags"]], + [("pair", 2), ("direct", 1), ("archived", 0)], + ) + + def test_invalid_search_sort_and_keys_are_refused(self): + self.seed_counts() + for options in ( + {"sort": "count"}, + {"sort": None}, + {"search": "x" * 201}, + {"search": 3}, + {"sort": "name", "after": 3}, + {"sort": "name", "after": ["a"]}, + {"sort": "prompts", "after": ["a", 1]}, + {"sort": "created", "after": ["a", 1]}, + ): + with self.assertRaises(ValueError, msg=options): + self.read("list_vocabulary_tags", **options) + + def test_untagged_count_is_every_parent_no_tag_browse_returns(self): + self.save("Plain") + self.save("Tagged", tags=["t"]) + self.db.save_prompt_pair("Pair tagged", "Noise", tags=["p"]) + self.db.save_prompt_pair("Pair plain", "Noise plain") + archived = self.db.save_prompt_pair("Archived tag", "Old", tags=["old"]) + self.db.archive_prompt_pair(archived) + self.add_unused("unused") + page = self.read("list_vocabulary_tags", search="zzz") + untagged = self.db.list_library(tag_mode="untagged") + self.assertEqual( + sorted(item["text"] for item in untagged["items"]), + ["Archived tag", "Pair plain", "Plain"], + ) + self.assertEqual(page["untagged_count"], untagged["total"]) + self.assertEqual(page["untagged_count"], 3) + tagged = set() + for row in self.all_tags(): + tagged |= { + item["id"] for item in self.db.list_library(tags=[row["name"]])["items"] + } + everyone = {item["id"] for item in self.db.list_library()["items"]} + self.assertEqual({item["id"] for item in untagged["items"]}, everyone - tagged) + + +class TestLibraryTagMode(_ReadFixture): + def test_untagged_mode_combines_with_text_and_category(self): + self.save("Red plain", category="Colors") + self.save("Blue plain") + self.save("Red tagged", category="Colors", tags=["red"]) + self.assertEqual( + [ + i["text"] + for i in self.db.list_library(text="red", tag_mode="untagged")["items"] + ], + ["Red plain"], + ) + self.assertEqual( + [ + i["text"] + for i in self.db.list_library(category="Colors", tag_mode="untagged")[ + "items" + ] + ], + ["Red plain"], + ) + self.assertEqual( + self.db.list_library(tag_mode="all")["total"], + self.db.list_library()["total"], + ) + + def test_untagged_mode_refuses_tags_and_unknown_modes(self): + for options in ( + {"tag_mode": "untagged", "tags": ["x"]}, + {"tag_mode": "none"}, + {"tag_mode": None}, + ): + with self.assertRaises(ValueError, msg=options): + self.db.list_library(**options) + + _TAG_TABLES = ('"tags"', '"prompt_tags"', '"prompt_pair_tags"', '"sqlite_sequence"') diff --git a/web/js/gallery-settings.js b/web/js/gallery-settings.js index 1fcb3dbd..552f6c6a 100644 --- a/web/js/gallery-settings.js +++ b/web/js/gallery-settings.js @@ -17,6 +17,7 @@ // The workspace Images defaults; the legacy gallery's defaultViewMode has no masonry. const WORKSPACE_LAYOUTS = ["grid", "list", "masonry"]; const WORKSPACE_DENSITIES = ["comfortable", "large", "compact"]; + const VIEWER_OPEN_MODES = ["centered", "fullscreen"]; const LIMIT_OPTIONS = [50, 100, 200, 500]; const MIN_COLUMNS = 2; const MAX_COLUMNS = 12; @@ -43,6 +44,8 @@ // #451: Images opens in Masonry unless this browser chose another layout. workspaceLayout: "masonry", workspaceDensity: "comfortable", + // #450 (D156): how the workspace viewer opens. + viewerOpenMode: "centered", }); function toInt(value) { @@ -83,6 +86,7 @@ videoLoop: bool(merged.videoLoop, DEFAULT_SETTINGS.videoLoop), workspaceLayout: WORKSPACE_LAYOUTS.includes(merged.workspaceLayout) ? merged.workspaceLayout : DEFAULT_SETTINGS.workspaceLayout, workspaceDensity: WORKSPACE_DENSITIES.includes(merged.workspaceDensity) ? merged.workspaceDensity : DEFAULT_SETTINGS.workspaceDensity, + viewerOpenMode: VIEWER_OPEN_MODES.includes(merged.viewerOpenMode) ? merged.viewerOpenMode : DEFAULT_SETTINGS.viewerOpenMode, }; } @@ -92,7 +96,7 @@ return `repeat(${n}, minmax(0, 1fr))`; } - const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, normalizeGallerySettings, gridColumnsStyle }; + const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, VIEWER_OPEN_MODES, normalizeGallerySettings, gridColumnsStyle }; if (typeof module !== "undefined" && module.exports) module.exports = api; else root.GallerySettings = api; })(typeof window !== "undefined" ? window : globalThis); diff --git a/web/js/workspace/icons.mjs b/web/js/workspace/icons.mjs index 5ce605a3..52fdabaa 100644 --- a/web/js/workspace/icons.mjs +++ b/web/js/workspace/icons.mjs @@ -21,7 +21,10 @@ const paths = Object.freeze({ "tagImage": "M3 4h8l9 9-7 7L3 10V4ZM7 8h.01M18 2v5M15.5 4.5h5", "folder": "M3 5h7l2 3h9v12H3z", "history": "M3 11a9 9 0 1 1 2 7M3 4v7h7M12 7v6l4 2", - "review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5" + "review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5", + // #450 (D156): the viewer's Full screen / Centered switch. + "maximize": "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5", + "minimize": "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" }); /** Create an approved decorative outline icon without injecting markup. */ diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index c5d3e531..d7333926 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -12,6 +12,13 @@ function parameters(filters,limit) { for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim()); const sort=filters.sort || 'newest'; if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort'); + // #444: how Tags match (every tag, any of them, or parents no tag's Browse returns, which never goes with tags) and + // the output folder below a root. + const tagMode=filters.tagMode ?? 'all',folder=filters.folder ?? ''; + if(!LIBRARY_TAG_MODES.includes(tagMode) || (tagMode==='untagged' && query.has('tag'))) throw new TypeError('Invalid library tag mode'); + if(typeof folder!=='string' || folder.length>1024) throw new TypeError('Invalid library folder'); + if(tagMode!=='all') query.set('tag_mode',tagMode); + if(folder) query.set('folder',folder); query.set('sort',sort); // #443 D152: the archived shelf; the default shelf sends nothing, as before. const shelf=filters.shelf || 'active'; @@ -19,8 +26,13 @@ function parameters(filters,limit) { if(shelf==='archived') query.set('shelf','archived'); return query; } +/** The Library's tag match values (#444); the first is the default, sent as no parameter. */ +export const LIBRARY_TAG_MODES=Object.freeze(['all','any','untagged']); // `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants"). -function validatePage(page,cursor) { +function validatePage(page,cursor,query) { + // A tag match or folder the server did not echo was not applied (an older server): never show the whole shelf as filtered. + if(query?.has('tag_mode') && page?.tag_mode!==query.get('tag_mode')) throw new TypeError('Unconfirmed library tag mode'); + if(query?.has('folder') && page?.folder!==query.get('folder')) throw new TypeError('Unconfirmed library folder'); 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}) { @@ -42,7 +54,7 @@ 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); + validatePage(page,cursor,query); // #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; diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 37a2f1b2..f36dff03 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -2,7 +2,7 @@ import {createScope,createButton} from '../shared/toolkit/core.js'; import {createPromptSender} from './prompt-sender.mjs'; import {mountPromptBlocks} from './prompt-inspector.mjs'; import {mountImages} from './images.mjs'; -import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs'; +import {createLibraryStream,createLibraryDetail,libraryPromptRoles,LIBRARY_TAG_MODES} from './library-state.mjs'; import {promptRoles} from './prompt-blocks.mjs'; import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs'; import {createLibraryEditorController} from './library-editor-state.mjs'; @@ -32,6 +32,16 @@ const metadataDrafts=new Map(); const archivedVisible=new Map(); // The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated". const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]); +// Refine › Tag match (#444 D149, D150), in LIBRARY_TAG_MODES order. +const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]); +/** + * Refine › Folder options (#444 D150): the concept's "All output folders" first, then each folder indented by its depth + * with its last segment as the label (the legacy admin select), and the current folder kept even when it is not listed. + */ +export function libraryFolderOptions(folders,current='') { + const listed=[...folders];if(current && !listed.includes(current)) listed.push(current); + return [['','All output folders'],...listed.map(folder=>{const parts=folder.split('/');return [folder,`${'\u00a0\u00a0'.repeat(parts.length-1)}${parts.at(-1)}`];})]; +} // 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.'; @@ -84,9 +94,9 @@ export function onLibraryWrite(listener) { * with the existing Library filters, and the heading takes focus as after a save. */ export function browseLibrary(filters) { - const {text='',category='',tags=[],sort='newest'}=filters || {}; - if(typeof text!=='string' || typeof category!=='string' || !Array.isArray(tags) || tags.some(tag=>typeof tag!=='string') || typeof sort!=='string') throw new TypeError('Invalid Library filters'); - shelfVisit={filters:{text,category,tags:[...tags],sort},anchor:null}; + const {text='',category='',tags=[],sort='newest',tagMode='all',folder=''}=filters || {}; + if(typeof text!=='string' || typeof category!=='string' || !Array.isArray(tags) || tags.some(tag=>typeof tag!=='string') || typeof sort!=='string' || !LIBRARY_TAG_MODES.includes(tagMode) || typeof folder!=='string') throw new TypeError('Invalid Library filters'); + shelfVisit={filters:{text,category,tags:[...tags],sort,tagMode,folder},anchor:null}; focusHeadingOnMount=true;window.location.hash='#/library'; } /** Remember the selection a reconciled save names, so the destination history opens on it once. */ @@ -162,15 +172,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on // An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again. const restorable=visit?.state?visit:null; // #443 D152: `shelf` ('active' or 'archived', Show archived prompts) lives in the visit's filters, never in a saved view. - const filters={text:'',category:'',tags:[],sort:'newest',shelf:'active',...visit?.filters,...saved}; + const filters={text:'',category:'',tags:[],sort:'newest',tagMode:'all',folder:'',shelf:'active',...visit?.filters,...saved}; // D153: a sort the shelf no longer offers (an older "Highest rated" visit) reads as Recently added. if(!LIBRARY_SORTS.some(([value])=>value===filters.sort)) filters.sort='newest'; const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library'); // The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15). // Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY. const toolbarScope=createScope(); - // D44: Filters reads "Filters · 1" while a Tags filter (from Refine or a Browse) narrows the shelf. - const refinements=()=>filters.tags.length?1:0; + // D44: Filters reads "Filters · N" while Refine filters (from Refine or a Browse) narrow the shelf: Tags, a Folder + // and a Tag match other than All (#444 D149, D150) each count once. + const refinements=()=>(filters.tags.length?1:0)+(filters.folder?1:0)+(filters.tagMode!=='all'?1:0); const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS, value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters, onSaveView:trigger=>{const value=toolbar.value();onSaveView(trigger,libraryViewState({...filters,text:value.q.trim(),category:value.category,sort:value.sort}));}}); @@ -225,7 +236,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on // 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,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; + let state,frame=0,loadFrame=0,observer,optionsRequest,folderRequest,focusOverride=-1; const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2; const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64; let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map(); @@ -407,7 +418,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on } const archivedLabel=next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`; if(showArchived.textContent!==archivedLabel) showArchived.textContent=archivedLabel; - const filtered=Boolean(filters.text || filters.category || filters.tags.length); + const filtered=Boolean(filters.text || filters.category || filters.tags.length || filters.folder || filters.tagMode!=='all'); const empty=filters.shelf==='archived'?(filtered?'No archived prompts match.':'No archived prompts.'):'No matching prompts. Try changing the filters.'; const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':''; message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?empty:!next.hasMore?'You’re all caught up.':'')+hidden; @@ -438,15 +449,26 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on if(Object.entries(next).every(([key,item])=>filters[key]===item)) return; Object.assign(filters,next);reset(); } - /** "Refine this view" with the Library's one extra filter, Tags (D14); Clear also clears the toolbar category. */ + /** + * "Refine this view" with the Library's filters: the concept's Folder (#444 D150, its options read when the dialog + * opens), Tags (D14) and Tag match (D149, D150); Clear also clears the toolbar category. + */ function openFilters(trigger) { - openRefineDialog({scope,trigger,fields:[{key:'tags'}],values:{tags:filters.tags.join(', ')},onApply:applyRefinement, - onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}}); + const refine=openRefineDialog({scope,trigger,fields:[{key:'folder',options:libraryFolderOptions([],filters.folder)},{key:'tags'}, + {key:'tagMode',options:TAG_MATCH_OPTIONS}],values:{folder:filters.folder,tags:filters.tags.join(', '),tagMode:filters.tagMode}, + onApply:applyRefinement,onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}}); + folderRequest?.abort();const request=folderRequest=new AbortController(); + // A failed read leaves "All output folders" and the current folder; nothing else depends on it. + api.get('/prompt_manager/library/options?include=folders',{signal:request.signal}).then(data=>{ + if(scope.signal.aborted || folderRequest!==request || !Array.isArray(data?.folders) || data.folders.some(folder=>typeof folder!=='string')) return; + refine.setOptions('folder',libraryFolderOptions(data.folders,filters.folder)); + },()=>{}); } - function applyRefinement({tags,category=filters.category}) { - const next=[...new Set(tags.split(',').map(tag=>tag.trim()).filter(Boolean))]; - if(category===filters.category && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return; - filters.category=category;filters.tags=next;reset(); + function applyRefinement({tags,tagMode='all',folder='',category=filters.category}) { + // "Prompts without tags" takes no Tags (the dialog disables them). + const next=tagMode==='untagged'?[]:[...new Set(tags.split(',').map(tag=>tag.trim()).filter(Boolean))]; + if(category===filters.category && tagMode===filters.tagMode && folder===filters.folder && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return; + filters.category=category;filters.tags=next;filters.tagMode=tagMode;filters.folder=folder;reset(); } scope.listen(more,'click',()=>stream.loadMore()); // Retry also re-reads a category list that failed (D15: Refresh is gone). @@ -459,7 +481,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on // A write while mounted makes this stream stale: keep only what still means something. // A saved view is not the plain shelf, so it never replaces the Library return state (#249 M2, as Favorites). if(!saved) shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null; - toolbarScope.dispose();stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); + toolbarScope.dispose();stream.dispose();optionsRequest?.abort();folderRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset(); } diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index da424337..cda26c2c 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -3,6 +3,7 @@ import {createViewerSession} from './viewer-state.mjs'; import {rootedFileAdapter} from './inspection-state.mjs'; import {mountFileInspector} from './file-inspector.mjs'; import {displayPreferences, readPreferences} from './preferences.mjs'; +import {createWorkspaceIcon} from './icons.mjs'; let viewerId = 0; // Upper bound on waiting for a closed Viewer's in-flight image view before destroying it. @@ -56,7 +57,10 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle const heading = element('div'), title = element('h2');title.id = 'viewer-title'; heading.append(title,element('p','muted small','One viewer · image, prompts, generation data, workflow')); const close = createButton('×');close.id = 'viewer-close';close.setAttribute('aria-label','Close image viewer');close.autofocus = true; - header.append(heading,close); + // #450 (D156): the view switch before ✕ names the view it switches to; the stored default picks the first view. + let fullscreen = displayPreferences()?.viewerOpenMode === 'fullscreen'; + const mode = createButton('','icon-btn');mode.id = 'viewer-mode'; + header.append(heading,mode,close); const body = element('div','viewer-layout'), left = element('div','viewer-left'); const stage = element('div');stage.id = 'viewer-stage';stage.tabIndex = 0; stage.setAttribute('role','region');stage.setAttribute('aria-label','Media preview'); @@ -80,27 +84,36 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle controls.append(download); const next = button('Next media','Next →',() => session.navigate(1)); // D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle. D155 - // (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing. + // (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing. D156 (#450): + // the toggle is also there while the view is full screen. const collapsible = displayPreferences()?.sidebarCollapsedByDefault === true, collapsed = collapsible && detailsMode !== 'shown'; - let detailsToggle = null; - if (collapsible) { - detailsToggle = createButton(collapsed ? 'Show details' : 'Hide details');detailsToggle.setAttribute('aria-expanded',String(!collapsed)); - detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle); - } + const detailsToggle = createButton('');detailsToggle.setAttribute('aria-controls','viewer-details'); const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`; position.setAttribute('role','status'); const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`; const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`; controls.append(position,reason,slideshowReason);left.append(stage,controls); const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata'); - if (detailsToggle) { - details.hidden = collapsed; - local.listen(detailsToggle,'click',() => { - details.hidden = !details.hidden; - detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details'; - detailsToggle.setAttribute('aria-expanded',String(!details.hidden)); - }); + details.hidden = collapsed; + function paintToggle() { + detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details'; + detailsToggle.setAttribute('aria-expanded',String(!details.hidden)); } + /** The toggle is in the toolbar only in full screen or with the collapse setting; without both the details show. */ + function syncToggle() { + if (collapsible || fullscreen) { if (!detailsToggle.isConnected) controls.insertBefore(detailsToggle,position); } + else { detailsToggle.remove();details.hidden = false; } + paintToggle(); + } + function paintMode() { + if (fullscreen) dialog.classList.add('viewer-fullscreen'); else dialog.classList.remove('viewer-fullscreen'); + const label = fullscreen ? 'Centered view' : 'Full screen view'; + mode.setAttribute('aria-label',label);mode.title = label; + mode.replaceChildren(createWorkspaceIcon(fullscreen ? 'minimize' : 'maximize')); + } + local.listen(detailsToggle,'click',() => {details.hidden = !details.hidden;paintToggle();}); + local.listen(mode,'click',() => {fullscreen = !fullscreen;paintMode();syncToggle();}); + paintMode();syncToggle(); body.append(left,details);dialog.append(header,body);container.append(dialog); 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(), diff --git a/web/js/workspace/preferences.mjs b/web/js/workspace/preferences.mjs index c964d846..1c420389 100644 --- a/web/js/workspace/preferences.mjs +++ b/web/js/workspace/preferences.mjs @@ -21,7 +21,7 @@ */ export const PREFERENCES_KEY = 'gallerySettings'; export const WORKSPACE_PREFERENCE_KEYS = Object.freeze(['workspaceLayout', 'workspaceDensity', 'autoLoadMetadata', 'cacheMetadata', - 'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault']); + 'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault', 'viewerOpenMode']); // The changes this tab could not store, overlaid on every read until storage accepts them. const tabOnly = new Map(); diff --git a/web/js/workspace/saved-views.mjs b/web/js/workspace/saved-views.mjs index e58fa94f..4711c332 100644 --- a/web/js/workspace/saved-views.mjs +++ b/web/js/workspace/saved-views.mjs @@ -47,6 +47,9 @@ const FIELDS = Object.freeze({ }), library: Object.freeze({ q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags, + // #444: how the Tags match ('untagged' never goes with tags; the listing refuses both) and the output folder + // ('/'-joined, below a root). + tag_mode: choice(['all', 'any', 'untagged'], 'all'), folder: text(1024), }), }); @@ -63,6 +66,8 @@ export function normalizeViewState(page, state) { })); // Favorites and a collection are never both a view's filter: the collection wins, as the listing does (D140). if (normalized.favorites === true && normalized.collection) normalized.favorites = false; + // Untagged and tags are never both a view's filter either: the tags win, as the server's normalizer does. + if (normalized.tag_mode === 'untagged' && normalized.tags.length) normalized.tag_mode = 'all'; return normalized; } @@ -113,10 +118,12 @@ export function imagesViewState(filters) { /** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */ export function libraryFilters(state) { const view = normalizeViewState('library', state); - return { text: view.q, category: view.category, sort: view.sort, tags: view.tags }; + return { text: view.q, category: view.category, sort: view.sort, tags: view.tags, tagMode: view.tag_mode, folder: view.folder }; } export function libraryViewState(filters) { - return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags }); + return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags, + // A filter the shelf lacks (undefined) takes its key's default in normalizeViewState. + tag_mode: filters.tagMode, folder: filters.folder }); } /** diff --git a/web/js/workspace/system-appearance.mjs b/web/js/workspace/system-appearance.mjs index 2f6210e7..13b59eed 100644 --- a/web/js/workspace/system-appearance.mjs +++ b/web/js/workspace/system-appearance.mjs @@ -13,7 +13,10 @@ * (`showImageInfo`), "Collapse viewer details by default" (`sidebarCollapsedByDefault`) and the operation "Reset * display defaults", which drafts the gallery's defaults for these browser rows and touches no other key. * - * - #317: a second surface, "Thumbnails", holds the server's thumbnail format, quality and size limit + * - #450 (D156, amends D97): a "Viewer" surface after it holds "Open viewer in" (Centered→`centered`, Full + * screen→`fullscreen`, `viewerOpenMode`) and the moved "Collapse viewer details by default"; "Reset display defaults" + * covers both surfaces' browser rows. + * - #317: a further surface, "Thumbnails", holds the server's thumbnail format, quality and size limit * (system-thumbnails.mjs). * * Controls follow the store: a value read later (the host settings, another tab's change) repaints them. A host row @@ -50,14 +53,20 @@ export const APPEARANCE_ROWS = Object.freeze([ { type: 'select', options: [['newtab', 'New tab'], ['popup', 'Popup window']] }], ['timeout', 'Node search auto-hide (seconds)', 'Node search behavior only. Use 0 to keep results visible.', 'host', 'result_timeout', { type: 'number' }], - // D97: the legacy gallery's card and viewer settings. + // D97: the legacy gallery's card setting (its viewer setting moved to VIEWER_ROWS, D156). ['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', 'browser', 'showImageInfo', BOOLEAN], +]); + +/** #450 (D156): the "Viewer" surface's rows, in the same shape. */ +export const VIEWER_ROWS = Object.freeze([ + ['viewer-mode', 'Open viewer in', 'Full screen fills the browser window; Centered is the framed viewer.', 'browser', 'viewerOpenMode', + { type: 'select', options: [['centered', 'Centered'], ['fullscreen', 'Full screen']] }], ['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', 'browser', 'sidebarCollapsedByDefault', BOOLEAN], ]); -/** The browser keys "Reset display defaults" restores (D97). */ -export const DISPLAY_KEYS = Object.freeze(APPEARANCE_ROWS.filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4])); +/** The browser keys "Reset display defaults" restores (D97), on both surfaces. */ +export const DISPLAY_KEYS = Object.freeze([...APPEARANCE_ROWS, ...VIEWER_ROWS].filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4])); /** A whole number of seconds from 0 to 300 (the server's `validate_result_timeout`), else null. */ export function resultTimeout(text) { @@ -104,29 +113,36 @@ function paint(input, control, value) { /** * Mount Appearance in `container` (System's `.settings-layout`); returns the section's nodes: "Browsing & display", - * then the server's thumbnail settings (#317, system-thumbnails.mjs). + * "Viewer" (D156), then the server's thumbnail settings (#317, system-thumbnails.mjs). */ export function mountAppearance(container, { scope, drafts, api, activity }) { const surface = el('section', undefined, 'surface'); surface.append(el('h2', 'Browsing & display'), el('p', 'One set of defaults; toolbar changes apply to the current view.')); const controls = []; - for (const row of APPEARANCE_ROWS) { - if (row === 'continuous') { surface.append(continuousRow()); continue; } - const [id, label, description, domain, key, control] = row; - const onChange = value => { - if (key === 'result_timeout') { - const seconds = resultTimeout(input.value); - if (seconds === null) { drafts.reject(domain, key); return; } - value = seconds; - } - drafts.set(domain, key, value); - }; - const node = settingRow({ id, label, description, control, scope, onChange }); - surface.append(node); - const input = controlOf(node, id); - input.dataset.key = key; - controls.push({ input, control, domain, key, text: node.children[0], note: null }); + function addRows(target, rows) { + for (const row of rows) { + if (row === 'continuous') { target.append(continuousRow()); continue; } + const [id, label, description, domain, key, control] = row; + const onChange = value => { + if (key === 'result_timeout') { + const seconds = resultTimeout(input.value); + if (seconds === null) { drafts.reject(domain, key); return; } + value = seconds; + } + drafts.set(domain, key, value); + }; + const node = settingRow({ id, label, description, control, scope, onChange }); + target.append(node); + const input = controlOf(node, id); + input.dataset.key = key; + controls.push({ input, control, domain, key, text: node.children[0], note: null }); + } } + addRows(surface, APPEARANCE_ROWS); + // D156: the viewer's defaults in their own surface. + const viewer = el('section', undefined, 'surface'); + viewer.append(el('h2', 'Viewer'), el('p', 'How the image viewer opens; the button beside ✕ switches the current view.')); + addRows(viewer, VIEWER_ROWS); surface.append(operationRow({ title: 'Reset display defaults', description: 'Restore this browser’s display defaults, then save. Other settings are kept.', label: 'Reset', scope, onClick: () => { @@ -146,5 +162,5 @@ export function mountAppearance(container, { scope, drafts, api, activity }) { }; scope.cleanup(drafts.subscribe(repaint)); repaint(); - return [surface, mountThumbnailSettings(container, { scope, drafts, api, activity })]; + return [surface, viewer, mountThumbnailSettings(container, { scope, drafts, api, activity })]; } diff --git a/web/js/workspace/vocabulary-state.mjs b/web/js/workspace/vocabulary-state.mjs index c86f0b30..afd3c07a 100644 --- a/web/js/workspace/vocabulary-state.mjs +++ b/web/js/workspace/vocabulary-state.mjs @@ -26,6 +26,12 @@ const UNCONFIRMED_STATUS = new Set([502, 503, 504]); // Phases that may send a command; done, storage-changed and unknown-outcome need reset(). const READY = new Set(['idle', 'previewed', 'write-error', 'conflict']); const IMPACT_FIELDS = ['positive_prompts', 'other_prompts', 'negative_variants', 'archived_negative_variants']; +/** #444 tag sorts as [value, label]: `created` (today's order) first, then the legacy tag manager's four. */ +export const TAG_SORTS = Object.freeze([['created', 'Oldest first'], ['name', 'Name A–Z'], ['name-desc', 'Name Z–A'], + ['prompts', 'Most prompts'], ['prompts-asc', 'Fewest prompts']].map(pair => Object.freeze(pair))); +// The route's cap on a tag search (database/vocabulary.py _MAX_SEARCH). +export const TAG_SEARCH_MAX = 200; +const DEFAULT_QUERY = Object.freeze({search: '', sort: 'created'}); const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); const id = value => Number.isSafeInteger(value) && value > 0; @@ -45,9 +51,23 @@ function storageChanged() { // Duck-typed by name: this ES module cannot import the UMD api-client.js. function isApiError(error) {return error instanceof Error && error.name === 'ApiError' && Number.isInteger(error.status);} -function validatePage(page, kind, cursor) { +/** A tags query, trimmed; categories take none. Throws on anything the route would refuse. */ +function tagQuery(kind, query) { + if (query === undefined) return DEFAULT_QUERY; + requireValue(kind === 'tags' && object(query), 'Only tags take a search and sort'); + const {search = '', sort = 'created'} = query; + requireValue(typeof search === 'string' && search.trim().length <= TAG_SEARCH_MAX, 'Invalid tag search'); + requireValue(TAG_SORTS.some(([value]) => value === sort), 'Invalid tag sort'); + return Object.freeze({search: search.trim(), sort}); +} + +function validatePage(page, kind, cursor, query) { const rows = page?.[kind]; const {key, valid} = KINDS[kind]; + // A search or sort the server did not echo was not applied (an older server): never show that list as filtered. + const plain = query.search === '' && query.sort === 'created' && page?.search === undefined && page?.sort === undefined; + const applied = plain || (page?.search === query.search && page?.sort === query.sort); + requireValue(applied && optionalCount(page?.untagged_count), `Unconfirmed ${kind} search`); requireValue(object(page) && page.success === true && epochOf(page.library_epoch) && Array.isArray(rows) && rows.every(row => object(row) && valid(row) && count(row.prompt_count)) && new Set(rows.map(key)).size === rows.length && count(page.total) && typeof page.has_more === 'boolean' && @@ -57,12 +77,18 @@ function validatePage(page, kind, cursor) { } /** One bounded, append-only list of tags or categories. */ -export function createVocabularyStream({api, kind, onChange = () => {}}) { +/** + * One bounded, append-only list of tags or categories. A tags stream takes `query` (`{search, sort}`, #444) and + * `reset(query)` replaces it; `state.untaggedCount` is the last count a tags page reported (kept across resets so the + * Untagged prompts row does not flicker), `null` before any. + */ +export function createVocabularyStream({api, kind, onChange = () => {}, query: initialQuery}) { requireValue(Object.hasOwn(KINDS, kind), 'Unknown vocabulary kind'); const {key} = KINDS[kind]; - const initial = () => ({items: Object.freeze([]), total: 0, cursor: null, hasMore: true, loading: false, error: null, - epoch: null, resetRequired: false}); - let state = Object.freeze(initial()); + let query = tagQuery(kind, initialQuery); + const initial = untaggedCount => ({items: Object.freeze([]), total: 0, cursor: null, hasMore: true, loading: false, error: null, + epoch: null, resetRequired: false, query, untaggedCount}); + let state = Object.freeze(initial(null)); let request = null, disposed = false, seen = new Set(); const emit = patch => {state = Object.freeze({...state, ...patch}); onChange(state);}; @@ -70,13 +96,16 @@ export function createVocabularyStream({api, kind, onChange = () => {}}) { if (disposed || state.loading || !state.hasMore || state.resetRequired) return 0; const current = request = new AbortController(); const cursor = state.cursor; - const query = new URLSearchParams({limit: String(LIMIT)}); - if (cursor !== null) query.set('cursor', cursor); + const asked = query; + const params = new URLSearchParams({limit: String(LIMIT)}); + if (asked.search) params.set('search', asked.search); + if (asked.sort !== 'created') params.set('sort', asked.sort); + if (cursor !== null) params.set('cursor', cursor); emit({loading: true, error: null}); try { - const page = await api.get(`${BASE}/${kind}?${query}`, {signal: current.signal, cache: 'no-store'}); + const page = await api.get(`${BASE}/${kind}?${params}`, {signal: current.signal, cache: 'no-store'}); if (disposed || current !== request) return 0; - validatePage(page, kind, cursor); + validatePage(page, kind, cursor, asked); request = null; if (state.epoch !== null && page.library_epoch !== state.epoch) { emit({loading: false, error: storageChanged(), resetRequired: true}); @@ -85,7 +114,8 @@ export function createVocabularyStream({api, kind, onChange = () => {}}) { const incoming = page[kind].filter(row => !seen.has(key(row))).map(row => Object.freeze({...row})); incoming.forEach(row => seen.add(key(row))); emit({items: incoming.length ? Object.freeze([...state.items, ...incoming]) : state.items, total: page.total, - cursor: page.next_cursor, hasMore: page.has_more, loading: false, error: null, epoch: page.library_epoch}); + cursor: page.next_cursor, hasMore: page.has_more, loading: false, error: null, epoch: page.library_epoch, + untaggedCount: page.untagged_count ?? state.untaggedCount}); return incoming.length; } catch (error) { if (!disposed && current === request) {request = null; emit({loading: false, error});} @@ -96,13 +126,14 @@ export function createVocabularyStream({api, kind, onChange = () => {}}) { return { get state() {return state;}, loadMore, - /** Drop every loaded row and read again from the first page. */ - reset() { + /** Drop every loaded row and read again from the first page, with `next` as the new query when given. */ + reset(next) { + if (next !== undefined) query = tagQuery(kind, next); if (disposed) return Promise.resolve(0); request?.abort(); request = null; seen = new Set(); - emit(initial()); + emit(initial(state.untaggedCount)); return loadMore(); }, dispose() {disposed = true; request?.abort(); request = null;}, @@ -254,6 +285,11 @@ export function browseFilters(kind, value) { : {text: '', category: value, tags: [], sort: 'newest'}; } +/** Library filters for the Untagged prompts row's Browse (#444): parents that no tag's Browse returns. */ +export function untaggedBrowseFilters() { + return {text: '', category: '', tags: [], sort: 'newest', tagMode: 'untagged'}; +} + /** * A category row's count (#332 D143): "N positive prompts" when no image holds it as its own category, "N positive * prompts · M images" when both do, "M images" when only images do (stored rows, files on disk or not). diff --git a/web/js/workspace/vocabulary.mjs b/web/js/workspace/vocabulary.mjs index f5c32b54..4048196c 100644 --- a/web/js/workspace/vocabulary.mjs +++ b/web/js/workspace/vocabulary.mjs @@ -4,12 +4,18 @@ * Composition, labels and order follow the approved concept: Auto-tag images * opens the Tag image dialog for the image selected in Images (aria-disabled * without one, D115) and the Images cells count the files carrying each tag - * (#249 C2). The recorded deviation left is bounded lists with Load more. Rows - * are real vocabulary pages from vocabulary-state.mjs; Browse hands the exact - * constraint to the existing Library filters. + * (#249 C2). The recorded deviations are bounded lists with Load more and (D149, + * #444) the Tag vocabulary's Untagged prompts row and its tag search and sort, + * drawn with the concept's `.operation` and toolbar tokens. Rows are real + * vocabulary pages from vocabulary-state.mjs; Browse hands the exact constraint + * to the existing Library filters. */ import { createButton } from '../shared/toolkit/core.js'; -import { browseFilters, categoryBrowseTarget, categoryRowText, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs'; +import { TAG_SEARCH_MAX, TAG_SORTS, browseFilters, categoryBrowseTarget, categoryRowText, createVocabularyCommand, createVocabularyStream, + untaggedBrowseFilters } from './vocabulary-state.mjs'; +import { SEARCH_DELAY } from './toolbar.mjs'; +import { createWorkspaceIcon } from './icons.mjs'; +import { countOf } from './plural.mjs'; import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs'; import { createVocabularyDialog } from './vocabulary-dialog.mjs'; import { pageHeading } from './heading.mjs'; @@ -20,6 +26,9 @@ import { showWorkspaceToast } from './toast.mjs'; /** D115: why Auto-tag images (and System › Auto-tagging's Preview tagging review) only explains without a selection. */ export const NO_SELECTION = 'Select an image in Images first.'; +const NO_MATCH = 'No tags match this search.'; +// The Untagged prompts row before the first tags page reports its count. +const UNTAGGED_PENDING = 'Positive prompts without a tag'; const COPY = { tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'}, categories: {empty: 'No categories yet.', error: 'Categories could not be loaded. Nothing was changed.', more: 'Load more categories'}, @@ -75,7 +84,7 @@ function categoryRow(category) { * in view (stacked layout below 700px), pages do not chain on their own. Before any row arrives (loading, failed) the list shows no rows, so a * failed read never looks like zero counts. */ -function mountList(scope, api, kind, {content, rowsHost, renderRow, after}) { +function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query}) { const copy = COPY[kind]; const empty = el('p', copy.empty, 'empty'); const continuation = el('div', undefined, 'vocabulary-continuation'); @@ -115,6 +124,7 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after}) { const failed = Boolean(state.error); content.hidden = !rendered; empty.hidden = Boolean(rendered || state.loading || failed || state.hasMore); + empty.textContent = state.query?.search ? NO_MATCH : copy.empty; status.textContent = state.loading ? 'Loading…' : ''; status.hidden = !state.loading; alert.hidden = !failed; @@ -129,7 +139,7 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after}) { continuation.hidden = !(state.loading || failed || state.hasMore); for (const listener of listeners) listener(state); } - const stream = createVocabularyStream({api, kind, onChange: render}); + const stream = createVocabularyStream({api, kind, onChange: render, query}); scope.listen(more, 'click', () => stream.loadMore()); scope.listen(retry, 'click', () => (stream.state.resetRequired ? stream.reset() : stream.loadMore())); let observer = null; @@ -156,6 +166,38 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after}) { return {stream, itemOf: row => items.get(row), rowFor, subscribe}; } +/** + * The tag search and sort (D149): the concept toolbar's `search.toolbar` > `label.search-field` (search icon and a + * search input) and a `select`, built here because the shared toolbar owns the page-level ids. A visually hidden + * status announces the result of a changed search or sort. + */ +function tagTools() { + const root = document.createElement('search'); + root.className = 'toolbar vocabulary-tools'; + const label = el('label', undefined, 'search-field'); + label.htmlFor = 'tag-search'; + const search = document.createElement('input'); + search.id = 'tag-search'; + search.type = 'search'; + search.placeholder = 'Search tags…'; + search.maxLength = TAG_SEARCH_MAX; + search.setAttribute('aria-label', 'Search tags'); + label.append(createWorkspaceIcon('search'), search); + const sort = document.createElement('select'); + sort.id = 'tag-sort'; + sort.setAttribute('aria-label', 'Sort tags'); + for (const [value, text] of TAG_SORTS) { + const option = el('option', text); + option.value = value; + sort.append(option); + } + const status = el('span', '', 'ca-visually-hidden'); + status.setAttribute('role', 'status'); + root.append(label, sort, status); + return {root, search, sort, read: () => ({search: search.value, sort: sort.value}), + announce: text => { if (status.textContent !== text) status.textContent = text; }}; +} + /** Mount the screen into `main` below the shell's heading; `scope` owns every request, listener and observer. */ export function mountVocabulary(main, api, scope) { main.classList.add('vocabulary-main'); @@ -188,8 +230,11 @@ export function mountVocabulary(main, api, scope) { const body = el('tbody'); table.append(el('caption', 'Reusable tags'), head, body); wrap.append(table); + // D149 (#444): the parents no tag's Browse returns, as a concept `.operation` row, then the tag search and sort. + const untagged = operation('Untagged prompts', UNTAGGED_PENDING, 'Browse'); + const tools = tagTools(); tagSurface.append(el('h2', 'Tag vocabulary'), - el('p', 'Category/tag autocomplete uses this same vocabulary across nodes and dialogs.'), wrap); + el('p', 'Category/tag autocomplete uses this same vocabulary across nodes and dialogs.'), untagged.row, tools.root, wrap); const categorySurface = el('section', undefined, 'surface'); const categoryRows = el('div', undefined, 'vocabulary-categories'); @@ -200,7 +245,17 @@ export function mountVocabulary(main, api, scope) { grid.append(tagSurface, categorySurface); main.append(grid); - const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap}); + const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap, query: tools.read()}); + let announcing = false; + tags.subscribe(state => { + if (state.untaggedCount !== null) untagged.row.querySelector('p').textContent = countOf(state.untaggedCount, 'positive prompt'); + // A search or sort the person changed is announced once its first page lands (or fails, which the alert says). + // A reset first emits its empty initial state (no epoch yet), then the read: wait for the page or the failure. + if (!announcing || state.loading || (state.epoch === null && !state.error)) return; + announcing = false; + if (!state.error) tools.announce(state.total ? countOf(state.total, 'tag') : state.query.search ? NO_MATCH : COPY.tags.empty); + }); + scope.listen(untagged.button, 'click', () => browseLibrary(untaggedBrowseFilters())); const categories = mountList(scope, api, 'categories', {content: categoryRows, rowsHost: categoryRows, renderRow: categoryRow, after: categoryRows}); scope.listen(body, 'click', event => { const button = event.target.closest('button[data-action="browse"]'); @@ -217,11 +272,20 @@ export function mountVocabulary(main, api, scope) { }); let settled = Promise.resolve(); - /** Re-read both lists from page one: after any command, rows can move between pages. */ + /** Re-read both lists from page one: after any command, rows can move between pages. The tag search and sort stay. */ function reload() { settled = Promise.all([tags.stream.reset(), categories.stream.reset()]).then(() => undefined); return settled; } + /** A changed tag search or sort reads the tags from page one; the categories are not affected. */ + function searchTags() { + const next = tools.read(), current = tags.stream.state.query; + if (next.search.trim() === current.search && next.sort === current.sort) return; + announcing = true; + settled = tags.stream.reset(next).then(() => undefined); + } + scope.listen(tools.search, 'input', scope.debounce(searchTags, SEARCH_DELAY)); + scope.listen(tools.sort, 'change', searchTags); /** A guarded command for one dialog; any outcome that may have changed storage re-reads the lists and the Library shelf. */ function createCommand(onChange) { return createVocabularyCommand({api, onChange, onInvalidate: event => { diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 25a1d5ef..9b114335 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -24,7 +24,8 @@ import { createButton, createSwitch } from '../shared/toolkit/core.js'; import { noun } from './plural.mjs'; -const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags' }); +// `tagMode` (#444, D149, D150): the Library's Tag match select after Tags; its `untagged` value disables Tags. +const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags', tagMode: 'Tag match' }); const FIELD_ORDER = Object.keys(FIELD_LABELS); let shell = null; @@ -179,7 +180,7 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, requireScope(scope); requireCallbacks({ onApply, onClear }); if (!Array.isArray(fields) || fields.some(item => !FIELD_ORDER.includes(item?.key))) { - throw new TypeError('Refine fields are source, folder, media and tags'); + throw new TypeError('Refine fields are source, folder, media, tags and tagMode'); } const controls = FIELD_ORDER.flatMap(key => { const item = fields.find(candidate => candidate.key === key); @@ -199,6 +200,12 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, for (const { key, control } of controls) { if (key !== 'tags') control.addEventListener('change', () => wanted.set(key, control.value)); } + // "Prompts without tags" takes no Tags: the field is disabled (and Apply reports it empty) while it is chosen. + const tagsField = controls.find(item => item.key === 'tags'); + const tagMode = controls.find(item => item.key === 'tagMode'); + const followTagMode = () => { if (tagsField && tagMode) tagsField.control.disabled = tagMode.control.value === 'untagged'; }; + tagMode?.control.addEventListener('change', followTagMode); + followTagMode(); const source = controls.find(item => item.key === 'source'); const folder = controls.find(item => item.key === 'folder' && typeof item.optionsFor === 'function'); const followSource = (chosen = folder.control.value) => { @@ -211,7 +218,7 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, source.control.addEventListener('change', () => { followSource(); wanted.set('folder', folder.control.value); }); } const read = () => Object.fromEntries(controls.map(({ key, control }) => - [key, key === 'tags' ? control.value.trim() : control.value])); + [key, key === 'tags' ? (control.disabled ? '' : control.value.trim()) : control.value])); const clear = createButton('Clear filters'); const apply = createButton('Apply filters', 'ca-primary'); @@ -219,6 +226,7 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, // The cleared Source's folders first, so the cleared Folder value is one of them. if (source && folder) { source.control.value = source.cleared; followSource(); } for (const { control, cleared } of controls) control.value = cleared; + followTagMode(); const reset = read(); finish(); onClear({ ...reset, category: '' }); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 527f9675..de08fa2f 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -187,6 +187,13 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } /* #249 D34: under 200% page zoom the concept's 94dvh outgrows the viewport; the dialog keeps within it. */ .ca-tools #viewer-dialog { max-height: min(94dvh, 94%); /* D34 */ } #viewer-dialog .dialog-header > div { min-width: 0; } +/* #450 D156: the view switch sits before ✕ (8px apart; the header's gap is the concept's 18px), and Full screen fills the + browser window edge to edge (no outer margin), beating the concept's box, D34's max-height and the ≤700px height. + At ≤700px the viewer is already full-bleed, so the switch hides there. */ +#viewer-dialog .dialog-header > #viewer-mode { margin-inline-start: auto; margin-inline-end: -10px; } +.ca-tools #viewer-dialog.viewer-fullscreen[open] { inset: 0; width: auto; height: auto; max-width: none; max-height: none; margin: 0; border: 0; border-radius: 0; } +/* `!important` only to beat the concept's `.icon-btn { display: inline-grid !important }`. */ +@media (max-width: 700px) { #viewer-dialog .dialog-header > #viewer-mode { display: none !important; } } #viewer-title { overflow-wrap: anywhere; /* D34 */ } #viewer-dialog, #viewer-dialog::backdrop { overscroll-behavior: contain; } #viewer-details { overscroll-behavior: contain; /* D34 */ }