* feat(trash): a freedesktop trash backend that renames without replacing or refuses (#480) * feat(images): remove a file's records and hide it with a removal mark (#480) Deletes every record of one file under every recorded spelling (image links, tags, auto-tag marks, pending review items, category, collection memberships, favorite) in one transaction. A Needs review item that is kept keeps its annotation rows (ON DELETE RESTRICT). The image_removals table hides an exact file (locator, size, mtime_ns) until it changes; linking skips a marked file, and an explicit tag, category, favorite or collection write shows it again. * feat(images): review and apply Delete images as a reviewed plan job (#480) POST /prompt_manager/images/delete/preview reviews both modes; the maintenance apply takes {token, mode}. Library mode removes the records and writes the removal mark; trash mode moves the file first and removes its records only after a successful move. The workspace listing, the output scan, the duplicate find and Tag output images skip marked files. * feat(images): Delete… in the Images selection bar, inspector and viewer (#480) The concept's Remove links… place, D159: one review per selection that asks each time (nothing preselected) whether to remove the files from the library only or also move them to the trash. The browser test server uses a fake trash. * test(images): portable delete tests, the native no-trash proof, D159 in TESTING.md (#480) * perf(images): cache the removal marks so a listing page reads no extra table (#480) The marks reload only when this process writes or clears one, or a restore replaces the database. The route snapshot gains the preview route. * test(images): the full inspector specs expect D159's Delete… in the panel and the viewer (#480) * fix(images): the delete review starts nothing without a chosen mode, and dims its primary until then (#480) An aria-disabled primary ignores every activation, a chosen one starts once, and the apply helper refuses a missing mode. Tests cover a forced click, a double click, Enter and Space on the primary, Enter in the dialog and a submit event, and the server refusing an apply without an exact mode.
156 lines
6.3 KiB
Python
156 lines
6.3 KiB
Python
"""Persisted image favorites, keyed by gallery root + relative path.
|
|
|
|
The root key is the root's real, case-normalized path and the relative key is
|
|
case-normalized the same way (see ``py/api/images.py``); both stay
|
|
server-side. Folder names alone are not unique across roots, so a favorite
|
|
follows the directory itself when the configured roots are reordered.
|
|
Optional in old backups, required once a database is current (see
|
|
``library_schema.py``).
|
|
"""
|
|
|
|
import re
|
|
import sqlite3
|
|
from typing import Iterable
|
|
|
|
TABLE = "image_favorites"
|
|
MAX_KEY_LENGTH = 4096
|
|
_LOOKUP_CHUNK = 500
|
|
_DDL = (
|
|
f"CREATE TABLE {TABLE} (root_key TEXT NOT NULL, relative_path TEXT NOT NULL, "
|
|
"created_at TEXT NOT NULL, PRIMARY KEY(root_key, relative_path))"
|
|
)
|
|
_AUTOINDEX = f"sqlite_autoindex_{TABLE}_1"
|
|
_COLUMNS = (
|
|
("root_key", "TEXT", 1, 1),
|
|
("relative_path", "TEXT", 1, 2),
|
|
("created_at", "TEXT", 1, 0),
|
|
)
|
|
|
|
|
|
def _compact(sql: str) -> str:
|
|
return re.sub(r"\s+", "", sql).lower()
|
|
|
|
|
|
def image_favorites_schema_error(
|
|
conn: sqlite3.Connection, *, allow_missing: bool = True
|
|
) -> str | None:
|
|
"""Reject unsupported favorites schemas and invalid persisted values.
|
|
|
|
Reads only. A missing table is acceptable for backups that predate it
|
|
unless ``allow_missing`` is false.
|
|
"""
|
|
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 favorites schema"
|
|
tables = [sql for kind, _name, sql in objects if kind == "table"]
|
|
others = {(kind, name, sql) for kind, name, sql in objects if kind != "table"}
|
|
if len(tables) != 1 or others != {("index", _AUTOINDEX, None)}:
|
|
return "Image favorites name is reserved by an unsupported schema object"
|
|
if _compact(tables[0] or "") != _compact(_DDL):
|
|
return "Image favorites has an invalid table definition"
|
|
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 favorites has invalid columns"
|
|
# 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' "
|
|
"OR typeof(relative_path) != 'text' OR typeof(created_at) != 'text' "
|
|
f"OR length(CAST(root_key AS BLOB)) > {limit} "
|
|
f"OR length(CAST(relative_path AS BLOB)) > {limit} "
|
|
"OR length(CAST(created_at AS BLOB)) > 64 LIMIT 1"
|
|
).fetchone():
|
|
return "Image favorites contains invalid values"
|
|
for root, path in conn.execute(
|
|
f"SELECT CAST(root_key AS BLOB), CAST(relative_path AS BLOB) FROM {TABLE} "
|
|
f"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 favorites contains invalid values"
|
|
return None
|
|
|
|
|
|
def migrate_image_favorites(conn: sqlite3.Connection) -> None:
|
|
"""Install the table atomically; validate before and after creating it."""
|
|
conn.execute("SAVEPOINT image_favorites_schema")
|
|
try:
|
|
error = image_favorites_schema_error(conn)
|
|
if error:
|
|
raise sqlite3.DatabaseError(error)
|
|
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
|
error = image_favorites_schema_error(conn, allow_missing=False)
|
|
if error:
|
|
raise sqlite3.DatabaseError(error)
|
|
conn.execute("RELEASE image_favorites_schema")
|
|
except BaseException:
|
|
conn.execute("ROLLBACK TO image_favorites_schema")
|
|
conn.execute("RELEASE image_favorites_schema")
|
|
raise
|
|
|
|
|
|
class ImageFavoriteOperations:
|
|
"""PromptDatabase mixin: set, clear and look up image favorites."""
|
|
|
|
def set_image_favorite(self, root_key: str, relative_path: str) -> bool:
|
|
"""Mark one file as a favorite; repeating it is a no-op. Returns True."""
|
|
from .models import utc_now_iso # models imports this module's schema
|
|
|
|
from .image_removal_schema import clear_marks_of_keys
|
|
|
|
with self.model.get_connection() as conn:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO image_favorites VALUES (?, ?, ?)",
|
|
(root_key, relative_path, utc_now_iso()),
|
|
)
|
|
clear_marks_of_keys(conn, [(root_key, relative_path)]) # #480
|
|
return True
|
|
|
|
def clear_image_favorite(self, root_key: str, relative_path: str) -> bool:
|
|
"""Remove one favorite; True when a row was removed."""
|
|
with self.model.get_connection() as conn:
|
|
cursor = conn.execute(
|
|
"DELETE FROM image_favorites WHERE root_key=? AND relative_path=?",
|
|
(root_key, relative_path),
|
|
)
|
|
return cursor.rowcount > 0
|
|
|
|
def get_image_favorites(
|
|
self, root_key: str, relative_paths: Iterable[str]
|
|
) -> set[str]:
|
|
"""The subset of *relative_paths* favorited under *root_key*."""
|
|
wanted = list(dict.fromkeys(relative_paths))
|
|
found: set[str] = set()
|
|
conn = self.model.get_connection()
|
|
for start in range(0, len(wanted), _LOOKUP_CHUNK):
|
|
chunk = wanted[start : start + _LOOKUP_CHUNK]
|
|
marks = ",".join("?" * len(chunk))
|
|
rows = conn.execute( # only "?" placeholders are interpolated
|
|
"SELECT relative_path FROM image_favorites WHERE root_key=? "
|
|
f"AND relative_path IN ({marks})", # nosec B608
|
|
(root_key, *chunk),
|
|
)
|
|
found.update(path for (path,) in rows)
|
|
return found
|
|
|
|
def get_root_favorites(self, root_key: str) -> set[str]:
|
|
"""Every relative key favorited under *root_key* (a primary-key range).
|
|
|
|
The Favorites listing (``favorites=1``) intersects these with the
|
|
root's scan, so its size follows the root's favorites, not its files.
|
|
"""
|
|
conn = self.model.get_connection()
|
|
rows = conn.execute(
|
|
"SELECT relative_path FROM image_favorites WHERE root_key=?",
|
|
(root_key,),
|
|
)
|
|
return {path for (path,) in rows}
|