* 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.
55 lines
2.2 KiB
Python
55 lines
2.2 KiB
Python
"""The listing identity of one media file, as a request names it.
|
|
|
|
Shared by the image-annotation routes and the collection routes (#331): a
|
|
target is ``{root_index, root_scope, path}``, checked here without touching
|
|
the filesystem or the database. The scope is compared with the current roots
|
|
by the caller.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
|
|
from .images import _forbidden, _has_traversal
|
|
from .prompts import bad_request
|
|
|
|
try:
|
|
from ...utils.media_capture import CAPTURE_EXTENSIONS
|
|
except ImportError:
|
|
from utils.media_capture import CAPTURE_EXTENSIONS
|
|
|
|
MAX_TARGET_PATH = 1024
|
|
TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
|
|
_HEX64 = re.compile(r"[0-9a-f]{64}")
|
|
TAGGING_MEDIA_MESSAGE = "Only media files can be tagged"
|
|
|
|
|
|
def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
|
|
"""``((index, scope, relative), None)`` for a target, else ``(None, error)``.
|
|
|
|
Only the listing identity is accepted: an int root index, the root's
|
|
64-hex scope and a bounded root-relative POSIX media path, never an
|
|
absolute, drive-qualified, backslash or ``..`` path (a 400 for a malformed
|
|
shape, a 403 for an unsafe or non-media path, carrying *media_message*).
|
|
"""
|
|
if not isinstance(data, dict) or set(data) != TARGET_FIELDS:
|
|
return None, bad_request("target must name root_index, root_scope and path")
|
|
index, scope, relative = data["root_index"], data["root_scope"], data["path"]
|
|
if type(index) is not int or index < 0:
|
|
return None, bad_request("Invalid gallery root")
|
|
if not isinstance(scope, str) or _HEX64.fullmatch(scope) is None:
|
|
return None, bad_request("Invalid gallery root scope")
|
|
if not isinstance(relative, str) or not relative or len(relative) > MAX_TARGET_PATH:
|
|
return None, bad_request("path must be a relative media path")
|
|
parts = relative.split("/")
|
|
if (
|
|
"\0" in relative
|
|
or "\\" in relative
|
|
or relative.startswith("/")
|
|
or _has_traversal(relative)
|
|
or any(part in ("", ".", "..") or ":" in part for part in parts)
|
|
):
|
|
return None, _forbidden()
|
|
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
|
|
return None, _forbidden(media_message)
|
|
return (index, scope, relative), None
|