diff --git a/TESTING.md b/TESTING.md index 54eba39e..6e9cba4f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/py/api/image_categories.py b/py/api/image_categories.py index 381b6059..0b497bae 100644 --- a/py/api/image_categories.py +++ b/py/api/image_categories.py @@ -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 ( diff --git a/tests/test_api_image_categories.py b/tests/test_api_image_categories.py index 5e06368a..02905267 100644 --- a/tests/test_api_image_categories.py +++ b/tests/test_api_image_categories.py @@ -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(), [])