* feat(images): link summary on workspace image items
* fix(images): carry title source and cut first-line titles like the Library
Summaries gain title_source (stored or text). The inspector always shows a
stored title and confirms only a first-line title against the file, so a
stored title equal to the first line is no longer withheld. A link-title read
failure no longer marks a readable link unavailable. First-line titles over
80 characters end with an ellipsis, as the Library's title() does. The card
gate for unconfirmed first-line titles is pinned as an expected failure for B2.
* feat(images): unambiguous seed on workspace items
Workspace items gain `seed`, read with the shared generation-parameter
parser from the prompt/workflow records the header open already loaded
(each at most 2 MiB; no extra file reads, videos give None). A
placeholder or non-integer seed gives None. JSON integers beyond 2**53
are recovered exactly, so the card prints the digits the inspector shows.
The same parse confirms first-line card titles: a `text` title shows only
when the file's parsed positive gives the same title. One helper applies
that rule on the card and in the inspector, which now reads the positive
from its own inspection records with the same parser.
* fix(images): a deeply nested record no longer fails the workspace listing
The Python parser recurses over nested arrays where JavaScript String()
does not, so a crafted 2 KB PNG turned every workspace page holding it
(and its inspect route) into a 500. Card facts now treat RecursionError
like any record the parser cannot use: no seed and no confirmed title.
Also: Library previews skip the card parse they never show; the parse
runs after the file handle closes; the exact-seed read uses the one JSON
reader (parse_js_json(..., exact_integers=True)) and the public
number_string; record size is decided from the text length unless only
the UTF-8 byte count can tell.
* feat(images): search and filter workspace images by linked prompts
The workspace listing now takes category (exact), tags (comma list, all
required) and sort=rating, and q also matches the linked positive's text,
Library title, pair title and tag names. One bounded, parameterized SELECT
(get_image_link_matches / get_image_paths_matching) returns normalized
paths of unambiguously linked images; the listing is intersected with it.
Unlinked and ambiguous files are excluded by a category or tag filter and
sort last by rating, with a newest-first tie-break. The cursor scope covers
the new filters and sort; unknown values are still rejected.
The derived listing values and sort orders are cached per gallery scan so a
50,000-file listing with 20,000 links pages well under 150 ms at p95.
* test(images): count the filter budget everywhere, time it in one job
The 150 ms wall-clock budget failed under the CI coverage tracer, so it
is split. test_filter_work_is_bounded runs on every job and under
coverage: one filter SELECT per request, the ambiguity probe is an index
search (EXPLAIN QUERY PLAN), the per-scan values are built on the cold
request only, and a cursor page calls the sort key at most
ceil(log2 n)+3 times. test_filter_budget runs only with
PROMPTMANAGER_PERF_BUDGET=1 and no tracer, asserts the best of three
rounds' p95, and has its own non-required "perf budget" job. TESTING.md
records the convention.
* fix(images): rating sort works however many link rows the database holds
The filter SELECT counted every generated_images row against a 500,000
row cap and materialized all of them first, so a large history made
sort=rating answer 400 with nothing to narrow. The rows are now streamed
and only paths in the listing's normalized set are kept, so memory is
bounded by the listing; the cap and LinkFilterTooBroad are gone.
* fix(images): rebuild workspace listing values only where the scan changed
A TTL rescan that finds the same files reuses the previous values and
sort orders; one that finds a few changes rebuilds only those files.
Gallery invalidation and a gallery-roots change drop the derived values,
so old roots no longer hold memory. category is trimmed like tags.
* fix(images): a monitor invalidation rebuilds only the new files' values
The image monitor invalidates the gallery whenever it sees a new file,
and that dropped every derived workspace value, so the next request paid
a full cold build. Invalidation now keeps the per-file values and drops
only the sort orders; the rescan reuses every unchanged file and builds
only new or changed ones. A gallery-roots change still drops everything.
* feat(library): expose run count, last use and variant totals
* fix(library): an out-of-range last use reads as unknown, not a failed list
* perf(images): read positive counts only on a plain listing's first page
* feat(images): persist image favorites
Favorites are stored in image_favorites keyed by the gallery root label
and the relative path, set and cleared through PUT/DELETE
/prompt_manager/images/favorite with a {root_index, path} body validated
against the rooted listing. Workspace image cards carry favorite: bool.
The table is created by migration, travels with backups, is optional in
old backups and required after a restore's migration.
* fix(images): key favorites by the real root and case-normalized path
Folder names are not unique across gallery roots, so two roots shown as
"output" shared each other's stars. Favorites are now keyed by the
root's real, case-normalized path (server-side only) and a
case-normalized relative path, so a Windows case-only rename keeps its
star while distinct Linux files stay distinct. Toggles look the file up
in a per-scan dictionary instead of walking the listing.
* fix(images): favorites lookup follows monitor invalidation and the stored key bound
A monitor invalidation now also drops the favorites lookup, which held the
previous scan list until the next toggle. PUT refuses a favorite whose root
or relative key would exceed the schema's 4096-character bound, so the live
database keeps passing startup and restore validation.
* docs(testing): workspace data fields for the concept pages
* fix(images): card facts read the inspector's projected graphs
The inspector hands its browser parser only graphs the inspection
projection parsed (strict JSON, depth 64, node ceiling); a deeper record
is "limited" there, so the legacy fill never runs and no seed shows. The
card parsed the raw records instead and showed a seed the inspector did
not, e.g. for a 900-deep class_type beside a normal sampler.
project_graph() is now the one projection step, used by the inspection
response and by the card. The card runs the shared parser on the
projected graphs, the same values the browser receives: big integers
arrive as their exact digits, so the separate exact-integer re-parse and
the parse_js_json flag it needed are gone.
* fix(images): send the card seed as a decimal string
JSON.parse rounds a number past 2**53, so a listing seed such as
9223372036854788153 reached the page as 9223372036854788000 while the
inspector showed the file's digits. Every non-null card seed is now its
decimal string.
The Node parity harness now JSON.parses the real listing response and
compares String(seed) with the seed the inspector shows, including a
seed past 2**53. TESTING.md describes the string contract and drops the
removed exact re-read.
* fix(images): bound digit-string seeds and isolate each card's parse
A seed recorded as a 5000-digit string reached int(), which raises
ValueError past Python's integer string limit, and the listing answered
500. A digit-string seed now has at most 20 digits (2**64 fits), and the
conversion falls back to no seed on ValueError.
Each card's parse is also guarded in the page builder: an unexpected
error in one file's records logs a warning and gives that card no seed
and no first-line title, and the rest of the page still lists.
* fix(images): filename search survives a failed prompt-link read
On main a workspace q matched only file paths, so search worked with a
broken database. Since search also matches linked prompts, any q turned
a failed link read into a 500. A q without category, tags or the rating
sort now falls back to filename matches with a null positive_count and
logs the failure with its traceback; those linked filters still fail.
* fix(images): card reads PNG records with the inspector's reader
The card read PNG text through Pillow's header info, where a later
duplicate prompt chunk silently wins and text after IDAT is missed. The
inspector's bounded reader refuses conflicting duplicates and reads text
after IDAT, so a file with two different prompt chunks showed a card
seed the inspector refused.
PNG cards now read their records with read_inspection_file, the reader
the inspector uses. When it refuses a file the card keeps its
dimensions from Pillow and shows no seed. Other formats still use
Pillow's header info, as the inspector's header read does.
* perf(images): read each card's header facts once per gallery scan
Every page parsed each card's prompt and workflow records again, about
3.4 ms per 50 KB record set and 25 ms per 400 KB workflow, inside
ComfyUI's server process, on every scroll-back and refresh.
Dimensions, the seed and the parsed positive's display title are now
cached per root by (path, mtime), beside the scan's per-file values and
invalidated the same way: a rescan keeps unchanged files and prunes the
rest, a roots change drops them all, and only files of the current scan
are stored. An unreadable header is not stored. Links and favorites
are still read on every page.
* ci: run the perf budget on main pushes and on demand only
The wall-clock budget has under 2x margin on shared ubuntu runners, and
a red exact-head check blocks a merge that must never be rerun to green.
The job now runs on each push to main and on workflow_dispatch, not on
pull requests. TESTING.md says a red run on main means fix the code.
* fix(library): cut display titles at 80 UTF-16 units, never inside a pair
The server counted code points while the Library's JavaScript counts UTF-16
units, so a first line of emoji kept 80 emoji on cards but showed 40 in the
Library. Both now cut at 80 UTF-16 units and drop a character that would
straddle the limit instead of leaving half a surrogate pair.
629 lines
24 KiB
Python
629 lines
24 KiB
Python
"""Read-only positive-parent views over existing prompt and pair tables."""
|
|
|
|
import datetime
|
|
import sqlite3
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
|
|
from .library_tokens import library_edit_token
|
|
|
|
_ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id)
|
|
OR EXISTS(SELECT 1 FROM generated_images eg WHERE eg.prompt_id=p.id)
|
|
OR NOT EXISTS(SELECT 1 FROM prompt_pairs en WHERE en.negative_prompt_id=p.id)
|
|
OR EXISTS(SELECT 1 FROM prompt_library_state es WHERE es.prompt_id=p.id))"""
|
|
_FROM = "prompts p LEFT JOIN prompt_library_state ls ON ls.prompt_id=p.id"
|
|
# The characters JavaScript's String.prototype.trim() removes, so a title the
|
|
# display treats as blank also falls back to the prompt text for name sorting.
|
|
_TRIM_CODES = (9, 10, 11, 12, 13, 32, 160, 5760, *range(8192, 8203))
|
|
_TRIM_CODES += (8232, 8233, 8239, 8287, 12288, 65279)
|
|
_BLANK = f"char({','.join(map(str, _TRIM_CODES))})"
|
|
_JS_TRIM = "".join(map(chr, _TRIM_CODES))
|
|
# The Library's display title: a stored title, else the prompt's first line.
|
|
DISPLAY_TITLE_LIMIT = 80
|
|
|
|
|
|
def _utf16_units(text: str) -> int:
|
|
"""JavaScript ``length``: characters beyond the BMP count as two units."""
|
|
return len(text) + sum(1 for ch in text if ord(ch) > 0xFFFF)
|
|
|
|
|
|
def _utf16_prefix(text: str, units: int) -> str:
|
|
"""The longest prefix within ``units`` UTF-16 units, never splitting a pair."""
|
|
used = 0
|
|
for index, ch in enumerate(text):
|
|
used += 2 if ord(ch) > 0xFFFF else 1
|
|
if used > units:
|
|
return text[:index]
|
|
return text
|
|
|
|
|
|
def titled(stored_titles: tuple, text: object) -> tuple[str | None, str | None]:
|
|
"""Return ``(title, source)``: a stored title, else the text's first line.
|
|
|
|
``source`` is ``"stored"`` for the first non-blank stored title (blank as
|
|
in JavaScript's trim, like the Library view), ``"text"`` for the first line,
|
|
and None when both are empty. The first line is trimmed and, past
|
|
DISPLAY_TITLE_LIMIT UTF-16 units, cut with an ellipsis like the Library's
|
|
``libraryTitle()`` (never inside a surrogate pair).
|
|
"""
|
|
for title in stored_titles:
|
|
if isinstance(title, str) and title.strip(_JS_TRIM):
|
|
return title, "stored"
|
|
if not isinstance(text, str):
|
|
return None, None
|
|
first = text.split("\n")[0].strip(_JS_TRIM)
|
|
if _utf16_units(first) > DISPLAY_TITLE_LIMIT:
|
|
first = _utf16_prefix(first, DISPLAY_TITLE_LIMIT) + "\u2026"
|
|
return (first, "text") if first else (None, None)
|
|
|
|
|
|
def display_title(stored_titles: tuple, text: object) -> str | None:
|
|
"""The Library display title of ``titled``, without its source."""
|
|
return titled(stored_titles, text)[0]
|
|
|
|
|
|
def utc_iso(value: object) -> str | None:
|
|
"""A stored timestamp as UTC ISO text, or None when absent or unreadable.
|
|
|
|
Usage writes are already UTC ISO; a naive value (SQLite's CURRENT_TIMESTAMP
|
|
form) is read as UTC, and an offset is converted to UTC. One bad row must
|
|
not fail a whole Library read, so a value past the datetime range after
|
|
conversion (OverflowError) is unreadable too. Python 3.11+ fromisoformat
|
|
accepts more ISO forms than 3.10 (e.g. a 2-digit fraction); every form the
|
|
app writes (utc_now_iso, the 3.2.4 backfill, CURRENT_TIMESTAMP, Z) parses
|
|
the same on both, so only hand-edited values can differ by version.
|
|
"""
|
|
if not isinstance(value, str):
|
|
return None
|
|
text = value.strip()
|
|
if text.endswith(("Z", "z")):
|
|
text = text[:-1] + "+00:00"
|
|
try:
|
|
stamp = datetime.datetime.fromisoformat(text)
|
|
if stamp.tzinfo is None:
|
|
stamp = stamp.replace(tzinfo=datetime.timezone.utc)
|
|
return stamp.astimezone(datetime.timezone.utc).isoformat()
|
|
except (ValueError, OverflowError):
|
|
return None
|
|
|
|
|
|
_NAME = (
|
|
f"LOWER(CASE WHEN TRIM(COALESCE(ls.title,''),{_BLANK})!='' "
|
|
"THEN ls.title ELSE p.text END)"
|
|
)
|
|
_ORDERS = {
|
|
"newest": ("COALESCE(julianday(p.created_at),0)", "DESC"),
|
|
"oldest": ("COALESCE(julianday(p.created_at),0)", "ASC"),
|
|
"name": (_NAME, "ASC"),
|
|
"rating": ("COALESCE(p.rating,0)", "DESC"),
|
|
}
|
|
# Pair archive scopes for variant and image reads, as conditions on alias pp.
|
|
ARCHIVE_SCOPES = {
|
|
"active": " AND pp.archived=0",
|
|
"archived": " AND pp.archived=1",
|
|
"all": "",
|
|
}
|
|
|
|
|
|
def _escape(value: str) -> str:
|
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
|
|
def _limit(value: int) -> None:
|
|
if type(value) is not int or not 1 <= value <= 200:
|
|
raise ValueError("limit must be between 1 and 200")
|
|
|
|
|
|
def _archive(value: str) -> str:
|
|
"""Return the pair-archive condition for one scope (alias ``pp``)."""
|
|
if not isinstance(value, str) or value not in ARCHIVE_SCOPES:
|
|
raise ValueError("archive must be one of active, archived or all")
|
|
return ARCHIVE_SCOPES[value]
|
|
|
|
|
|
def _filter(text: str, category: str | None, tags: list[str]) -> 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):
|
|
raise ValueError("Invalid library category")
|
|
if (
|
|
not isinstance(tags, list)
|
|
or len(tags) > 30
|
|
or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags)
|
|
):
|
|
raise ValueError("Invalid library tags")
|
|
parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], []
|
|
if text:
|
|
parent.append("(p.text LIKE ? ESCAPE '\\' OR ls.title LIKE ? ESCAPE '\\')")
|
|
parent_params.extend([f"%{_escape(text)}%"] * 2)
|
|
pair.append(
|
|
"(p.text LIKE ? ESCAPE '\\' OR ls.title LIKE ? ESCAPE '\\' "
|
|
"OR n.text LIKE ? ESCAPE '\\' OR pp.title LIKE ? ESCAPE '\\')"
|
|
)
|
|
pair_params.extend([f"%{_escape(text)}%"] * 4)
|
|
if category is not None:
|
|
parent.append("p.category=?")
|
|
pair.append("pp.category=?")
|
|
parent_params.append(category)
|
|
pair_params.append(category)
|
|
for tag in tags:
|
|
parent.append(
|
|
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
|
|
"WHERE pt.prompt_id=p.id AND t.name=?)"
|
|
)
|
|
pair.append(
|
|
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
|
|
"WHERE pt.pair_id=pp.id AND t.name=?)"
|
|
)
|
|
parent_params.append(tag)
|
|
pair_params.append(tag)
|
|
condition = (
|
|
"("
|
|
+ (" AND ".join(parent) or "1=1")
|
|
+ (
|
|
") OR EXISTS(SELECT 1 FROM prompt_pairs pp "
|
|
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
|
|
"WHERE pp.positive_prompt_id=p.id AND "
|
|
)
|
|
+ " AND ".join(pair)
|
|
+ ")"
|
|
)
|
|
return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params]
|
|
|
|
|
|
class LibraryOperations:
|
|
"""Group reusable negative variants without changing legacy prompt queries.
|
|
|
|
Each public read runs inside ``library_snapshot``; the ``_``-prefixed
|
|
helpers take that snapshot's connection and epoch so routes can share it.
|
|
"""
|
|
|
|
@contextmanager
|
|
def library_snapshot(
|
|
self, *, include_ceiling: bool = False
|
|
) -> Iterator[tuple[sqlite3.Connection, str, int | None]]:
|
|
"""Hold one read transaction, reading the epoch (and ceiling) first."""
|
|
conn = self.model.get_connection()
|
|
if conn.in_transaction:
|
|
raise sqlite3.ProgrammingError("Library reads require their own snapshot")
|
|
conn.execute("BEGIN")
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT storage_epoch,"
|
|
+ (
|
|
"(SELECT COALESCE(MAX(id),0) FROM prompts)"
|
|
if include_ceiling
|
|
else "NULL"
|
|
)
|
|
+ " FROM execution_history_metadata WHERE singleton_id=1"
|
|
).fetchone()
|
|
if row is None:
|
|
raise sqlite3.DatabaseError("Missing execution history metadata")
|
|
yield conn, row[0], row[1]
|
|
finally:
|
|
if conn.in_transaction:
|
|
conn.rollback()
|
|
|
|
def _library_parents(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
epoch: str,
|
|
where: str,
|
|
params: list,
|
|
suffix: str = "",
|
|
sort_expression: str | None = None,
|
|
) -> list[dict]:
|
|
rows = conn.execute(
|
|
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,p.created_at,"
|
|
"p.run_count,p.last_used_at,"
|
|
+ (f"{sort_expression} AS _sort," if sort_expression else "")
|
|
+ "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
|
|
"AND v.archived=0) AS variant_count,"
|
|
"(SELECT COUNT(*) FROM generated_images g "
|
|
"WHERE g.prompt_id=p.id) AS image_count "
|
|
"FROM " + _FROM + " WHERE " + where + suffix,
|
|
params,
|
|
).fetchall()
|
|
result = [{**dict(row), "tags": []} for row in rows]
|
|
if result:
|
|
by_id = {row["id"]: row for row in result}
|
|
marks = ",".join("?" for _ in result)
|
|
for row in conn.execute(
|
|
"SELECT pt.prompt_id,t.name FROM prompt_tags pt "
|
|
"JOIN tags t ON t.id=pt.tag_id "
|
|
f"WHERE pt.prompt_id IN ({marks}) ORDER BY t.name",
|
|
list(by_id),
|
|
):
|
|
by_id[row["prompt_id"]]["tags"].append(row["name"])
|
|
for row in result:
|
|
# Usage is shown, never compared: the token hashes owner fields only.
|
|
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
|
|
row["last_used_at"] = utc_iso(row["last_used_at"])
|
|
row["edit_token"] = library_edit_token(epoch, row)
|
|
return result
|
|
|
|
def _list_library(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
epoch: str,
|
|
ceiling: int,
|
|
*,
|
|
text: str = "",
|
|
category: str | None = None,
|
|
tags: list[str] | None = None,
|
|
sort: str = "newest",
|
|
limit: int = 40,
|
|
after: dict | None = None,
|
|
) -> dict:
|
|
_limit(limit)
|
|
if sort not in _ORDERS:
|
|
raise ValueError("Unknown library sort order")
|
|
where, params = _filter(text, category, [] if tags is None else tags)
|
|
if after is not None:
|
|
if (
|
|
not isinstance(after, dict)
|
|
or set(after) != {"ceiling", "value", "id"}
|
|
or type(after["ceiling"]) is not int
|
|
or type(after["id"]) is not int
|
|
):
|
|
raise ValueError("Invalid library cursor")
|
|
ceiling = after["ceiling"]
|
|
where += " AND p.id<=?"
|
|
params.append(ceiling)
|
|
# One SELECT counts the filtered parents and their active variants.
|
|
total, variant_total = conn.execute(
|
|
"SELECT COUNT(*),COALESCE(SUM((SELECT COUNT(*) FROM prompt_pairs v "
|
|
"WHERE v.positive_prompt_id=p.id AND v.archived=0)),0) FROM "
|
|
+ _FROM
|
|
+ " WHERE "
|
|
+ where,
|
|
params,
|
|
).fetchone()
|
|
expression, direction = _ORDERS[sort]
|
|
if after is not None:
|
|
operator = ">" if direction == "ASC" else "<"
|
|
where += (
|
|
f" AND ({expression}{operator}? OR "
|
|
f"({expression}=? AND p.id{operator}?))"
|
|
)
|
|
params.extend([after["value"], after["value"], after["id"]])
|
|
rows = self._library_parents(
|
|
conn,
|
|
epoch,
|
|
where,
|
|
[*params, limit + 1],
|
|
f" ORDER BY {expression} {direction},p.id {direction} LIMIT ?",
|
|
sort_expression=expression,
|
|
)
|
|
has_more = len(rows) > limit
|
|
rows = rows[:limit]
|
|
next_key = None
|
|
if has_more:
|
|
next_key = {
|
|
"ceiling": ceiling,
|
|
"value": rows[-1]["_sort"],
|
|
"id": rows[-1]["id"],
|
|
}
|
|
for row in rows:
|
|
row.pop("_sort")
|
|
return {
|
|
"library_epoch": epoch,
|
|
"items": rows,
|
|
"total": total,
|
|
"variant_total": variant_total,
|
|
"has_more": has_more,
|
|
"next_key": next_key,
|
|
}
|
|
|
|
def list_library(
|
|
self,
|
|
*,
|
|
text: str = "",
|
|
category: str | None = None,
|
|
tags: list[str] | None = None,
|
|
sort: str = "newest",
|
|
limit: int = 40,
|
|
after: dict | None = None,
|
|
) -> dict:
|
|
with self.library_snapshot(include_ceiling=True) as (conn, epoch, ceiling):
|
|
return self._list_library(
|
|
conn,
|
|
epoch,
|
|
ceiling,
|
|
text=text,
|
|
category=category,
|
|
tags=tags,
|
|
sort=sort,
|
|
limit=limit,
|
|
after=after,
|
|
)
|
|
|
|
def _is_library_positive(self, conn: sqlite3.Connection, positive_id: int) -> bool:
|
|
return (
|
|
conn.execute(
|
|
"SELECT 1 FROM prompts p WHERE p.id=? AND " + _ELIGIBLE, (positive_id,)
|
|
).fetchone()
|
|
is not None
|
|
)
|
|
|
|
def _get_library_positive(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
epoch: str,
|
|
positive_id: int,
|
|
*,
|
|
variant_limit: int = 40,
|
|
variant_after: int | None = None,
|
|
archive: str = "active",
|
|
) -> dict | None:
|
|
_limit(variant_limit)
|
|
scope = _archive(archive)
|
|
if variant_after is not None and (
|
|
type(variant_after) is not int or variant_after <= 0
|
|
):
|
|
raise ValueError("Invalid variant cursor")
|
|
rows = self._library_parents(
|
|
conn, epoch, _ELIGIBLE + " AND p.id=?", [positive_id]
|
|
)
|
|
if not rows:
|
|
return None
|
|
positive = rows[0]
|
|
positive["archived_variant_count"] = conn.execute(
|
|
"SELECT COUNT(*) FROM prompt_pairs "
|
|
"WHERE positive_prompt_id=? AND archived=1",
|
|
(positive_id,),
|
|
).fetchone()[0]
|
|
variant_total = {
|
|
"active": positive["variant_count"],
|
|
"archived": positive["archived_variant_count"],
|
|
"all": positive["variant_count"] + positive["archived_variant_count"],
|
|
}[archive]
|
|
where = "pp.positive_prompt_id=?" + scope
|
|
params = [positive_id]
|
|
if variant_after is not None:
|
|
where += " AND pp.id<?"
|
|
params.append(variant_after)
|
|
variants = [
|
|
dict(row)
|
|
for row in conn.execute(
|
|
"SELECT pp.id,COALESCE(n.text,'') AS negative_text,"
|
|
"pp.category,pp.title,pp.archived,"
|
|
"pp.negative_prompt_id IS NULL AS explicit_empty,"
|
|
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
|
|
"WHERE ip.pair_id=pp.id) AS image_count "
|
|
"FROM prompt_pairs pp "
|
|
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
|
|
"WHERE " + where + " ORDER BY pp.id DESC LIMIT ?",
|
|
[*params, variant_limit + 1],
|
|
)
|
|
]
|
|
variants_has_more = len(variants) > variant_limit
|
|
variants = variants[:variant_limit]
|
|
by_id = {row["id"]: row for row in variants}
|
|
for row in variants:
|
|
row["tags"] = []
|
|
row["archived"] = bool(row["archived"])
|
|
row["explicit_empty"] = bool(row["explicit_empty"])
|
|
if by_id:
|
|
marks = ",".join("?" for _ in by_id)
|
|
for row in conn.execute(
|
|
"SELECT pt.pair_id,t.name FROM prompt_pair_tags pt "
|
|
"JOIN tags t ON t.id=pt.tag_id "
|
|
f"WHERE pt.pair_id IN ({marks}) ORDER BY t.name",
|
|
list(by_id),
|
|
):
|
|
by_id[row["pair_id"]]["tags"].append(row["name"])
|
|
unassigned = conn.execute(
|
|
"SELECT COUNT(*) FROM generated_images g WHERE g.prompt_id=? "
|
|
"AND NOT EXISTS(SELECT 1 FROM image_prompt_pairs ip "
|
|
"WHERE ip.image_id=g.id)",
|
|
(positive_id,),
|
|
).fetchone()[0]
|
|
return {
|
|
"library_epoch": epoch,
|
|
"positive": positive,
|
|
"variants": variants,
|
|
"unassigned_image_count": unassigned,
|
|
"variant_total": variant_total,
|
|
"variants_has_more": variants_has_more,
|
|
"variants_next_key": variants[-1]["id"] if variants_has_more else None,
|
|
}
|
|
|
|
def get_library_positive(
|
|
self,
|
|
positive_id: int,
|
|
*,
|
|
variant_limit: int = 40,
|
|
variant_after: int | None = None,
|
|
archive: str = "active",
|
|
) -> dict | None:
|
|
with self.library_snapshot() as (conn, epoch, _ceiling):
|
|
return self._get_library_positive(
|
|
conn,
|
|
epoch,
|
|
positive_id,
|
|
variant_limit=variant_limit,
|
|
variant_after=variant_after,
|
|
archive=archive,
|
|
)
|
|
|
|
def _get_library_options(self, conn: sqlite3.Connection, epoch: str) -> dict:
|
|
rows = conn.execute(
|
|
"SELECT category FROM (SELECT p.category AS category "
|
|
"FROM prompts p WHERE "
|
|
+ _ELIGIBLE
|
|
+ (" UNION SELECT category FROM prompt_pairs " "WHERE archived=0) ")
|
|
+ "WHERE category IS NOT NULL AND TRIM(category)!='' "
|
|
"ORDER BY category COLLATE NOCASE,category"
|
|
).fetchall()
|
|
return {
|
|
"library_epoch": epoch,
|
|
"categories": [row["category"] for row in rows],
|
|
}
|
|
|
|
def get_library_options(self) -> dict:
|
|
with self.library_snapshot() as (conn, epoch, _ceiling):
|
|
return self._get_library_options(conn, epoch)
|
|
|
|
def get_library_previews(self, positive_ids: list[int]) -> dict[int, list[dict]]:
|
|
"""Read at most two links per parent in one bounded page query."""
|
|
if (
|
|
not isinstance(positive_ids, list)
|
|
or len(positive_ids) > 200
|
|
or any(type(value) is not int or value <= 0 for value in positive_ids)
|
|
):
|
|
raise ValueError("Invalid preview parent IDs")
|
|
if not positive_ids:
|
|
return {}
|
|
result = {value: [] for value in positive_ids}
|
|
marks = ",".join("?" for _ in positive_ids)
|
|
rows = self.model.get_connection().execute(
|
|
"WITH ranked AS (SELECT id,prompt_id,image_path,ROW_NUMBER() OVER "
|
|
"(PARTITION BY prompt_id ORDER BY id DESC) AS position "
|
|
f"FROM generated_images WHERE prompt_id IN ({marks})) "
|
|
"SELECT id,prompt_id,image_path FROM ranked WHERE position<=2 "
|
|
"ORDER BY prompt_id,id DESC",
|
|
positive_ids,
|
|
)
|
|
for row in rows:
|
|
result[row["prompt_id"]].append(dict(row))
|
|
return result
|
|
|
|
def _list_library_images(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
epoch: str,
|
|
positive_id: int,
|
|
*,
|
|
pair_id: int | None = None,
|
|
limit: int = 40,
|
|
after: int | None = None,
|
|
archive: str = "active",
|
|
) -> dict:
|
|
_limit(limit)
|
|
scope = _archive(archive)
|
|
if after is not None and (type(after) is not int or after <= 0):
|
|
raise ValueError("Invalid image cursor")
|
|
if (
|
|
pair_id is not None
|
|
and not conn.execute(
|
|
"SELECT 1 FROM prompt_pairs pp WHERE pp.id=? "
|
|
"AND pp.positive_prompt_id=?" + scope,
|
|
(pair_id, positive_id),
|
|
).fetchone()
|
|
):
|
|
if conn.execute(
|
|
"SELECT 1 FROM prompt_pairs WHERE id=? AND positive_prompt_id=?",
|
|
(pair_id, positive_id),
|
|
).fetchone():
|
|
raise ValueError("Variant is not in the requested archive scope")
|
|
raise ValueError("Variant does not belong to this positive prompt")
|
|
where, params = "g.prompt_id=?", [positive_id]
|
|
if pair_id is not None:
|
|
where += (
|
|
" AND EXISTS(SELECT 1 FROM image_prompt_pairs ip "
|
|
"WHERE ip.image_id=g.id AND ip.pair_id=?)"
|
|
)
|
|
params.append(pair_id)
|
|
total = conn.execute(
|
|
"SELECT COUNT(*) FROM generated_images g WHERE " + where, params
|
|
).fetchone()[0]
|
|
if after is not None:
|
|
where += " AND g.id<?"
|
|
params.append(after)
|
|
rows = conn.execute(
|
|
"SELECT g.id,g.image_path FROM generated_images g WHERE "
|
|
+ where
|
|
+ " ORDER BY g.id DESC LIMIT ?",
|
|
[*params, limit + 1],
|
|
).fetchall()
|
|
return {
|
|
"library_epoch": epoch,
|
|
"images": [dict(row) for row in rows[:limit]],
|
|
"total": total,
|
|
"has_more": len(rows) > limit,
|
|
"next_key": rows[limit - 1]["id"] if len(rows) > limit else None,
|
|
}
|
|
|
|
def list_library_images(
|
|
self,
|
|
positive_id: int,
|
|
*,
|
|
pair_id: int | None = None,
|
|
limit: int = 40,
|
|
after: int | None = None,
|
|
archive: str = "active",
|
|
) -> dict:
|
|
with self.library_snapshot() as (conn, epoch, _ceiling):
|
|
return self._list_library_images(
|
|
conn,
|
|
epoch,
|
|
positive_id,
|
|
pair_id=pair_id,
|
|
limit=limit,
|
|
after=after,
|
|
archive=archive,
|
|
)
|
|
|
|
def export_library(self) -> dict:
|
|
"""Read every portable owner and pair from one snapshot, writing nothing.
|
|
|
|
Local IDs order the rows internally and never appear in the document.
|
|
"""
|
|
with self.library_snapshot() as (conn, _epoch, _ceiling):
|
|
owners = {}
|
|
order = []
|
|
for row in conn.execute(
|
|
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes FROM "
|
|
+ _FROM
|
|
+ " WHERE "
|
|
+ _ELIGIBLE
|
|
+ " ORDER BY p.id"
|
|
):
|
|
owners[row["id"]] = {
|
|
"positive_text": row["text"],
|
|
"title": row["title"],
|
|
"category": row["category"],
|
|
"tags": [],
|
|
"rating": row["rating"],
|
|
"notes": row["notes"],
|
|
"variants": [],
|
|
}
|
|
order.append(row["id"])
|
|
for row in conn.execute(
|
|
"SELECT pt.prompt_id,t.name FROM prompt_tags pt "
|
|
"JOIN tags t ON t.id=pt.tag_id ORDER BY pt.prompt_id,t.name"
|
|
):
|
|
if row["prompt_id"] in owners:
|
|
owners[row["prompt_id"]]["tags"].append(row["name"])
|
|
variants = {}
|
|
for row in conn.execute(
|
|
"SELECT pp.id,pp.positive_prompt_id,"
|
|
"COALESCE(n.text,'') AS negative_text,"
|
|
"pp.title,pp.category,pp.rating,pp.notes,pp.archived "
|
|
"FROM prompt_pairs pp "
|
|
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
|
|
"ORDER BY pp.id"
|
|
):
|
|
variant = {
|
|
"negative_text": row["negative_text"],
|
|
"title": row["title"],
|
|
"category": row["category"],
|
|
"tags": [],
|
|
"rating": row["rating"],
|
|
"notes": row["notes"],
|
|
"archived": bool(row["archived"]),
|
|
}
|
|
variants[row["id"]] = variant
|
|
owners[row["positive_prompt_id"]]["variants"].append(variant)
|
|
for row in conn.execute(
|
|
"SELECT pt.pair_id,t.name FROM prompt_pair_tags pt "
|
|
"JOIN tags t ON t.id=pt.tag_id ORDER BY pt.pair_id,t.name"
|
|
):
|
|
variants[row["pair_id"]]["tags"].append(row["name"])
|
|
return {
|
|
"schema_version": 1,
|
|
"kind": "prompt-manager-library",
|
|
"positives": [owners[key] for key in order],
|
|
}
|