* fix(tagging): Tag output images tags each image, not its prompt (#379, PR 1) The native "Tag output images" job now runs the image-tags loop over every still image the output folders list, linked or not: each image gets its own model tags (at most "Suggestions per image") in its image tag set, and its bytes are marked auto-tagged in a new media_image_autotags table. No prompt tag or prompt text is written. - database/autotag_schema.py: one forward-only WITHOUT ROWID table, optional in old backups, refused without the annotation group. - add_image_tags/replace_image_tags take mark_autotagged; autotag_state and autotagged_locators read the marks through the one current-record rule. - "Skip already tagged" skips images whose current bytes are marked, counted in a stat-only pre-pass; hand tags do not count. - The review counts images and estimates the time (WD14 on CPU or CUDA). - System > Auto-tagging review rows and copy, D134 and the Auto-tagging contract follow. Part of #379 * fix(tagging): end the batch job after its cleanup and report pre-pass progress (#379) The job read as ended while it still held the model gate, so a quick restart could be refused with model-busy. Pre-pass chunks now publish progress, and a cancel between chunks keeps exact counts. Part of #379 * fix(tagging): measured GPU rate, and an ended image-tags job shows no time left (#379) The live check measured WD14 on CUDA at 0.167 s per image (100 images in 16.7 s with the model load), so the review estimate uses 0.17 s. A finished or cancelled Auto-tag/Add tags job kept "Processing file N of N · about X min left"; it now ends with "<verb> <state> · N files processed". Part of #379 * test(browser): reload the admin page only after the in-place edit saved The gallery-to-admin workflow pressed Enter and reloaded as soon as the edited text showed; the page saves after showing it, so a reload that won the race abandoned the PUT (WebKit, run 40564511000). The test now waits for the save's answer. Part of #379 * test(tagging): the batch coverage row names the renamed #379 test
536 lines
22 KiB
Python
536 lines
22 KiB
Python
"""Per-image tag sets (#249): byte-exact storage in the shared vocabulary.
|
|
|
|
A tag set belongs to one byte record (``media_annotation_bytes``): the
|
|
whole-file SHA-256 of one physical file, owned by the file's private locator
|
|
(``media_annotation_files``). Callers pass verified facts (digest, size,
|
|
``mtime_ns``); nothing here reads the filesystem or resolves an owner through
|
|
prompt links, so tagging one image never changes another image, a prompt or a
|
|
pair.
|
|
|
|
Commands run in one ``_pair_transaction`` fenced by the storage epoch, like the
|
|
native Library and vocabulary commands. A save is a full replacement guarded
|
|
by the set's ``tag_revision``; the identical set sent again (a lost-response
|
|
retry, or the current set at its own revision) answers ``replayed: true`` and
|
|
writes nothing. ``add_image_tags`` unions into the existing set, keeping the
|
|
stored spelling. Names enter the global ``tags`` registry exactly as spelled,
|
|
so vocabulary rename, merge and delete carry image links with prompt and pair
|
|
links (``PromptDatabase._move_tag_links``).
|
|
|
|
Cheap reads (spec amendment S1) treat a byte record as *current* only while
|
|
the file's present ``(size, mtime_ns)`` equals the record's verified
|
|
fingerprint; a file without a current record shows no image tags.
|
|
|
|
A write made from a tagging model's result passes ``mark_autotagged`` (#379):
|
|
the byte record is then marked auto-tagged (``media_image_autotags``) in the
|
|
same transaction, so a mark never exists without the write it records. Hand
|
|
tags never mark.
|
|
"""
|
|
|
|
import re
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from typing import Iterator
|
|
from uuid import uuid4
|
|
|
|
from .annotation_schema import (
|
|
BYTES_TABLE,
|
|
FILES_TABLE,
|
|
IMAGE_TAGS_TABLE,
|
|
MAX_LOCATOR_LENGTH,
|
|
METADATA_TABLE,
|
|
)
|
|
from .autotag_schema import AUTOTAG_TABLE
|
|
from .library_commands import _UUID, LibraryCommandError, _validate_epoch
|
|
from .models import utc_now_iso
|
|
from .vocabulary import validate_vocabulary_tag_name
|
|
|
|
MAX_IMAGE_TAGS = 100
|
|
_LOOKUP_CHUNK = 500
|
|
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
_MAX_INT = 2**63 - 1
|
|
_MIN_INT = -(2**63)
|
|
# Only this module's table constants are interpolated into the SQL below.
|
|
_OWNER = f"SELECT id FROM {FILES_TABLE} WHERE locator=?" # nosec B608
|
|
_INSERT_OWNER = (
|
|
f"INSERT INTO {FILES_TABLE}(id,locator,created_at) VALUES(?,?,?)" # nosec B608
|
|
)
|
|
_BYTE = (
|
|
f"SELECT id,tag_revision,size,mtime_ns FROM {BYTES_TABLE} " # nosec B608
|
|
"WHERE file_id=? AND sha256=?"
|
|
)
|
|
_INSERT_BYTE = (
|
|
f"INSERT INTO {BYTES_TABLE}(id,file_id,sha256,size,mtime_ns," # nosec B608
|
|
"verified_at,first_seen_at,tag_revision) VALUES(?,?,?,?,?,?,?,0)"
|
|
)
|
|
_REFRESH_BYTE = (
|
|
f"UPDATE {BYTES_TABLE} SET size=?,mtime_ns=?,verified_at=? WHERE id=?" # nosec B608
|
|
)
|
|
_REFRESH_BY_LOCATOR = (
|
|
f"UPDATE {BYTES_TABLE} SET size=?,mtime_ns=?,verified_at=? " # nosec B608
|
|
f"WHERE sha256=? AND file_id=({_OWNER})"
|
|
)
|
|
_BYTE_TAGS = (
|
|
f"SELECT t.name FROM {IMAGE_TAGS_TABLE} mit " # nosec B608
|
|
"JOIN tags t ON t.id=mit.tag_id WHERE mit.byte_id=? ORDER BY mit.position"
|
|
)
|
|
_CLEAR_TAGS = f"DELETE FROM {IMAGE_TAGS_TABLE} WHERE byte_id=?" # nosec B608
|
|
_INSERT_TAG = (
|
|
f"INSERT INTO {IMAGE_TAGS_TABLE}(byte_id,tag_id,position) " # nosec B608
|
|
"VALUES(?,?,?)"
|
|
)
|
|
_BUMP_REVISION = (
|
|
f"UPDATE {BYTES_TABLE} SET tag_revision=tag_revision+1 WHERE id=?" # nosec B608
|
|
)
|
|
_REVISION = f"SELECT tag_revision FROM {BYTES_TABLE} WHERE id=?" # nosec B608
|
|
_BUMP_INDEX = (
|
|
f"UPDATE {METADATA_TABLE} SET " # nosec B608
|
|
"image_index_revision=image_index_revision+1 WHERE singleton_id=1"
|
|
)
|
|
_READ = (
|
|
"SELECT b.id,b.tag_revision," # nosec B608
|
|
f"(SELECT COUNT(*) FROM {BYTES_TABLE} o WHERE o.file_id=f.id AND o.sha256!=?) "
|
|
f"FROM {FILES_TABLE} f LEFT JOIN {BYTES_TABLE} b "
|
|
"ON b.file_id=f.id AND b.sha256=? WHERE f.locator=?"
|
|
)
|
|
_FINGERPRINTS = (
|
|
"SELECT f.locator,b.id,b.size,b.mtime_ns,b.verified_at " # nosec B608
|
|
f"FROM {FILES_TABLE} f JOIN {BYTES_TABLE} b ON b.file_id=f.id "
|
|
"WHERE f.locator IN ({marks})"
|
|
)
|
|
_TAGS_OF = (
|
|
f"SELECT mit.byte_id,t.name FROM {IMAGE_TAGS_TABLE} mit " # nosec B608
|
|
"JOIN tags t ON t.id=mit.tag_id WHERE mit.byte_id IN ({marks}) "
|
|
"ORDER BY mit.byte_id,mit.position"
|
|
)
|
|
_MARK = f"INSERT OR IGNORE INTO {AUTOTAG_TABLE}(byte_id) VALUES(?)" # nosec B608
|
|
_MARKED_OF = (
|
|
f"SELECT byte_id FROM {AUTOTAG_TABLE} WHERE byte_id IN ({{marks}})" # nosec B608
|
|
)
|
|
_MARKED_LOCATORS = (
|
|
f"SELECT DISTINCT f.locator FROM {AUTOTAG_TABLE} m " # nosec B608
|
|
f"JOIN {BYTES_TABLE} b ON b.id=m.byte_id JOIN {FILES_TABLE} f ON f.id=b.file_id"
|
|
)
|
|
_RECORDS = (
|
|
"SELECT f.locator,b.id,b.size,b.mtime_ns,t.name " # nosec B608
|
|
f"FROM {FILES_TABLE} f JOIN {BYTES_TABLE} b ON b.file_id=f.id "
|
|
f"LEFT JOIN {IMAGE_TAGS_TABLE} mit ON mit.byte_id=b.id "
|
|
"LEFT JOIN tags t ON t.id=mit.tag_id "
|
|
"ORDER BY f.locator,b.first_seen_at,b.id,mit.position"
|
|
)
|
|
|
|
|
|
class AnnotationCommandError(LibraryCommandError):
|
|
"""An image tag command lost a race or hit the set's bound.
|
|
|
|
``code`` is ``tag-conflict`` (the set changed since it was read) or
|
|
``tag-limit`` (the union would exceed ``MAX_IMAGE_TAGS``); ``current`` is
|
|
the stored set ``{sha256, tags, tag_revision}`` read inside the
|
|
transaction, so the caller can show it.
|
|
"""
|
|
|
|
def __init__(self, code: str, epoch: str, current: dict | None = None):
|
|
super().__init__(code, epoch)
|
|
self.current = current
|
|
|
|
|
|
class ImageTagLimitError(ValueError):
|
|
"""More than ``MAX_IMAGE_TAGS`` distinct names in one set."""
|
|
|
|
|
|
def normalize_image_tags(names: object) -> list[str]:
|
|
"""Trim and validate each name, fold case-insensitive repeats, cap at 100.
|
|
|
|
The first occurrence keeps its spelling and position. Raises ``ValueError``
|
|
for anything but a list of valid vocabulary names, and its subclass
|
|
``ImageTagLimitError`` for more than ``MAX_IMAGE_TAGS`` distinct names.
|
|
"""
|
|
if not isinstance(names, list):
|
|
raise ValueError("tags must be a list of strings")
|
|
result: list[str] = []
|
|
seen: set[str] = set()
|
|
for value in names:
|
|
name = validate_vocabulary_tag_name(value)
|
|
key = name.casefold()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
result.append(name)
|
|
if len(result) > MAX_IMAGE_TAGS:
|
|
raise ImageTagLimitError(f"An image can carry at most {MAX_IMAGE_TAGS} tags")
|
|
return result
|
|
|
|
|
|
def _validate_locator(locator: object) -> None:
|
|
if not isinstance(locator, str) or not 0 < len(locator) <= MAX_LOCATOR_LENGTH:
|
|
raise ValueError(
|
|
f"locator must be a nonblank path of at most {MAX_LOCATOR_LENGTH} chars"
|
|
)
|
|
|
|
|
|
def _validate_digest(sha256: object) -> None:
|
|
if not isinstance(sha256, str) or not _DIGEST.fullmatch(sha256):
|
|
raise ValueError("sha256 must be 64 lowercase hexadecimal characters")
|
|
|
|
|
|
def _validate_int(value: object, name: str, minimum: int) -> None:
|
|
if type(value) is not int or not minimum <= value <= _MAX_INT:
|
|
raise ValueError(f"{name} must be an integer not below {minimum}")
|
|
|
|
|
|
def _validate_target(locator: object, sha256: object, size: object, mtime_ns: object):
|
|
_validate_locator(locator)
|
|
_validate_digest(sha256)
|
|
_validate_int(size, "size", 0)
|
|
_validate_int(mtime_ns, "mtime_ns", _MIN_INT)
|
|
|
|
|
|
def _validate_fingerprint(locator: object, fingerprint: object) -> None:
|
|
_validate_locator(locator)
|
|
if (
|
|
not isinstance(fingerprint, tuple)
|
|
or len(fingerprint) != 2
|
|
or any(type(value) is not int for value in fingerprint)
|
|
):
|
|
raise ValueError("stats must map locators to (size, mtime_ns) integer pairs")
|
|
|
|
|
|
def _new_id() -> str:
|
|
"""A canonical UUID; the schema's CHECK lets NULL through, this does not."""
|
|
value = str(uuid4())
|
|
if not _UUID.fullmatch(value):
|
|
raise ValueError("Annotation ids must be canonical UUIDs")
|
|
return value
|
|
|
|
|
|
def _receipt(epoch: str, sha256: str, tags: list, revision: int, replayed: bool):
|
|
return {
|
|
"library_epoch": epoch,
|
|
"sha256": sha256,
|
|
"tags": list(tags),
|
|
"tag_revision": revision,
|
|
"replayed": replayed,
|
|
}
|
|
|
|
|
|
class AnnotationOperations:
|
|
"""PromptDatabase mixin: image tag sets, fingerprints and cheap reads.
|
|
|
|
``_`` helpers take the open ``_pair_transaction`` connection.
|
|
"""
|
|
|
|
@contextmanager
|
|
def _annotation_read(self) -> Iterator[sqlite3.Connection]:
|
|
"""One deferred read transaction, so paired SELECTs see one snapshot."""
|
|
conn = self.model.get_connection()
|
|
if conn.in_transaction:
|
|
raise sqlite3.ProgrammingError(
|
|
"Annotation reads require their own snapshot"
|
|
)
|
|
with conn:
|
|
conn.execute("BEGIN")
|
|
yield conn
|
|
|
|
@staticmethod
|
|
def _annotation_owner(conn: sqlite3.Connection, locator: str, now: str) -> str:
|
|
row = conn.execute(_OWNER, (locator,)).fetchone()
|
|
if row is not None:
|
|
return row[0]
|
|
file_id = _new_id()
|
|
conn.execute(_INSERT_OWNER, (file_id, locator, now))
|
|
return file_id
|
|
|
|
@staticmethod
|
|
def _annotation_byte(
|
|
conn: sqlite3.Connection,
|
|
file_id: str,
|
|
sha256: str,
|
|
size: int,
|
|
mtime_ns: int,
|
|
now: str,
|
|
) -> tuple[str, int, bool, bool]:
|
|
"""The byte record for these bytes, refreshed or created.
|
|
|
|
Returns ``(id, tag_revision, created, refingerprinted)``; the last is
|
|
True when an existing record's verified ``(size, mtime_ns)`` changed,
|
|
which moves its currency for cheap reads (the caller bumps the index
|
|
revision when nothing else does).
|
|
"""
|
|
row = conn.execute(_BYTE, (file_id, sha256)).fetchone()
|
|
if row is None:
|
|
byte_id = _new_id()
|
|
conn.execute(
|
|
_INSERT_BYTE, (byte_id, file_id, sha256, size, mtime_ns, now, now)
|
|
)
|
|
return byte_id, 0, True, False
|
|
conn.execute(_REFRESH_BYTE, (size, mtime_ns, now, row[0]))
|
|
return row[0], row[1], False, (row[2], row[3]) != (size, mtime_ns)
|
|
|
|
@staticmethod
|
|
def _byte_tags(conn: sqlite3.Connection, byte_id: str) -> list[str]:
|
|
return [row[0] for row in conn.execute(_BYTE_TAGS, (byte_id,))]
|
|
|
|
def _write_image_tags(
|
|
self, conn: sqlite3.Connection, byte_id: str, tags: list[str]
|
|
) -> int:
|
|
"""Rewrite the junction in order, bump both revisions; return the new one."""
|
|
tag_ids = self._ensure_tags(conn, tags)
|
|
conn.execute(_CLEAR_TAGS, (byte_id,))
|
|
conn.executemany(
|
|
_INSERT_TAG,
|
|
[(byte_id, tag_ids[name], position) for position, name in enumerate(tags)],
|
|
)
|
|
conn.execute(_BUMP_REVISION, (byte_id,))
|
|
conn.execute(_BUMP_INDEX)
|
|
return conn.execute(_REVISION, (byte_id,)).fetchone()[0]
|
|
|
|
def _current_set(self, conn, sha256, byte_id, revision) -> dict:
|
|
return {
|
|
"sha256": sha256,
|
|
"tags": self._byte_tags(conn, byte_id),
|
|
"tag_revision": revision,
|
|
}
|
|
|
|
def read_image_tags(self, locator: str, sha256: str) -> dict:
|
|
"""The stored set of these exact bytes, or an empty set at revision 0.
|
|
|
|
``prior_count`` is the number of other byte records at the locator
|
|
(earlier or later bytes of the same file).
|
|
"""
|
|
_validate_locator(locator)
|
|
_validate_digest(sha256)
|
|
with self._annotation_read() as conn:
|
|
row = conn.execute(_READ, (sha256, sha256, locator)).fetchone()
|
|
if row is None:
|
|
return {"tags": [], "tag_revision": 0, "prior_count": 0}
|
|
tags = self._byte_tags(conn, row[0]) if row[0] is not None else []
|
|
return {"tags": tags, "tag_revision": row[1] or 0, "prior_count": row[2]}
|
|
|
|
def replace_image_tags(
|
|
self,
|
|
*,
|
|
locator: str,
|
|
sha256: str,
|
|
size: int,
|
|
mtime_ns: int,
|
|
expected_epoch: str,
|
|
expected_tag_revision: int,
|
|
tags: list,
|
|
mark_autotagged: bool = False,
|
|
) -> dict:
|
|
"""Replace the set of these exact bytes, guarded by epoch and revision.
|
|
|
|
Creates the owner and byte record when the bytes are new (an explicit
|
|
empty set is a real save). The identical set at the current revision,
|
|
or one behind it (a lost-response retry), is ``replayed`` and writes
|
|
no tag; any other revision mismatch is ``tag-conflict`` with the
|
|
current set and no writes. ``mark_autotagged`` marks the bytes
|
|
auto-tagged on a saved or replayed receipt (the Tag image dialog's
|
|
save after Generate suggestions).
|
|
"""
|
|
_validate_target(locator, sha256, size, mtime_ns)
|
|
_validate_epoch(expected_epoch)
|
|
_validate_int(expected_tag_revision, "expected_tag_revision", 0)
|
|
tags = normalize_image_tags(tags)
|
|
with self._pair_transaction() as conn:
|
|
epoch = self._library_epoch(conn, expected_epoch)
|
|
now = utc_now_iso()
|
|
file_id = self._annotation_owner(conn, locator, now)
|
|
byte_id, revision, created, refingerprinted = self._annotation_byte(
|
|
conn, file_id, sha256, size, mtime_ns, now
|
|
)
|
|
current = self._byte_tags(conn, byte_id)
|
|
if (
|
|
not created
|
|
and current == tags
|
|
and expected_tag_revision in (revision, revision - 1)
|
|
):
|
|
if refingerprinted:
|
|
conn.execute(_BUMP_INDEX)
|
|
if mark_autotagged:
|
|
conn.execute(_MARK, (byte_id,))
|
|
return _receipt(epoch, sha256, current, revision, True)
|
|
if expected_tag_revision != revision:
|
|
raise AnnotationCommandError(
|
|
"tag-conflict",
|
|
epoch,
|
|
{"sha256": sha256, "tags": current, "tag_revision": revision},
|
|
)
|
|
revision = self._write_image_tags(conn, byte_id, tags)
|
|
if mark_autotagged:
|
|
conn.execute(_MARK, (byte_id,))
|
|
return _receipt(epoch, sha256, tags, revision, False)
|
|
|
|
def add_image_tags(
|
|
self,
|
|
*,
|
|
locator: str,
|
|
sha256: str,
|
|
size: int,
|
|
mtime_ns: int,
|
|
expected_epoch: str,
|
|
tags: list,
|
|
mark_autotagged: bool = False,
|
|
) -> dict:
|
|
"""Union ``tags`` into the set of these exact bytes, keeping stored spelling.
|
|
|
|
``added`` counts the names that were new (case-insensitively). A union
|
|
over ``MAX_IMAGE_TAGS`` is ``tag-limit`` with the current set and no
|
|
writes; a union that adds nothing to an existing record is ``replayed``.
|
|
``mark_autotagged`` (a tagging model's result) also marks the bytes
|
|
auto-tagged, replayed or not; only such a call may pass no tags (the
|
|
model found none: the image is marked and its set is unchanged).
|
|
"""
|
|
_validate_target(locator, sha256, size, mtime_ns)
|
|
_validate_epoch(expected_epoch)
|
|
tags = normalize_image_tags(tags)
|
|
if not tags and not mark_autotagged:
|
|
raise ValueError("tags must name at least one tag")
|
|
with self._pair_transaction() as conn:
|
|
epoch = self._library_epoch(conn, expected_epoch)
|
|
now = utc_now_iso()
|
|
file_id = self._annotation_owner(conn, locator, now)
|
|
byte_id, revision, created, refingerprinted = self._annotation_byte(
|
|
conn, file_id, sha256, size, mtime_ns, now
|
|
)
|
|
current = self._byte_tags(conn, byte_id)
|
|
present = {name.casefold() for name in current}
|
|
added = [name for name in tags if name.casefold() not in present]
|
|
merged = current + added
|
|
if len(merged) > MAX_IMAGE_TAGS:
|
|
raise AnnotationCommandError(
|
|
"tag-limit",
|
|
epoch,
|
|
{"sha256": sha256, "tags": current, "tag_revision": revision},
|
|
)
|
|
if mark_autotagged:
|
|
conn.execute(_MARK, (byte_id,))
|
|
if not created and not added:
|
|
if refingerprinted:
|
|
conn.execute(_BUMP_INDEX)
|
|
return {**_receipt(epoch, sha256, current, revision, True), "added": 0}
|
|
revision = self._write_image_tags(conn, byte_id, merged)
|
|
return {
|
|
**_receipt(epoch, sha256, merged, revision, False),
|
|
"added": len(added),
|
|
}
|
|
|
|
def refresh_image_fingerprint(
|
|
self, locator: str, sha256: str, size: int, mtime_ns: int
|
|
) -> bool:
|
|
"""Record that these exact bytes were just seen with this stat.
|
|
|
|
True when a byte record was refreshed; nothing is created and no tag
|
|
or tag revision changes. The image index revision moves because the
|
|
record's currency for cheap reads may have changed.
|
|
"""
|
|
_validate_target(locator, sha256, size, mtime_ns)
|
|
with self.model.get_connection() as conn:
|
|
cursor = conn.execute(
|
|
_REFRESH_BY_LOCATOR, (size, mtime_ns, utc_now_iso(), sha256, locator)
|
|
)
|
|
if cursor.rowcount > 0:
|
|
conn.execute(_BUMP_INDEX)
|
|
return cursor.rowcount > 0
|
|
|
|
@staticmethod
|
|
def _validated_stats(stats: object) -> dict:
|
|
if not isinstance(stats, dict):
|
|
raise ValueError(
|
|
"stats must map locators to (size, mtime_ns) integer pairs"
|
|
)
|
|
for locator, fingerprint in stats.items():
|
|
_validate_fingerprint(locator, fingerprint)
|
|
return stats
|
|
|
|
@staticmethod
|
|
def _current_byte_records(conn: sqlite3.Connection, stats: dict) -> dict:
|
|
"""``{locator: byte_id}`` of each locator's current byte record.
|
|
|
|
The one rule every cheap read uses: a record is current while its
|
|
verified ``(size, mtime_ns)`` equals the file's present fingerprint
|
|
in ``stats``; the most recently verified wins (ties on ``(verified_at,
|
|
byte_id)``). A locator without a current record is left out. Lookups
|
|
go in chunks of 500 locators.
|
|
"""
|
|
locators = list(stats)
|
|
chosen: dict[str, tuple[tuple[str, str], str]] = {}
|
|
for start in range(0, len(locators), _LOOKUP_CHUNK):
|
|
chunk = locators[start : start + _LOOKUP_CHUNK]
|
|
marks = ",".join("?" * len(chunk))
|
|
for locator, byte_id, size, mtime_ns, verified_at in conn.execute(
|
|
_FINGERPRINTS.format(marks=marks), chunk
|
|
):
|
|
if (size, mtime_ns) != stats[locator]:
|
|
continue
|
|
key = (verified_at, byte_id)
|
|
if locator not in chosen or key > chosen[locator][0]:
|
|
chosen[locator] = (key, byte_id)
|
|
return {locator: byte_id for locator, (_key, byte_id) in chosen.items()}
|
|
|
|
def current_image_tags(self, stats: dict) -> dict:
|
|
"""Image tags of the files whose current ``(size, mtime_ns)`` is verified.
|
|
|
|
``stats`` maps locators to their present fingerprint. A locator is
|
|
answered (possibly with an empty set) only when a byte record carries
|
|
exactly that verified fingerprint; the most recently verified record
|
|
wins when several do. Lookups go in chunks of 500 locators.
|
|
"""
|
|
stats = self._validated_stats(stats)
|
|
with self._annotation_read() as conn:
|
|
current = self._current_byte_records(conn, stats)
|
|
owners = {byte_id: locator for locator, byte_id in current.items()}
|
|
result: dict[str, list[str]] = {locator: [] for locator in current}
|
|
byte_ids = list(owners)
|
|
for start in range(0, len(byte_ids), _LOOKUP_CHUNK):
|
|
chunk = byte_ids[start : start + _LOOKUP_CHUNK]
|
|
marks = ",".join("?" * len(chunk))
|
|
for byte_id, name in conn.execute(_TAGS_OF.format(marks=marks), chunk):
|
|
result[owners[byte_id]].append(name)
|
|
return result
|
|
|
|
def autotag_state(self, stats: dict) -> dict:
|
|
"""``{locator: bool}``: is each file's current byte record auto-tagged?
|
|
|
|
``stats`` as for :meth:`current_image_tags`, whose rule picks the
|
|
current record; a locator without one is False. One read snapshot.
|
|
"""
|
|
stats = self._validated_stats(stats)
|
|
with self._annotation_read() as conn:
|
|
current = self._current_byte_records(conn, stats)
|
|
byte_ids = list(dict.fromkeys(current.values()))
|
|
marked: set[str] = set()
|
|
for start in range(0, len(byte_ids), _LOOKUP_CHUNK):
|
|
chunk = byte_ids[start : start + _LOOKUP_CHUNK]
|
|
marks = ",".join("?" * len(chunk))
|
|
marked.update(
|
|
row[0]
|
|
for row in conn.execute(_MARKED_OF.format(marks=marks), chunk)
|
|
)
|
|
return {locator: current.get(locator) in marked for locator in stats}
|
|
|
|
def autotagged_locators(self) -> set:
|
|
"""Locators with at least one auto-tagged byte record, current or not.
|
|
|
|
No fingerprint is checked (the Tag output images review's estimate).
|
|
"""
|
|
with self._annotation_read() as conn:
|
|
return {row[0] for row in conn.execute(_MARKED_LOCATORS)}
|
|
|
|
def iter_image_tag_records(self) -> Iterator[tuple[str, int, int, list[str]]]:
|
|
"""Stream every byte record as ``(locator, size, mtime_ns, tags)``.
|
|
|
|
One SELECT ordered by locator, then by the bytes' first sighting, with
|
|
each set's names in position order.
|
|
"""
|
|
conn = self.model.get_connection()
|
|
current: tuple | None = None
|
|
for locator, byte_id, size, mtime_ns, name in conn.execute(_RECORDS):
|
|
if current is None or current[0] != byte_id:
|
|
if current is not None:
|
|
yield current[1]
|
|
current = (byte_id, (locator, size, mtime_ns, []))
|
|
if name is not None:
|
|
current[1][3].append(name)
|
|
if current is not None:
|
|
yield current[1]
|