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.
This commit is contained in:
+5
-1
@@ -4510,7 +4510,11 @@ category; without one it shows the category its link supplies.
|
||||
- 409 `source-changed`: a stale `root_scope`;
|
||||
- 413: a body over 1 MiB.
|
||||
Any invalid item fails the whole request before a write. Targets that fold to one
|
||||
key are written and counted once.
|
||||
key are written and counted once. Instead of `items` a request may send `filter`,
|
||||
the Images listing's own query ("Select all N matching", #392, keys as in
|
||||
`parse_listing_filter`), resolved now: over 500 matches is a 400
|
||||
`too-many-images` ("Select at most 500 images."), a renumbered source a 409
|
||||
`source-changed`.
|
||||
- PUT writes only files the source's listing holds and counts the rest in
|
||||
`not_found`; when none is listed it answers 404 `files-not-found` and writes
|
||||
nothing. A file that already has the value counts as `unchanged` and keeps its
|
||||
|
||||
@@ -6,6 +6,8 @@ category can still be removed); ``GET /prompt_manager/images/categories``
|
||||
lists the own-category values of files the sources list. Targets are the
|
||||
Images listing identity (``{root_index, root_scope, path}``, checked by
|
||||
``media_target``) of an output source; LoRA folders are not sources here.
|
||||
Instead of ``items`` a request may name ``filter``, the Images listing's own
|
||||
query ("Select all N matching", #392), resolved now up to 500 images.
|
||||
Each file is stored under its file key (``images.image_category_key``).
|
||||
These routes never read or write prompts, recipes or image links.
|
||||
"""
|
||||
@@ -33,8 +35,9 @@ except ImportError:
|
||||
from utils.validators import validate_category
|
||||
|
||||
CATEGORY_MEDIA_MESSAGE = "Only media files can have a category"
|
||||
_PUT_FIELDS = frozenset({"items", "category"})
|
||||
_DELETE_FIELDS = frozenset({"items"})
|
||||
_PUT_FIELDS = frozenset({"category"})
|
||||
_DELETE_FIELDS = frozenset()
|
||||
TOO_MANY = f"Select at most {MAX_JOB_TARGETS} images."
|
||||
SOURCE_CHANGED_MESSAGE = "The sources changed. Refresh the images and try again."
|
||||
WRITE_FAILED = "The category could not be changed."
|
||||
READ_FAILED = "Image categories could not be read."
|
||||
@@ -196,20 +199,53 @@ class ImageCategoryRoutesMixin:
|
||||
|
||||
# -- writing ---------------------------------------------------------------
|
||||
|
||||
async def _filter_targets(self, raw):
|
||||
"""``(targets, None)`` for an Images listing filter, else ``(None, error)``.
|
||||
|
||||
"Select all N matching" (#392) sends the listing's own query; it is
|
||||
resolved now, up to 500 images (a 400 ``too-many-images`` above).
|
||||
"""
|
||||
from .images import ListingSourceChanged, parse_listing_filter
|
||||
|
||||
try:
|
||||
selection = await self.resolve_listing_filter(
|
||||
parse_listing_filter(raw), MAX_JOB_TARGETS
|
||||
)
|
||||
except ListingSourceChanged:
|
||||
return None, _coded(409, "source-changed", SOURCE_CHANGED_MESSAGE)
|
||||
except ValueError as exc:
|
||||
return None, bad_request(str(exc))
|
||||
if selection.truncated:
|
||||
return None, _coded(400, "too-many-images", TOO_MANY)
|
||||
return tuple(selection.targets), None
|
||||
|
||||
async def _category_request(self, request, fields: frozenset):
|
||||
"""``(data, targets, roots, None)`` or ``(None, None, None, error)``."""
|
||||
"""``(data, targets, roots, None)`` or ``(None, None, None, error)``.
|
||||
|
||||
The images are ``items`` (1 to 500 targets) or ``filter`` (all images
|
||||
an Images listing filter admits), besides *fields*.
|
||||
"""
|
||||
data, error = await _json_body_or_error(request)
|
||||
if error is not None:
|
||||
return None, None, None, error
|
||||
if set(data) != fields:
|
||||
names = set(data) - fields
|
||||
if set(data) & fields != fields or names not in ({"items"}, {"filter"}):
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
bad_request(
|
||||
"Request must contain exactly " + ", ".join(sorted(fields))
|
||||
"Request must contain "
|
||||
+ ", ".join(sorted(fields))
|
||||
+ (" and " if fields else "")
|
||||
+ "either items or filter"
|
||||
),
|
||||
)
|
||||
if "filter" in data:
|
||||
targets, error = await self._filter_targets(data["filter"])
|
||||
if error is not None:
|
||||
return None, None, None, error
|
||||
return data, targets, list(self._get_all_output_dirs()), None
|
||||
items = data["items"]
|
||||
if not isinstance(items, list) or not 1 <= len(items) <= MAX_JOB_TARGETS:
|
||||
return (
|
||||
|
||||
@@ -322,3 +322,54 @@ class TestCategoryList(ImageCategoryTestCase):
|
||||
data = await self.listed(status=500)
|
||||
self.assertEqual(data["error"], "Image categories could not be read.")
|
||||
self.assertNotIn("secret", json.dumps(data))
|
||||
|
||||
|
||||
class TestAllMatchingFilter(ImageCategoryTestCase):
|
||||
"""#392 "Select all N matching": the listing filter, resolved now."""
|
||||
|
||||
async def test_filter_sets_and_clears_every_match(self):
|
||||
for name in ("bulk/a.png", "bulk/b.png", "other/c.png"):
|
||||
make_png(self.output_dir / name)
|
||||
data = await self.send(
|
||||
"PUT", {"filter": {"subfolder": "bulk"}, "category": "Scans"}
|
||||
)
|
||||
self.assertEqual((data["updated"], data["not_found"]), (2, 0))
|
||||
self.assertEqual(
|
||||
sorted(row[1] for row in self.rows()), ["bulk/a.png", "bulk/b.png"]
|
||||
)
|
||||
data = await self.send("DELETE", {"filter": {"subfolder": "bulk"}})
|
||||
self.assertEqual(data["cleared"], 2)
|
||||
data = await self.send(
|
||||
"PUT", {"filter": {"subfolder": "none"}, "category": "Scans"}, 404
|
||||
)
|
||||
self.assertEqual(data["code"], "files-not-found")
|
||||
self.assertEqual(
|
||||
(await self.send("DELETE", {"filter": {"q": "zz"}}))["cleared"], 0
|
||||
)
|
||||
|
||||
async def test_filter_refusals(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
item = self.target("a.png")
|
||||
for body in (
|
||||
{"filter": {}, "items": [item], "category": "x"},
|
||||
{"filter": {"unknown": "1"}, "category": "x"},
|
||||
{"filter": "q=a", "category": "x"},
|
||||
{"filter": {"root": "0", "root_scope": "nothex"}, "category": "x"},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
await self.send("PUT", body, 400)
|
||||
data = await self.send(
|
||||
"PUT",
|
||||
{"filter": {"root": "0", "root_scope": "0" * 64}, "category": "x"},
|
||||
409,
|
||||
)
|
||||
self.assertEqual(data["code"], "source-changed")
|
||||
self.assertEqual(self.rows(), [])
|
||||
|
||||
async def test_more_than_500_matching_is_refused(self):
|
||||
for number in range(501):
|
||||
make_png(self.output_dir / f"many/{number:03}.png", size=(1, 1))
|
||||
data = await self.send("PUT", {"filter": {}, "category": "x"}, 400)
|
||||
self.assertEqual(data["code"], "too-many-images")
|
||||
self.assertEqual(data["error"], "Select at most 500 images.")
|
||||
self.assertEqual(self.rows(), [])
|
||||
|
||||
Reference in New Issue
Block a user