diff --git a/TESTING.md b/TESTING.md index 29ede7fc..1f93bf62 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6551,15 +6551,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 +"Open existing tag manager" card for multi-tag AND/OR browsing (tag search and sort +and the untagged entry moved here with D149, #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 @@ -6611,6 +6612,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" / "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|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 d91677be..ec684527 100644 --- a/database/library.py +++ b/database/library.py @@ -121,7 +121,28 @@ def _archive(value: str) -> str: return ARCHIVE_SCOPES[value] -def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list]: +# 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)" +) +# How the Tags filter matches: every tag (AND), or parents without any tag. +TAG_MODES = ("all", "untagged") + + +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 _filter( + text: str, category: str | None, tags: list[str], tag_mode: str = "all" +) -> 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): @@ -132,6 +153,7 @@ 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") + untagged = _tag_mode(tag_mode, tags) == "untagged" parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], [] if text: parent.append("(p.text LIKE ? ESCAPE '\\' OR ls.title LIKE ? ESCAPE '\\')") @@ -168,6 +190,10 @@ def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list + " AND ".join(pair) + ")" ) + 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}" return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params] @@ -251,6 +277,7 @@ class LibraryOperations: text: str = "", category: str | None = None, tags: list[str] | None = None, + tag_mode: str = "all", sort: str = "newest", limit: int = 40, after: dict | None = None, @@ -258,7 +285,7 @@ class LibraryOperations: _limit(limit) if sort not in _ORDERS: raise ValueError("Unknown library sort order") - where, params = _filter(text, category, [] if tags is None else tags) + where, params = _filter(text, category, [] if tags is None else tags, tag_mode) if after is not None: if ( not isinstance(after, dict) @@ -311,6 +338,8 @@ class LibraryOperations: "items": rows, "total": total, "variant_total": variant_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, } @@ -321,6 +350,7 @@ class LibraryOperations: text: str = "", category: str | None = None, tags: list[str] | None = None, + tag_mode: str = "all", sort: str = "newest", limit: int = 40, after: dict | None = None, @@ -333,6 +363,7 @@ class LibraryOperations: text=text, category=category, tags=tags, + tag_mode=tag_mode, sort=sort, limit=limit, after=after, diff --git a/database/saved_views.py b/database/saved_views.py index 3a4d7afa..9fad284c 100644 --- a/database/saved_views.py +++ b/database/saved_views.py @@ -33,6 +33,8 @@ 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, or parents without tags. +_TAG_MODES = ("all", "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 +69,8 @@ _STATE_FIELDS = { "category": ("text", 100, ""), "sort": ("choice", _LIBRARY_SORTS, "newest"), "tags": ("tags", None, ()), + # #444: "untagged" never goes with tags (the listing refuses both). + "tag_mode": ("choice", _TAG_MODES, "all"), }, } @@ -152,6 +156,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 +201,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 9dd5f42a..5eb22c34 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, _limit +from .library import _ELIGIBLE, UNTAGGED, _escape, _limit from .library_commands import _TOKEN, LibraryCommandError, _validate_epoch from .library_tokens import canonical_token from .models import utc_now_iso @@ -38,7 +38,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 carrying a tag in the page, plus the # positive parent of every active pair carrying it (always eligible). @@ -57,11 +57,45 @@ 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 + " " + "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)," + "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 +) _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 = ( @@ -210,6 +244,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 @@ -431,22 +487,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, @@ -490,9 +604,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 2e3dc4b5..5bd4e973 100644 --- a/py/api/library.py +++ b/py/api/library.py @@ -294,6 +294,7 @@ class LibraryRoutesMixin: "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"), } limit = _page_limit(request) 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/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.spec.mjs b/tests/browser/workspace-library.spec.mjs index b91a73df..685eab8d 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -200,7 +200,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(['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..26a2c1be 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' } }), ]); // 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' } }); 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' } }); 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' } }); 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' } }); 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' } }); 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-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/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index cda08eb3..f765a729 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -104,6 +104,17 @@ 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 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:'none'}),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-saved-views.test.js b/tests/js/workspace-saved-views.test.js index c5cb9d6a..72435079 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' }; test('a saved state always holds every page key and keeps only values its page accepts', async () => { const { normalizeViewState, SAVED_VIEW_PAGES } = await source; @@ -32,7 +32,7 @@ test('a saved state always holds every page key and keeps only values its page a { ...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'] }); + assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all' }); 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' } }, { 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: 'all' }; + assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tag_mode: 'all' }); assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf); }); @@ -216,6 +216,16 @@ 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 untagged never travels with tags (#444)', async () => { + const { normalizeViewState, libraryFilters, libraryViewState } = await source; + assert.equal(normalizeViewState('library', { tag_mode: 'untagged' }).tag_mode, 'untagged'); + assert.equal(normalizeViewState('library', { tag_mode: 'any' }).tag_mode, 'all'); + assert.deepEqual(normalizeViewState('library', { tags: ['a'], tag_mode: 'untagged' }), { ...LIBRARY_DEFAULTS, tags: ['a'] }); + const shelf = { text: '', category: '', sort: 'newest', tags: [], tagMode: 'untagged' }; + assert.equal(libraryViewState(shelf).tag_mode, 'untagged'); + assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf); +}); + 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-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..6239b611 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") 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_saved_views.py b/tests/test_saved_views.py index 662e7573..d5cdc565 100644 --- a/tests/test_saved_views.py +++ b/tests/test_saved_views.py @@ -42,7 +42,13 @@ IMAGES_STATE = { "favorites": False, "collection": "", } -LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []} +LIBRARY_STATE = { + "q": "", + "category": "", + "sort": "rating", + "tags": [], + "tag_mode": "untagged", +} IMAGES_DEFAULTS = { "q": "", "category": "", @@ -54,7 +60,13 @@ IMAGES_DEFAULTS = { "favorites": False, "collection": "", } -LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []} +LIBRARY_DEFAULTS = { + "q": "", + "category": "", + "sort": "newest", + "tags": [], + "tag_mode": "all", +} # 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 +240,16 @@ 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"}, + ) + untagged = await self.create( + page="library", state={"tags": [], "tag_mode": "untagged"} + ) + self.assertEqual(untagged["state"]["tag_mode"], "untagged") _, listed = await self.send("GET") - self.assertEqual(len(listed["views"]), 20) + self.assertEqual(len(listed["views"]), 21) async def test_write_requires_the_collection_key(self): # #331 D140: a view saved on a collection page keeps the collection id. @@ -675,6 +694,23 @@ class TestSavedViewsSchema(unittest.TestCase): ) self.assertEqual(normalize_state("history", {}), {}) + def test_library_tag_mode_is_kept_and_never_paired_with_tags(self): + # A row from before #444 has no tag_mode: it opens matching every tag. + old = {"q": "", "category": "", "sort": "rating", "tags": ["a"]} + self.assertEqual(normalize_state("library", old), {**LIBRARY_DEFAULTS, **old}) + self.assertEqual( + normalize_state("library", {"tag_mode": "any"}), LIBRARY_DEFAULTS + ) + # 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"]}) + with self.assertRaises(SavedViewInvalid): + encode_state("library", {**LIBRARY_STATE, "tag_mode": "none"}) + 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/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index 2f61be61..bb4e4d98 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -12,10 +12,18 @@ 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; `untagged` lists parents no tag's Browse returns and never goes with tags. + const tagMode=filters.tagMode ?? 'all'; + if(!LIBRARY_TAG_MODES.includes(tagMode) || (tagMode==='untagged' && query.has('tag'))) throw new TypeError('Invalid library tag mode'); + if(tagMode!=='all') query.set('tag_mode',tagMode); query.set('sort',sort);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','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 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(!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}) { @@ -37,7 +45,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 fe187f9e..e57169fc 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'; @@ -31,6 +31,8 @@ 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), in LIBRARY_TAG_MODES order. +const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['untagged','Prompts without tags']]); // 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.'; @@ -83,9 +85,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'}=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)) throw new TypeError('Invalid Library filters'); + shelfVisit={filters:{text,category,tags:[...tags],sort,tagMode},anchor:null}; focusHeadingOnMount=true;window.location.hash='#/library'; } /** Remember the selection a reconciled save names, so the destination history opens on it once. */ @@ -158,15 +160,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration; // An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again. const restorable=visit?.state?visit:null; - const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved}; + const filters={text:'',category:'',tags:[],sort:'newest',tagMode:'all',...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, and a + // Tag match other than All (#444 D149). + const refinements=()=>(filters.tags.length?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}));}}); @@ -420,15 +423,17 @@ 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 extra filters, Tags (D14) and Tag match (D149); Clear also clears the toolbar category. */ function openFilters(trigger) { - openRefineDialog({scope,trigger,fields:[{key:'tags'}],values:{tags:filters.tags.join(', ')},onApply:applyRefinement, + openRefineDialog({scope,trigger,fields:[{key:'tags'},{key:'tagMode',options:TAG_MATCH_OPTIONS}], + values:{tags:filters.tags.join(', '),tagMode:filters.tagMode},onApply:applyRefinement, onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}}); } - 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',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 && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return; + filters.category=category;filters.tags=next;filters.tagMode=tagMode;reset(); } scope.listen(more,'click',()=>stream.loadMore()); // Retry also re-reads a category list that failed (D15: Refresh is gone). diff --git a/web/js/workspace/saved-views.mjs b/web/js/workspace/saved-views.mjs index e58fa94f..664bc4f0 100644 --- a/web/js/workspace/saved-views.mjs +++ b/web/js/workspace/saved-views.mjs @@ -47,6 +47,8 @@ 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). + tag_mode: choice(['all', 'untagged'], 'all'), }), }); @@ -63,6 +65,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 +117,11 @@ 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 }; } 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, + tag_mode: filters.tagMode ?? 'all' }); } /** 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..8df9f351 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): 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: '' });