* feat(images): store images' own categories in the library database (#332) One new table, image_categories, and its category index, created empty by a forward-only migration that reads and writes only its own objects. Startup checks the structure; restore also checks the values. The store sets, clears and reads own categories by file key (the deepest output source holding the file and the path below it), and copies a removed duplicate's category to a kept copy that has none. * feat(images): duplicate cleanup keeps images' own categories (#332) The kept copy's own category wins; a kept copy without one receives the removed copy's, keyed by the deepest output source holding each file. The removed copy's row is never deleted, so a give-back or a failed delete loses nothing. The job log reports how many were kept. * feat(images): set, remove and list images' own categories (#332) PUT /prompt_manager/images/category sets the own category of up to 500 listed files, DELETE removes it (listed or not), and GET /prompt_manager/images/categories lists the values whose file is listed. Targets are the Images listing identity of an output source, checked by media_target (shared with Collections). The routes never touch prompts, recipes or links; POST /prompt_manager/bulk/category is unchanged. * fix(images): key symlinked output folders and keep key work off the event loop (#332) Review fixes: a source matches by its configured spelling too, so a detected output folder that is a symlink keys its files (routes and duplicate cleanup). File keys are computed in the executor. A path no source holds is a plain 403, a lone surrogate a 400. The install repairs a table that lacks its category index instead of refusing to open. * feat(images): set or remove a category on all N matching images (#332) The category routes also accept the Images listing filter of "Select all N matching" (#392) instead of explicit items, resolved now and capped at 500 images like an explicit selection. * test(vendor): count the #332 host case in the execution matrix image-attribution.spec.mjs gains the image category route case, so the execution config lists 92 tests (3 attribution cases per project). * test(legacy): record the image category routes in the legacy contract snapshot (#332) #332 adds PUT/DELETE /prompt_manager/images/category and GET /prompt_manager/images/categories; the legacy pages' routes are unchanged.
250 lines
10 KiB
Python
250 lines
10 KiB
Python
"""Image categories (#332): an image file's own category.
|
|
|
|
An image shows its own category when it has one, otherwise the category its
|
|
link supplies (its recipe's, else its positive prompt's); this table holds
|
|
only the own values. A row is keyed by the file key: the deepest configured
|
|
output source that contains the file (its real, case-normalized path) and the
|
|
path below it, case-folded where the OS folds case
|
|
(``py/api/images.py`` ``image_category_key``). Without nested sources the key
|
|
equals a favorite's key. Nothing follows a moved file: its row stays and is
|
|
hidden until a file appears at the same path again.
|
|
|
|
Writes validate strictly (the route strips the value and applies
|
|
``validate_category``). Startup checks the structure only, so a value saved
|
|
under older rules never stops the library opening; restore also checks the
|
|
values. Optional in old backups, required once a database is current.
|
|
"""
|
|
|
|
import re
|
|
import sqlite3
|
|
from typing import Iterable
|
|
|
|
TABLE = "image_categories"
|
|
INDEX = "idx_image_categories_category"
|
|
MAX_KEY_LENGTH = 4096
|
|
MAX_CATEGORY_LENGTH = 100
|
|
_DDL = (
|
|
f"CREATE TABLE {TABLE} (root_key TEXT NOT NULL, relative_path TEXT NOT NULL, "
|
|
"category TEXT NOT NULL, updated_at TEXT NOT NULL, "
|
|
"PRIMARY KEY (root_key, relative_path))"
|
|
)
|
|
_INDEX_DDL = f"CREATE INDEX {INDEX} ON {TABLE}(category)"
|
|
_AUTOINDEX = f"sqlite_autoindex_{TABLE}_1"
|
|
_COLUMNS = (
|
|
("root_key", "TEXT", 1, 1),
|
|
("relative_path", "TEXT", 1, 2),
|
|
("category", "TEXT", 1, 0),
|
|
("updated_at", "TEXT", 1, 0),
|
|
)
|
|
# A category may not hold a character below U+0020 (validate_category).
|
|
_CONTROL_GLOB = "*[" + "".join(chr(code) for code in range(1, 32)) + "]*"
|
|
|
|
CATEGORY_OF_SQL = (
|
|
f"SELECT category FROM {TABLE} WHERE root_key = ? AND relative_path = ?"
|
|
)
|
|
CATEGORIES_OF_ROOT_SQL = (
|
|
f"SELECT relative_path, category FROM {TABLE} WHERE root_key = ?"
|
|
)
|
|
UPSERT_SQL = (
|
|
f"INSERT INTO {TABLE} (root_key, relative_path, category, updated_at) "
|
|
"VALUES (?, ?, ?, ?) ON CONFLICT(root_key, relative_path) "
|
|
"DO UPDATE SET category = excluded.category, updated_at = excluded.updated_at"
|
|
)
|
|
DELETE_SQL = f"DELETE FROM {TABLE} WHERE root_key = ? AND relative_path = ?"
|
|
# The duplicate cleanup's copy (#332 MC11): the removed copy's row is copied
|
|
# to the kept copy only when the kept copy has none; it is never deleted.
|
|
COPY_SQL = (
|
|
f"INSERT OR IGNORE INTO {TABLE} (root_key, relative_path, category, updated_at) "
|
|
f"SELECT ?, ?, category, updated_at FROM {TABLE} "
|
|
"WHERE root_key = ? AND relative_path = ?"
|
|
)
|
|
STATEMENTS = (CATEGORY_OF_SQL, CATEGORIES_OF_ROOT_SQL, UPSERT_SQL, DELETE_SQL, COPY_SQL)
|
|
|
|
|
|
def _compact(sql: str) -> str:
|
|
return re.sub(r"\s+", "", sql or "").lower()
|
|
|
|
|
|
def _structure_error(
|
|
conn: sqlite3.Connection, allow_missing: bool, allow_missing_index: bool = False
|
|
) -> str | None:
|
|
objects = conn.execute(
|
|
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
|
|
"OR (type IN ('index','trigger') AND tbl_name=?)",
|
|
(TABLE, TABLE),
|
|
).fetchall()
|
|
if not objects:
|
|
return None if allow_missing else "Missing image categories schema"
|
|
tables = [sql for kind, _name, sql in objects if kind == "table"]
|
|
others = {(kind, name) for kind, name, _sql in objects if kind != "table"}
|
|
expected = {("index", _AUTOINDEX), ("index", INDEX)}
|
|
# Before its install, a table without the category index is repaired by
|
|
# CREATE INDEX IF NOT EXISTS rather than refused.
|
|
if allow_missing_index and others == {("index", _AUTOINDEX)}:
|
|
expected = others
|
|
if len(tables) != 1 or others != expected:
|
|
return "Image categories name is reserved by an unsupported schema object"
|
|
if _compact(tables[0]) != _compact(_DDL):
|
|
return "Image categories has an invalid table definition"
|
|
index_sql = next((sql for kind, name, sql in objects if name == INDEX), None)
|
|
if ("index", INDEX) in expected and _compact(index_sql) != _compact(_INDEX_DDL):
|
|
return "Image categories has an invalid index"
|
|
autoindex_sql = next(sql for kind, name, sql in objects if name == _AUTOINDEX)
|
|
if autoindex_sql is not None:
|
|
return "Image categories has an invalid index"
|
|
columns = tuple(
|
|
(row[1], row[2].upper(), row[3], row[5])
|
|
for row in conn.execute(f"PRAGMA table_info({TABLE})")
|
|
)
|
|
if columns != _COLUMNS:
|
|
return "Image categories has invalid columns"
|
|
return None
|
|
|
|
|
|
def _values_error(conn: sqlite3.Connection) -> str | None:
|
|
# Bytes bound characters from above (UTF-8 is at most 4 bytes each).
|
|
limit = MAX_KEY_LENGTH * 4
|
|
if conn.execute(
|
|
f"SELECT 1 FROM {TABLE} WHERE typeof(root_key) != 'text' " # nosec B608
|
|
"OR typeof(relative_path) != 'text' OR typeof(category) != 'text' "
|
|
"OR typeof(updated_at) != 'text' "
|
|
f"OR length(CAST(root_key AS BLOB)) > {limit} "
|
|
f"OR length(CAST(relative_path AS BLOB)) > {limit} "
|
|
"OR length(CAST(updated_at AS BLOB)) > 64 "
|
|
"OR instr(CAST(category AS BLOB), x'00') > 0 "
|
|
f"OR length(category) < 1 OR length(category) > {MAX_CATEGORY_LENGTH} "
|
|
"OR category != TRIM(category) OR category GLOB ? LIMIT 1",
|
|
(_CONTROL_GLOB,),
|
|
).fetchone():
|
|
return "Image categories contains invalid values"
|
|
for root, path in conn.execute(
|
|
f"SELECT CAST(root_key AS BLOB), CAST(relative_path AS BLOB) " # nosec B608
|
|
f"FROM {TABLE} WHERE length(CAST(root_key AS BLOB)) > {MAX_KEY_LENGTH} "
|
|
f"OR length(CAST(relative_path AS BLOB)) > {MAX_KEY_LENGTH}"
|
|
):
|
|
for raw in (root, path):
|
|
if len(bytes(raw).decode("utf-8", "replace")) > MAX_KEY_LENGTH:
|
|
return "Image categories contains invalid values"
|
|
return None
|
|
|
|
|
|
def image_categories_schema_error(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
allow_missing: bool = True,
|
|
check_values: bool = True,
|
|
) -> str | None:
|
|
"""Reject an unsupported image categories schema (and, optionally, values).
|
|
|
|
Reads only. A missing table is acceptable for backups that predate it
|
|
unless ``allow_missing`` is false. ``check_values`` also refuses a stored
|
|
value the write path could never have written.
|
|
"""
|
|
error = _structure_error(conn, allow_missing)
|
|
if error is not None or not check_values:
|
|
return error
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (TABLE,)
|
|
).fetchone()
|
|
return _values_error(conn) if exists else None
|
|
|
|
|
|
def migrate_image_categories(conn: sqlite3.Connection) -> None:
|
|
"""Install the table and its index atomically; structure is checked
|
|
before and after. Reads only ``sqlite_master`` and this table's PRAGMAs
|
|
and writes only its own two objects (no backfill)."""
|
|
conn.execute("SAVEPOINT image_categories_schema")
|
|
try:
|
|
error = _structure_error(conn, True, allow_missing_index=True)
|
|
if error:
|
|
raise sqlite3.DatabaseError(error)
|
|
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
|
conn.execute(
|
|
_INDEX_DDL.replace("CREATE INDEX", "CREATE INDEX IF NOT EXISTS", 1)
|
|
)
|
|
error = image_categories_schema_error(
|
|
conn, allow_missing=False, check_values=False
|
|
)
|
|
if error:
|
|
raise sqlite3.DatabaseError(error)
|
|
conn.execute("RELEASE image_categories_schema")
|
|
except BaseException:
|
|
conn.execute("ROLLBACK TO image_categories_schema")
|
|
conn.execute("RELEASE image_categories_schema")
|
|
raise
|
|
|
|
|
|
def copy_image_category(conn: sqlite3.Connection, removed_key, kept_key) -> int:
|
|
"""Give the kept copy the removed copy's category when it has none.
|
|
|
|
Runs inside the caller's transaction; never deletes the removed copy's
|
|
row, so a give-back or a failed delete loses nothing. Returns 1 when a
|
|
row was inserted, else 0.
|
|
"""
|
|
if tuple(removed_key) == tuple(kept_key):
|
|
return 0
|
|
return max(conn.execute(COPY_SQL, (*kept_key, *removed_key)).rowcount, 0)
|
|
|
|
|
|
class ImageCategoryOperations:
|
|
"""PromptDatabase mixin: set, clear and read images' own categories.
|
|
|
|
Never reads or writes ``prompts``, ``prompt_pairs`` or
|
|
``generated_images``.
|
|
"""
|
|
|
|
def image_category(self, root_key: str, relative_key: str) -> str | None:
|
|
"""One file's own category, or None."""
|
|
row = (
|
|
self.model.get_connection()
|
|
.execute(CATEGORY_OF_SQL, (root_key, relative_key))
|
|
.fetchone()
|
|
)
|
|
return row[0] if row else None
|
|
|
|
def own_categories_of(self, root_keys: Iterable[str]) -> dict:
|
|
"""``{(root key, relative key): category}`` under the given sources.
|
|
|
|
One primary-key range read per source.
|
|
"""
|
|
found = {}
|
|
conn = self.model.get_connection()
|
|
for root_key in dict.fromkeys(root_keys):
|
|
for relative, category in conn.execute(CATEGORIES_OF_ROOT_SQL, (root_key,)):
|
|
found[(root_key, relative)] = category
|
|
return found
|
|
|
|
def set_image_categories(self, file_keys, category: str) -> dict:
|
|
"""Set *category* on every file key, in one transaction.
|
|
|
|
``file_keys``: ``(root key, relative key)`` pairs from any number of
|
|
sources. Returns ``{updated, unchanged}``; a file that already had
|
|
exactly this value is ``unchanged`` and keeps its ``updated_at``.
|
|
"""
|
|
from .models import utc_now_iso # models imports this module's schema
|
|
|
|
keys = list(dict.fromkeys(tuple(key) for key in file_keys))
|
|
counts = {"updated": 0, "unchanged": 0}
|
|
now = utc_now_iso()
|
|
with self._pair_transaction() as conn:
|
|
for key in keys:
|
|
row = conn.execute(CATEGORY_OF_SQL, key).fetchone()
|
|
if row is not None and row[0] == category:
|
|
counts["unchanged"] += 1
|
|
continue
|
|
conn.execute(UPSERT_SQL, (*key, category, now))
|
|
counts["updated"] += 1
|
|
return counts
|
|
|
|
def clear_image_categories(self, file_keys) -> int:
|
|
"""Remove the own category of every file key (one transaction).
|
|
|
|
Returns the number of rows removed.
|
|
"""
|
|
keys = list(dict.fromkeys(tuple(key) for key in file_keys))
|
|
removed = 0
|
|
with self._pair_transaction() as conn:
|
|
for key in keys:
|
|
removed += conn.execute(DELETE_SQL, key).rowcount
|
|
return removed
|