feat(vocabulary): tag search, tag sort and an Untagged prompts entry (#444)

Tags & categories gains a server-side tag search and sort (keyset pages,
cursor scoped to the query) and an "Untagged prompts" row whose Browse opens
the Library with tag_mode=untagged. Refine this view gets Tag match, saved
Library views keep it, and the routes echo the applied query so a stale
server is an error, never an unfiltered list. Recorded as D149.

Refs #444
This commit is contained in:
Vito Sansevero
2026-10-28 17:07:50 -07:00
parent 4040007264
commit d4d99b7e18
24 changed files with 982 additions and 90 deletions
+34 -3
View File
@@ -6036,13 +6036,14 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
longer hands off to the legacy tag manager. That page stays reachable from Overview's
"Open existing tag manager" card for tag search and sort, the untagged entry and
multi-tag AND/OR browsing; bulk prompt actions (delete, tag, category) stay on the
"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 actions (delete, tag, category) stay on the
legacy admin page. A prompt's category is changed in the Prompt library editor or with
the legacy admin page's "Set Category" (`POST /prompt_manager/bulk/category`, unchanged);
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
@@ -6094,6 +6095,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
+33 -2
View File
@@ -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,
+9
View File
@@ -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
+133 -10
View File
@@ -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
+1
View File
@@ -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)
+18 -5
View File
@@ -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
@@ -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)};
+1 -1
View File
@@ -168,7 +168,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);
+5 -5
View File
@@ -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' });
@@ -232,7 +232,7 @@ test('a view stored under older rules still lists and opens with its usable valu
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);
@@ -275,7 +275,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; });
@@ -323,7 +323,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"}' })
@@ -514,7 +514,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();
@@ -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: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]');
});
+25 -4
View File
@@ -3,7 +3,7 @@ import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} 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.
@@ -761,6 +761,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');
@@ -780,7 +789,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)};
@@ -793,13 +802,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']);
@@ -869,6 +883,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);
+11
View File
@@ -104,3 +104,14 @@ 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();
});
+15 -5
View File
@@ -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);
});
@@ -215,3 +215,13 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
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);
});
@@ -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'});
+26
View File
@@ -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")
+46 -1
View File
@@ -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,
+40 -4
View File
@@ -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(""),
+156
View File
@@ -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"')
+10 -2
View File
@@ -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','rating'].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);const incoming=[];const added=new Set();
validatePage(page,cursor,query);const incoming=[];const added=new Set();
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
for(const value of added) seen.add(value);
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
+18 -13
View File
@@ -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';
@@ -30,6 +30,8 @@ const metadataDrafts=new Map();
const archivedVisible=new Map();
// The concept's three sorts first, then the Library's appended Oldest first (D14).
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
// 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']]);
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.';
function el(tag,text,className) {
@@ -80,9 +82,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. */
@@ -155,13 +157,14 @@ 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};
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}));}});
@@ -402,15 +405,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).
+7 -2
View File
@@ -46,6 +46,8 @@ const FIELDS = Object.freeze({
}),
library: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
// #444: how the Tags match; 'untagged' never goes with tags (the listing refuses both).
tag_mode: choice(['all', 'untagged'], 'all'),
}),
});
@@ -62,6 +64,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;
}
@@ -112,10 +116,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' });
}
/**
+49 -13
View File
@@ -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).
+73 -9
View File
@@ -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 => {
+11 -3
View File
@@ -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;
@@ -171,7 +172,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);
@@ -191,6 +192,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) => {
@@ -203,7 +210,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');
@@ -211,6 +218,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: '' });