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:
+34
-3
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -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"')
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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: '' });
|
||||
|
||||
Reference in New Issue
Block a user