Merge remote-tracking branch 'origin/main' into fix/460-case-history

# Conflicts:
#	database/execution_contexts.py
#	database/history_reads.py
#	tests/comfyui/execution-history.spec.mjs
#	tests/test_execution_context_database.py
#	tests/test_execution_history_hooks.py
#	tests/test_hashing.py
#	tests/test_history_reads.py
This commit is contained in:
Vito Sansevero
2026-10-29 05:47:06 -07:00
61 changed files with 4682 additions and 213 deletions
+33 -2
View File
@@ -121,7 +121,28 @@ def _archive(value: str) -> str:
return ARCHIVE_SCOPES[value]
def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list]:
# A parent that no tag's Browse returns (#444 "Untagged prompts"): no tag of its
# own and no active pair carrying one. The vocabulary's untagged count uses it too.
UNTAGGED = (
"NOT EXISTS(SELECT 1 FROM prompt_tags ut WHERE ut.prompt_id=p.id) "
"AND NOT EXISTS(SELECT 1 FROM prompt_pairs up JOIN prompt_pair_tags upt "
"ON upt.pair_id=up.id WHERE up.positive_prompt_id=p.id AND up.archived=0)"
)
# How the Tags filter matches: every tag (AND), or parents without any tag.
TAG_MODES = ("all", "untagged")
def _tag_mode(tag_mode: object, tags: list[str]) -> str:
if not isinstance(tag_mode, str) or tag_mode not in TAG_MODES:
raise ValueError("tag_mode must be one of " + ", ".join(TAG_MODES))
if tag_mode == "untagged" and tags:
raise ValueError("Untagged prompts cannot also filter by tags")
return tag_mode
def _filter(
text: str, category: str | None, tags: list[str], tag_mode: str = "all"
) -> tuple[str, list]:
if not isinstance(text, str) or len(text) > 500:
raise ValueError("Search text must be a string of at most 500 characters")
if category is not None and (not isinstance(category, str) or len(category) > 200):
@@ -132,6 +153,7 @@ def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list
or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags)
):
raise ValueError("Invalid library tags")
untagged = _tag_mode(tag_mode, tags) == "untagged"
parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], []
if text:
parent.append("(p.text LIKE ? ESCAPE '\\' OR ls.title LIKE ? ESCAPE '\\')")
@@ -168,6 +190,10 @@ def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list
+ " AND ".join(pair)
+ ")"
)
if untagged:
# A whole-card condition: inside the parent-or-pair OR an untagged pair
# would let a tagged parent through.
condition = f"({condition}) AND {UNTAGGED}"
return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params]
@@ -251,6 +277,7 @@ class LibraryOperations:
text: str = "",
category: str | None = None,
tags: list[str] | None = None,
tag_mode: str = "all",
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
@@ -258,7 +285,7 @@ class LibraryOperations:
_limit(limit)
if sort not in _ORDERS:
raise ValueError("Unknown library sort order")
where, params = _filter(text, category, [] if tags is None else tags)
where, params = _filter(text, category, [] if tags is None else tags, tag_mode)
if after is not None:
if (
not isinstance(after, dict)
@@ -311,6 +338,8 @@ class LibraryOperations:
"items": rows,
"total": total,
"variant_total": variant_total,
# Echoed so a client can tell this read applied its tag match.
"tag_mode": tag_mode,
"has_more": has_more,
"next_key": next_key,
}
@@ -321,6 +350,7 @@ class LibraryOperations:
text: str = "",
category: str | None = None,
tags: list[str] | None = None,
tag_mode: str = "all",
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
@@ -333,6 +363,7 @@ class LibraryOperations:
text=text,
category=category,
tags=tags,
tag_mode=tag_mode,
sort=sort,
limit=limit,
after=after,
+9
View File
@@ -33,6 +33,8 @@ MAX_TAG_LENGTH = 50
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
_MEDIA = ("all", "image", "video")
# How a Library view's Tags match (#444): every tag, or parents without tags.
_TAG_MODES = ("all", "untagged")
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
# A collection id as the listing's ``collection`` query takes it (#331): none
@@ -67,6 +69,8 @@ _STATE_FIELDS = {
"category": ("text", 100, ""),
"sort": ("choice", _LIBRARY_SORTS, "newest"),
"tags": ("tags", None, ()),
# #444: "untagged" never goes with tags (the listing refuses both).
"tag_mode": ("choice", _TAG_MODES, "all"),
},
}
@@ -152,6 +156,8 @@ def encode_state(page: object, state: object) -> str:
_validate_value(key, kind, limit, state[key])
if state.get("favorites") is True and state.get("collection"):
raise SavedViewInvalid("A view cannot filter by favorites and a collection")
if state.get("tag_mode") == "untagged" and state.get("tags"):
raise SavedViewInvalid("A view cannot list untagged prompts and filter tags")
encoded = json.dumps(
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
)
@@ -195,6 +201,9 @@ def normalize_state(page: str, raw: object) -> dict:
# as the listing does for a URL with both (#331 D140).
if state.get("favorites") is True and state.get("collection"):
state["favorites"] = False
# Never written by this build either: the tags win.
if state.get("tag_mode") == "untagged" and state.get("tags"):
state["tag_mode"] = "all"
return state
+133 -10
View File
@@ -20,7 +20,7 @@ counted, hashed into the token and renamed with the prompts and pairs.
import sqlite3
from .library import _ELIGIBLE, _limit
from .library import _ELIGIBLE, UNTAGGED, _escape, _limit
from .library_commands import _TOKEN, LibraryCommandError, _validate_epoch
from .library_tokens import canonical_token
from .models import utc_now_iso
@@ -38,7 +38,7 @@ _IMAGE_FILES = (
)
def _tag_rows(page: str) -> str:
def _tag_rows(page: str, order: str = "page.id") -> str:
"""Count rows for the registry rows selected by ``page`` (id, name)."""
# Eligible parent components carrying a tag in the page, plus the
# positive parent of every active pair carrying it (always eligible).
@@ -57,11 +57,45 @@ def _tag_rows(page: str) -> str:
"COALESCE(MAX(images.n),0) AS image_count "
"FROM page LEFT JOIN owners ON owners.tag_id=page.id "
"LEFT JOIN images ON images.tag_id=page.id "
"GROUP BY page.id ORDER BY page.id"
"GROUP BY page.id ORDER BY " + order
)
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
# "created" follow the id, so every order is total and keyset pages are exact.
TAG_SORTS = {
"created": (None, "ASC"),
"name": ("name COLLATE NOCASE", "ASC"),
"name-desc": ("name COLLATE NOCASE", "DESC"),
"prompts": ("prompt_count", "DESC"),
"prompts-asc": ("prompt_count", "ASC"),
}
_MAX_SEARCH = 200
_TAG_SEARCH = "name LIKE ? ESCAPE '\\'"
# Every matching tag ranked by the prompt count its row shows (the owners rule
# of ``_tag_rows``), then one keyset page of it with its image counts.
_RANKED_TAG_PAGE = (
"WITH owners(tag_id,pid) AS ("
"SELECT pt.tag_id,p.id FROM prompts p "
"JOIN prompt_tags pt ON pt.prompt_id=p.id WHERE " + _ELIGIBLE + " "
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id WHERE pp.archived=0),"
"counted AS (SELECT t.id,t.name,COUNT(DISTINCT o.pid) AS prompt_count "
"FROM tags t LEFT JOIN owners o ON o.tag_id=t.id WHERE {search} "
"GROUP BY t.id),"
"page AS (SELECT id,name,prompt_count FROM counted WHERE {after} "
"ORDER BY prompt_count {direction},id LIMIT ?),"
"images(tag_id,n) AS (" + _IMAGE_FILES + " "
"WHERE mit.tag_id IN (SELECT id FROM page) GROUP BY mit.tag_id) "
"SELECT page.id,page.name,page.prompt_count,"
"COALESCE(images.n,0) AS image_count FROM page "
"LEFT JOIN images ON images.tag_id=page.id "
"ORDER BY page.prompt_count {direction},page.id"
)
_UNTAGGED_COUNT = (
"SELECT COUNT(*) FROM prompts p WHERE (" + _ELIGIBLE + ") AND " + UNTAGGED
)
_TAG_ONE = _tag_rows("SELECT id,name FROM tags WHERE id=?")
# Every owner kind a tag can sit on, for the delete confirmation.
_TAG_IMPACT = (
@@ -210,6 +244,28 @@ def _tag_after(after: object) -> int:
return after
def _tag_search(search: object) -> str:
if not isinstance(search, str) or len(search) > _MAX_SEARCH:
raise ValueError(f"Tag search must be text of at most {_MAX_SEARCH} characters")
return search.strip()
def _sorted_tag_after(after: object, sort: str) -> tuple | None:
"""The ``[key, id]`` cursor of a name or prompt-count sort."""
if after is None:
return None
key_type = str if sort.startswith("name") else int
if (
not isinstance(after, list)
or len(after) != 2
or type(after[0]) is not key_type
or type(after[1]) is not int
or after[1] <= 0
):
raise ValueError("Invalid vocabulary cursor")
return after[0], after[1]
def _category_after(after: object) -> tuple[int, str] | None:
if after is None:
return None
@@ -431,22 +487,80 @@ class VocabularyOperations:
epoch: str,
*,
limit: int = 100,
after: int | None = None,
after: object = None,
search: str = "",
sort: str = "created",
) -> dict:
"""One page of tags matching ``search`` (a literal, case-insensitive
substring) in ``sort`` order, with the untagged parents' count."""
_limit(limit)
start = _tag_after(after)
total = conn.execute("SELECT COUNT(*) FROM tags").fetchone()[0]
rows = [dict(row) for row in conn.execute(_TAG_PAGE, (start, limit + 1))]
search = _tag_search(search)
if not isinstance(sort, str) or sort not in TAG_SORTS:
raise ValueError("sort must be one of " + ", ".join(TAG_SORTS))
where, params = "1=1", []
if search:
where, params = _TAG_SEARCH, [f"%{_escape(search)}%"]
total, untagged = conn.execute(
f"SELECT (SELECT COUNT(*) FROM tags WHERE {where}),({_UNTAGGED_COUNT})",
params,
).fetchone()
rows = [
dict(row) for row in self._tag_page(conn, where, params, sort, after, limit)
]
has_more = len(rows) > limit
rows = rows[:limit]
key, _direction = TAG_SORTS[sort]
next_key = None
if has_more:
last = rows[-1]
next_key = (
last["id"]
if key is None
else [
last["prompt_count" if key == "prompt_count" else "name"],
last["id"],
]
)
return {
"library_epoch": epoch,
"tags": rows,
"total": total,
"untagged_count": untagged,
# Echoed so a client can tell this read applied its search and sort.
"search": search,
"sort": sort,
"has_more": has_more,
"next_key": rows[-1]["id"] if has_more else None,
"next_key": next_key,
}
@staticmethod
def _tag_page(conn, where, params, sort, after, limit):
key, direction = TAG_SORTS[sort]
if key is None:
page = (
f"SELECT id,name FROM tags WHERE {where} AND id>? ORDER BY id LIMIT ?"
)
return conn.execute(
_tag_rows(page), [*params, _tag_after(after), limit + 1]
)
cursor = _sorted_tag_after(after, sort)
operator = ">" if direction == "ASC" else "<"
keyset, keys = "1=1", []
if cursor is not None:
keyset = f"({key}{operator}? OR ({key}=? AND id>?))"
keys = [cursor[0], cursor[0], cursor[1]]
if key == "prompt_count":
sql = _RANKED_TAG_PAGE.format(
search=where, after=keyset, direction=direction
)
return conn.execute(sql, [*params, *keys, limit + 1])
page = (
f"SELECT id,name FROM tags WHERE {where} AND {keyset} "
f"ORDER BY {key} {direction},id LIMIT ?"
)
order = f"page.{key} {direction},page.id"
return conn.execute(_tag_rows(page, order), [*params, *keys, limit + 1])
def _vocabulary_categories(
self,
conn: sqlite3.Connection,
@@ -490,9 +604,18 @@ class VocabularyOperations:
),
}
def list_vocabulary_tags(self, *, limit: int = 100, after: int | None = None):
def list_vocabulary_tags(
self,
*,
limit: int = 100,
after: object = None,
search: str = "",
sort: str = "created",
):
with self.library_snapshot() as (conn, epoch, _ceiling):
return self._vocabulary_tags(conn, epoch, limit=limit, after=after)
return self._vocabulary_tags(
conn, epoch, limit=limit, after=after, search=search, sort=sort
)
def list_vocabulary_categories(
self, *, limit: int = 100, after: list | None = None