A prompt_archive table marks Library parents hidden from the shelf; older builds ignore it. New routes /prompt_manager/library/delete/preview, /library/delete and /library/restore classify a selection (delete, archive, protected, already archived, not found), execute exactly what was reviewed under a records hold, take a verified safety backup (disk checked) before any delete, and restore archived prompts. The shelf, search and the shelf-facing counts honor the archive (?shelf=active|archived); archiving repairs a stale prompt hash so scans find the archived row.
290 lines
12 KiB
Python
290 lines
12 KiB
Python
"""Delete selected Library prompts; archive the paired ones (#443, D152).
|
|
|
|
A selected Library parent is classified, in this order, as ``not_found``
|
|
(missing, or not a Library parent), ``protected`` (tagged ``__protected__``;
|
|
never changed), ``already_archived``, ``archive`` (the positive or negative of
|
|
any saved pair, archived pair or not: archiving hides it from the shelf and
|
|
removes nothing) or ``delete`` (in no pair: deleted exactly as the legacy
|
|
delete does, through ``delete_prompt_rows``). Every command checks the
|
|
storage epoch first and runs in one ``BEGIN IMMEDIATE`` transaction.
|
|
|
|
The write executes what the user reviewed: it is given the reviewed archive
|
|
and delete lists, and deletes only reviewed deletes the route backed up
|
|
(``candidates``) whose fingerprint is unchanged since that backup.
|
|
"""
|
|
|
|
import sqlite3
|
|
from typing import Iterable
|
|
|
|
from .library import _ELIGIBLE
|
|
from .library_commands import _validate_bulk_ids, _validate_epoch
|
|
from .models import utc_now_iso
|
|
from .prompt_archive import ARCHIVED as _ARCHIVED
|
|
from .prompt_archive import TABLE as ARCHIVE
|
|
from .workflow_documents import documents_of_rows, release_documents
|
|
|
|
try:
|
|
from ..utils.hashing import generate_prompt_hash
|
|
except ImportError:
|
|
from utils.hashing import generate_prompt_hash
|
|
|
|
# Ids bound per statement: the pair check binds each id twice, which stays
|
|
# under SQLite's historical 999-variable limit (maintenance.py ID_BATCH).
|
|
ID_BATCH = 400
|
|
_PROTECTED = (
|
|
"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='__protected__')"
|
|
)
|
|
_PAIRED = (
|
|
"EXISTS(SELECT 1 FROM prompt_pairs pp "
|
|
"WHERE pp.positive_prompt_id=p.id OR pp.negative_prompt_id=p.id)"
|
|
)
|
|
# Classes in the order a prompt is tested; the first that holds wins.
|
|
CLASSES = ("not_found", "protected", "already_archived", "archive", "delete")
|
|
|
|
|
|
def _batches(ids: list) -> Iterable[list]:
|
|
for start in range(0, len(ids), ID_BATCH):
|
|
yield ids[start : start + ID_BATCH]
|
|
|
|
|
|
def delete_prompt_rows(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
|
|
"""Delete prompts and their image link rows; return the prompts deleted.
|
|
|
|
The legacy delete's statements (``delete_prompt``, ``bulk_delete_prompts``)
|
|
in the caller's transaction: image link rows first (``image_prompt_pairs``
|
|
cascades, review decisions keep a NULL image), then the prompts (tags and
|
|
Library state cascade), then the workflow documents no row references.
|
|
Image files are never touched. The caller checks pairs first.
|
|
"""
|
|
documents = documents_of_rows(conn, "prompt_id", prompt_ids)
|
|
deleted = 0
|
|
for chunk in _batches(list(prompt_ids)):
|
|
marks = ",".join("?" for _ in chunk)
|
|
conn.execute(
|
|
f"DELETE FROM generated_images WHERE prompt_id IN ({marks})", # nosec
|
|
chunk,
|
|
)
|
|
deleted += conn.execute(
|
|
f"DELETE FROM prompts WHERE id IN ({marks})", chunk # nosec B608
|
|
).rowcount
|
|
release_documents(conn, documents)
|
|
return deleted
|
|
|
|
|
|
class LibraryArchiveOperations:
|
|
"""PromptDatabase mixin: classify, archive or delete, and restore parents."""
|
|
|
|
def _classify_library_delete(
|
|
self, conn: sqlite3.Connection, prompt_ids: list[int]
|
|
) -> dict:
|
|
found = {}
|
|
for chunk in _batches(prompt_ids):
|
|
marks = ",".join("?" for _ in chunk)
|
|
for row in conn.execute(
|
|
f"SELECT p.id,{_PROTECTED},{_ARCHIVED},{_PAIRED} " # nosec B608
|
|
f"FROM prompts p WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
|
|
chunk,
|
|
):
|
|
protected, archived, paired = row[1], row[2], row[3]
|
|
if protected:
|
|
found[row[0]] = "protected"
|
|
elif archived:
|
|
found[row[0]] = "already_archived"
|
|
else:
|
|
found[row[0]] = "archive" if paired else "delete"
|
|
classes = {name: [] for name in CLASSES}
|
|
for pid in prompt_ids:
|
|
classes[found.get(pid, "not_found")].append(pid)
|
|
return classes
|
|
|
|
@staticmethod
|
|
def _image_links(conn: sqlite3.Connection, prompt_ids: list[int]) -> int:
|
|
total = 0
|
|
for chunk in _batches(prompt_ids):
|
|
marks = ",".join("?" for _ in chunk)
|
|
total += conn.execute(
|
|
"SELECT COUNT(*) FROM generated_images " # nosec B608
|
|
f"WHERE prompt_id IN ({marks})",
|
|
chunk,
|
|
).fetchone()[0]
|
|
return total
|
|
|
|
@staticmethod
|
|
def _fingerprints(conn: sqlite3.Connection, prompt_ids: list[int]) -> dict:
|
|
"""What a safety backup holds of each prompt a delete would remove.
|
|
|
|
The prompt row (usage included), its tag ids, its Library title and
|
|
its image link rows (count and newest id), as a JSON-safe list.
|
|
"""
|
|
prints = {}
|
|
for chunk in _batches(prompt_ids):
|
|
marks = ",".join("?" for _ in chunk)
|
|
for row in conn.execute(
|
|
"SELECT p.id,p.text,p.hash,p.category,p.rating,p.notes," # nosec
|
|
"p.updated_at,p.run_count,p.last_used_at,"
|
|
"(SELECT group_concat(tag_id) FROM (SELECT tag_id FROM prompt_tags "
|
|
"WHERE prompt_id=p.id ORDER BY tag_id)),"
|
|
"(SELECT title FROM prompt_library_state WHERE prompt_id=p.id),"
|
|
"(SELECT COUNT(*) FROM generated_images WHERE prompt_id=p.id),"
|
|
"(SELECT MAX(id) FROM generated_images WHERE prompt_id=p.id) "
|
|
f"FROM prompts p WHERE p.id IN ({marks})",
|
|
chunk,
|
|
):
|
|
prints[row[0]] = [
|
|
value if isinstance(value, (int, str, type(None))) else repr(value)
|
|
for value in tuple(row)[1:]
|
|
]
|
|
return prints
|
|
|
|
@staticmethod
|
|
def _checked_ids(prompt_ids: list[int], expected_epoch: str) -> list[int]:
|
|
_validate_epoch(expected_epoch)
|
|
_validate_bulk_ids(prompt_ids)
|
|
return list(dict.fromkeys(prompt_ids))
|
|
|
|
def classify_library_delete(
|
|
self, prompt_ids: list[int], *, expected_epoch: str
|
|
) -> dict:
|
|
"""What a delete of ``prompt_ids`` would do now (reads only).
|
|
|
|
Returns the id lists per class, in request order, ``image_links``
|
|
(the image link rows the ``delete`` class would remove) and
|
|
``fingerprints`` of the ``delete`` class (``_fingerprints``).
|
|
"""
|
|
prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
|
|
with self.library_snapshot() as (conn, _epoch, _ceiling):
|
|
epoch = self._library_epoch(conn, expected_epoch)
|
|
classes = self._classify_library_delete(conn, prompt_ids)
|
|
return {
|
|
"library_epoch": epoch,
|
|
**classes,
|
|
"image_links": self._image_links(conn, classes["delete"]),
|
|
"fingerprints": self._fingerprints(conn, classes["delete"]),
|
|
}
|
|
|
|
@staticmethod
|
|
def _repair_hash(conn: sqlite3.Connection, prompt_id: int) -> None:
|
|
"""Give an archived prompt its canonical hash, so ingestion finds it.
|
|
|
|
Scans, the monitor and node saves resolve prompts by hash; a stale or
|
|
missing one would make them insert a visible copy (Q3). When another
|
|
row already holds the canonical hash, lookups find that row and no new
|
|
copy is made, so nothing is written.
|
|
"""
|
|
text, stored = conn.execute(
|
|
"SELECT text,hash FROM prompts WHERE id=?", (prompt_id,)
|
|
).fetchone()
|
|
canonical = generate_prompt_hash(text) if isinstance(text, str) else None
|
|
if canonical is None or stored == canonical:
|
|
return
|
|
if conn.execute(
|
|
"SELECT 1 FROM prompts WHERE hash=? AND id!=?", (canonical, prompt_id)
|
|
).fetchone():
|
|
return
|
|
conn.execute("UPDATE prompts SET hash=? WHERE id=?", (canonical, prompt_id))
|
|
|
|
def archive_or_delete_library_prompts(
|
|
self,
|
|
archive_ids: list[int],
|
|
delete_ids: list[int],
|
|
*,
|
|
expected_epoch: str,
|
|
candidates: dict | None,
|
|
) -> dict:
|
|
"""Archive and delete what the user reviewed, all or nothing.
|
|
|
|
``archive_ids`` and ``delete_ids`` are the reviewed classes.
|
|
``candidates`` maps each reviewed delete the route backed up to its
|
|
fingerprint then (None: every reviewed delete, unchecked; tests and
|
|
callers without a backup). A reviewed delete is deleted only when it
|
|
still classifies ``delete`` and its fingerprint is unchanged; one that
|
|
is now paired, and a reviewed archive now unpaired, are archived (the
|
|
restorable direction); anything else is kept and counted.
|
|
"""
|
|
_validate_epoch(expected_epoch)
|
|
for ids in (archive_ids, delete_ids):
|
|
if not isinstance(ids, list) or any(
|
|
type(pid) is not int or not 1 <= pid <= 2**63 - 1 for pid in ids
|
|
):
|
|
raise ValueError("prompt ids must be lists of positive integers")
|
|
reviewed_delete = set(delete_ids)
|
|
if not archive_ids and not delete_ids:
|
|
raise ValueError("Nothing to delete or archive")
|
|
if reviewed_delete & set(archive_ids):
|
|
raise ValueError("A prompt cannot be both archived and deleted")
|
|
prompt_ids = list(dict.fromkeys([*archive_ids, *delete_ids]))
|
|
with self._pair_transaction() as conn:
|
|
epoch = self._library_epoch(conn, expected_epoch)
|
|
classes = self._classify_library_delete(conn, prompt_ids)
|
|
now_prints = self._fingerprints(conn, classes["delete"])
|
|
deletes, archives, changed = [], list(classes["archive"]), 0
|
|
for pid in classes["delete"]:
|
|
if pid not in reviewed_delete:
|
|
archives.append(pid) # reviewed as archive, now unpaired
|
|
elif candidates is None or candidates.get(pid) == now_prints[pid]:
|
|
deletes.append(pid)
|
|
else:
|
|
changed += 1
|
|
changed += len(classes["protected"])
|
|
image_links = self._image_links(conn, deletes)
|
|
now = utc_now_iso()
|
|
for pid in archives:
|
|
self._repair_hash(conn, pid)
|
|
conn.executemany(
|
|
f"INSERT INTO {ARCHIVE}(prompt_id, archived_at) VALUES (?, ?)",
|
|
[(pid, now) for pid in archives],
|
|
)
|
|
# A changed row is the newest edit the restore fingerprint reads.
|
|
conn.executemany(
|
|
"UPDATE prompts SET updated_at=? WHERE id=?",
|
|
[(now, pid) for pid in archives],
|
|
)
|
|
deleted = delete_prompt_rows(conn, deletes)
|
|
return {
|
|
"library_epoch": epoch,
|
|
"deleted": deleted,
|
|
"archived": len(archives),
|
|
"already_archived": len(classes["already_archived"]),
|
|
"not_found": len(classes["not_found"]),
|
|
"changed": changed,
|
|
"image_links": image_links,
|
|
}
|
|
|
|
def restore_library_prompts(
|
|
self, prompt_ids: list[int], *, expected_epoch: str
|
|
) -> dict:
|
|
"""Put archived prompts back on the shelf, all or nothing.
|
|
|
|
Every requested id with an archive row is restored, whatever its
|
|
protection or Library parent state: restoring only un-hides it.
|
|
"""
|
|
prompt_ids = self._checked_ids(prompt_ids, expected_epoch)
|
|
with self._pair_transaction() as conn:
|
|
epoch = self._library_epoch(conn, expected_epoch)
|
|
restored, existing = [], set()
|
|
for chunk in _batches(prompt_ids):
|
|
marks = ",".join("?" for _ in chunk)
|
|
for pid, archived in conn.execute(
|
|
f"SELECT p.id,{_ARCHIVED} FROM prompts p " # nosec B608
|
|
f"WHERE p.id IN ({marks})",
|
|
chunk,
|
|
):
|
|
existing.add(pid)
|
|
if archived:
|
|
restored.append(pid)
|
|
now = utc_now_iso()
|
|
conn.executemany(
|
|
f"DELETE FROM {ARCHIVE} WHERE prompt_id=?", [(pid,) for pid in restored]
|
|
)
|
|
conn.executemany(
|
|
"UPDATE prompts SET updated_at=? WHERE id=?",
|
|
[(now, pid) for pid in restored],
|
|
)
|
|
return {
|
|
"library_epoch": epoch,
|
|
"restored": len(restored),
|
|
"unchanged": len(existing) - len(restored),
|
|
"not_found": len(prompt_ids) - len(existing),
|
|
}
|