diff --git a/TESTING.md b/TESTING.md index 0999efb..6b05b1e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1245,7 +1245,8 @@ Recorded deviations on this page (spec §8): `#bulkbar` shows "N selected" and only the real actions in the concept's order (Add tags, then D85's Auto-tag selected, Set category, Export metadata, Clear). Add to collection waits for collections; "Remove links…" is dropped. (Amended by #249 PR D: - Add tags ships, see "Bulk tagging from the selection bar".) The selection survives Load more and + Add tags ships, see "Bulk tagging from the selection bar"; #392 adds range, keyboard, + touch and "all N matching" selection, see "Selecting many images", D135.) The selection survives Load more and bounded re-mounting and is cleared whenever the collection is replaced (search, category, sort, Filters, source change, rescan, reload). A return to Images restores the saved collection unreplaced and keeps its selection; a full page reload starts @@ -1722,6 +1723,65 @@ busy". Units: | Copy without a concept line: no tags, start fallbacks, the panel heading | D118 | | Activity item and job view for image tagging jobs | D89 (plan D66) | +### Selecting many images (#392) + +**D135** (beyond the concept, which draws only the per-card checkbox): Images selects +like a file manager, in Grid, List (where the concept hides the checkbox) and Masonry, +over every loaded batch. The selection is a set of image keys in display order, so +ranges include cards that bounded rendering has unmounted. + +- Clicking a checkbox toggles it, and that image becomes the anchor. Ctrl/Cmd+click + anywhere on a card (image, star, Info, Tag) also toggles it and makes it the anchor; + nothing opens, inspects or changes the favorite. +- Shift+click on a card selects the range from the anchor to it. Shift+click on a + checkbox gives its range the box's new state, so it can clear a range. The anchor + stays where it is. Without an anchor, the clicked image becomes the anchor. +- In the grid, Ctrl/Cmd+A selects every loaded image; in the search box it keeps its + text meaning. Shift+Arrow, Shift+Home and Shift+End move focus as the plain keys do, + then select from the anchor (the starting card when there is none) to the new card. + Escape in the grid clears the selection. Escape in the bar clears it too, and focus + moves to the heading, as after Clear. +- Touch: a 500 ms long-press (at most 10 px of movement) selects the card and turns on + touch selection mode. The context menu during the press and the click after it are + swallowed, and the card has no image callout. Taps then toggle cards until the + selection is empty. A keyboard Enter or Space keeps its plain meaning. +- A plain click keeps its meaning: the image opens the viewer, and Info inspects. +- When every loaded image is checked and the listing has more, the bar offers + **Select all N matching** right after the count. Pressing it gives **All N matching + selected**: later batches arrive checked, and unchecking any image goes back to the + loaded images minus that one. Clear, Escape, and a new search, filter, sort or rescan + end it. A refresh after a tag save keeps it. Return visits keep the loaded images, + and the offer shows again. +- In that mode, Add tags and Auto-tag selected start the `image-tags` job with + `filter` (the listing's own query as an object: `q`, `subfolder`, `category`, + `tags`, `root`, `media`, `sort`, `favorites`, and with a Source the loaded root's + `root_scope`) instead of `targets`. The server resolves the filter at start, in + listing order, using the listing's own admission code (`_admitted_entries`). That + means "all matching" when the action starts: an output written since then that + matches is included. A `root_scope` that no longer names its root (the sources were + renumbered) answers 409 `source-changed`. The limit is 10,000 images (`MAX_LISTING_SELECTION`). + Over the limit, the client toasts "Narrow the view to at most 10,000 images." and the + server answers 400 `too-many-images`. No match is 400 `no-images`. Auto-tag in this + mode does not offer Review each image, because the review walks images in the + browser. Set category posts `{images_filter, category}` to `/prompt_manager/bulk/category`. + The server sets the category on the distinct linked positives and answers + `prompt_count` and `skipped`. A listing whose `positive_count` is 0 toasts the + nothing-linked sentence and opens no dialog, and over 10,000 matches it toasts the + limit sentence. Export metadata reads every image in + the browser, so it is `aria-disabled` in this mode, and its `title` says why. + +Tests: `tests/browser/workspace-images.spec.mjs` ("Shift+click selects a range across +an appended batch in Grid, List and Masonry", "Ctrl/Cmd+click toggles a card without +opening it, and a plain click still opens the viewer", "Ctrl/Cmd+A, Select all N +matching and Add tags tag every matching image on the server", "Shift+arrow keys extend +the selection from the focused card, and Escape in the bar clears it", "a touch +long-press starts selection mode, taps then toggle, and an empty selection ends it"); +units `tests/js/workspace-images-multiselect.test.js`, +`tests/js/workspace-images-tagging-bulk.test.js` ("all matching sends the listing +filter instead of targets"), `tests/js/workspace-images-state.test.js` +(`listingFilter`); Python `tests/test_image_tag_jobs.py` (`TestFilterStart`) and +`tests/test_api_workspace_images.py` (`TestListingFilterSelection`). + Native image tagging (#249 PR B routes, PR C UI): `POST /prompt_manager/image-annotations/observe` (capture one listed file, its saved set and `tag_revision`), `GET`/`DELETE /prompt_manager/image-annotations/capture/` diff --git a/py/api/image_tag_jobs.py b/py/api/image_tag_jobs.py index 09aeb87..1d61174 100644 --- a/py/api/image_tag_jobs.py +++ b/py/api/image_tag_jobs.py @@ -2,7 +2,9 @@ ``POST /prompt_manager/jobs/image-tags`` starts one ``image-tags`` job over 1..500 listed images, named by the listing identity ``{root_index, -root_scope, path}``. The job is record-changing (it writes image tag sets and +root_scope, path}``, or over every image a listing ``filter`` admits (at most +10,000, resolved at start in the listing's order; "Select all N matching", +#392). The job is record-changing (it writes image tag sets and new vocabulary names), so it never runs beside the metadata scan. - **Add tags** (``mode: "add"``) hashes each current file and unions a fixed @@ -54,7 +56,14 @@ from .annotations import ( _target, ) from .autotag_routes import BATCH_CANCELLED, MODEL_BUSY_ERROR, model_busy_response -from .images import _current_fingerprint, _listing_posix, _public_path +from .images import ( + MAX_LISTING_SELECTION, + ListingSourceChanged, + _current_fingerprint, + _listing_posix, + _public_path, + parse_listing_filter, +) from .job_routes import busy_response from .jobs import ( AUTOTAG_BATCH_JOB_KIND, @@ -90,6 +99,11 @@ except ImportError: from utils.video_metadata import is_video_path MAX_JOB_TARGETS = 500 +# A filter start resolves on the server, so the request body does not bound it. +MAX_FILTER_TARGETS = MAX_LISTING_SELECTION +NO_IMAGES = "No images match this view." +FILTER_LISTING_FAILED = "The matching images could not be listed. Try again." +TOO_MANY_IMAGES = "Narrow the view to at most {cap:,} images." # 500 maximal targets plus the options: a path of MAX_TARGET_PATH code points # takes at most 6 bytes each on the wire (a \uXXXX escape; raw UTF-8 is at most # 4), and the root index and scope fit in 256. The annotation routes' 64 KiB @@ -103,9 +117,10 @@ ADD_TITLE = "Add tags to images" AUTO_TITLE = "Auto-tag images" GATE_OWNER_KIND = "image-tag-job" +# Each mode's fields besides the images, named by ``targets`` or ``filter``. _FIELDS = { - "add": frozenset({"mode", "targets", "tags"}), - "auto": frozenset({"mode", "targets", "model_type", "options"}), + "add": frozenset({"mode", "tags"}), + "auto": frozenset({"mode", "model_type", "options"}), } _VERBS = {"add": "Add tags", "auto": "Auto-tag"} _LOG_PREFIXES = {"tagged": "+", "unchanged": "=", "skipped": "-", "errors": "!"} @@ -164,6 +179,8 @@ class _Spec: tags: tuple = () model_type: str | None = None options: dict | None = None + # A listing filter the start resolves into ``targets`` (#392). + filter: dict | None = None @property def title(self) -> str: @@ -222,21 +239,35 @@ def parse_image_tag_request(data: dict): """A :class:`_Spec` for a start body, or ``(None, error response)``. ``{mode:"add", targets, tags}`` or ``{mode:"auto", targets, model_type, - options}`` exactly; options follow the Tag dialog's Generate rules. + options}`` exactly; options follow the Tag dialog's Generate rules. A + ``filter`` (a listing query object, parse_listing_filter) may stand in for + ``targets``; the start resolves it. """ fields = _FIELDS.get(data.get("mode")) if fields is None: return None, bad_request('mode must be "add" or "auto"') + fields = fields | {"filter" if "filter" in data else "targets"} if set(data) != fields: return None, bad_request( "Request must contain exactly " + ", ".join(sorted(fields)) ) - targets, error = _parse_targets(data["targets"]) - if error is not None: - return None, error + listing_filter, targets = None, () + if "filter" in data: + try: + listing_filter = parse_listing_filter(data["filter"]) + except ValueError as exc: + return None, bad_request(str(exc)) + else: + targets, error = _parse_targets(data["targets"]) + if error is not None: + return None, error if data["mode"] == "add": - return _parse_add(targets, data) - return _parse_auto(targets, data) + spec, error = _parse_add(targets, data) + else: + spec, error = _parse_auto(targets, data) + if spec is not None and listing_filter is not None: + spec = dataclasses.replace(spec, filter=listing_filter) + return spec, error def _capture_outcome(exc: CaptureError, root, relative: str): @@ -398,6 +429,10 @@ class ImageTagJobsMixin: spec, error = parse_image_tag_request(data) if error is not None: return error + if spec.filter is not None: + spec, error = await self._resolve_job_filter(spec) + if error is not None: + return error roots = await self._run_in_executor(self._job_roots, spec.targets) if any(root is None for root, _label in roots.values()): return _coded("source-changed") @@ -639,6 +674,36 @@ class ImageTagJobsMixin: job.log_line(f"Skipped {_plural(skipped, 'image')} before tagging") return remaining + async def _resolve_job_filter(self, spec: _Spec): + """``spec`` with its filter resolved into targets, or ``(None, error)``. + + The listing's own identities, in its order, kept only where a target + could have named them (a media path the job can tag). + """ + cap = MAX_FILTER_TARGETS + try: + selection = await self.resolve_listing_filter(spec.filter, cap) + except ListingSourceChanged: + return None, _coded("source-changed") + except ValueError as exc: + return None, bad_request(str(exc)) + except Exception: + # Never echo it: it can carry SQL or database text. + self.logger.error("A tagging filter could not be resolved", exc_info=True) + return None, _error(500, "listing-failed", FILTER_LISTING_FAILED) + if selection.truncated: + return None, _error(400, "too-many-images", TOO_MANY_IMAGES.format(cap=cap)) + targets = [] + for index, scope, relative in selection.targets: + target, error = _target( + {"root_index": index, "root_scope": scope, "path": relative} + ) + if error is None: + targets.append(target) + if not targets: + return None, _error(400, "no-images", NO_IMAGES) + return dataclasses.replace(spec, targets=tuple(targets)), None + # -- runner ---------------------------------------------------------------- def _job_roots(self, targets) -> dict: diff --git a/py/api/images.py b/py/api/images.py index 95d5e2e..439a280 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -1099,37 +1099,94 @@ def _workspace_filters(query: Mapping) -> dict: } +# The listing parameters a bulk request may name as its filter (#392): the +# workspace query without mode, limit, cursor and the Activity-only ``path``, +# plus the chosen root's ``root_scope`` (a renumbered source is refused). +LISTING_FILTER_KEYS = frozenset( + { + "q", + "subfolder", + "category", + "tags", + "root", + "root_scope", + "media", + "sort", + "favorites", + } +) +MAX_LISTING_FILTER_VALUE = 2048 +# The most images "Select all N matching" acts on in one request (#392). +MAX_LISTING_SELECTION = 10_000 + + +def parse_listing_filter(raw: object) -> dict: + """A bulk request's listing filter (#392): the listing's own query as an object. + + Keys are a subset of LISTING_FILTER_KEYS, each value a string of at most + MAX_LISTING_FILTER_VALUE characters, validated as the listing validates its + query. The root's bounds need the current roots, so resolve_listing_filter + checks those. Raises ValueError. + """ + if not isinstance(raw, dict) or not set(raw) <= LISTING_FILTER_KEYS: + raise ValueError("filter must be an object of listing parameters") + if any( + not isinstance(value, str) or len(value) > MAX_LISTING_FILTER_VALUE + for value in raw.values() + ): + raise ValueError("filter values must be short strings") + if "root_scope" in raw and "root" not in raw: + raise ValueError("root_scope needs root") + _workspace_filters(raw) + return dict(raw) + + +class ListingSourceChanged(Exception): + """A listing filter's ``root_scope`` no longer names its root (#392).""" + + +class ListingSelection(NamedTuple): + """Every image a listing filter admits, in the listing's order (#392). + + ``targets`` are listing identities ``(root_index, root_scope, path)``; + ``positive_ids`` the distinct linked positives (first seen), ``unlinked`` + the admitted images without one, and ``truncated`` that more than the cap + matched (the lists then stop at the cap). + """ + + targets: list + positive_ids: list + unlinked: int + truncated: bool + + # A plain listing's cursor page leaves positive_count out (see # _select_workspace_page); None instead means the count could not be read. _COUNT_OMITTED = object() -def _filtered_page( +def _admitted_entries( entries: list, - start: int, - size: int, filters: dict, links: dict | None, favorites: frozenset | None = None, image_tags: dict | None = None, -) -> tuple[int, list, int | None]: - """Count the *entries* the filters admit and take *size* from *start* on. +): + """Yield ``(position, entry, link match)`` for each entry the filters admit. *links* maps normalized paths to (hit, rating, positive id) for every unambiguously linked image (see PromptDatabase.get_image_link_matches); a category or tag filter admits only those, and ``q`` admits a filename match - or a linked-positive hit. Returns the total, the page and the number of - distinct linked positives among all admitted entries (None without links). - *favorites*, when given, admits only those listed paths (``favorites=1``). - *image_tags* maps normalized paths to their _ImageTagMatch: a tag filter - also admits a file whose current image tags (with its linked positive's - tags) carry every tag, and ``q`` also admits an image tag name hit. + or a linked-positive hit. *favorites*, when given, admits only those listed + paths (``favorites=1``). *image_tags* maps normalized paths to their + _ImageTagMatch: a tag filter also admits a file whose current image tags + (with its linked positive's tags) carry every tag, and ``q`` also admits an + image tag name hit. """ query, media, folder = filters["query"], filters["media"], filters["folder"] members_only = filters["category"] is not None or bool(filters["tags"]) native_folder = folder.replace("/", os.sep) nested = native_folder + os.sep - total, page, positives = 0, [], set() exact = filters.get("path", "") for position, entry in enumerate(entries): if exact and entry.folded != exact: @@ -1153,6 +1210,28 @@ def _filtered_page( and not (image and image.query) ): continue + yield position, entry, match + + +def _filtered_page( + entries: list, + start: int, + size: int, + filters: dict, + links: dict | None, + favorites: frozenset | None = None, + image_tags: dict | None = None, +) -> tuple[int, list, int | None]: + """Count the *entries* the filters admit and take *size* from *start* on. + + Admission is _admitted_entries'. Returns the total, the page and the number + of distinct linked positives among all admitted entries (None without + links). + """ + total, page, positives = 0, [], set() + for position, entry, match in _admitted_entries( + entries, filters, links, favorites, image_tags + ): total += 1 if match is not None: positives.add(match[2]) @@ -2131,35 +2210,23 @@ class ImageRoutesMixin: ) return matches - def _select_workspace_page( - self, - listings: list[tuple], - filters: dict, - after: tuple | None, - limit: int, - scope: str, + def _workspace_admission( + self, listings: list[tuple], filters: dict, *, read_links: bool, strict=False ) -> tuple: - """One keyset page of the filtered listing (blocking; run in executor). + """The ordered entries and what admits them (blocking; run in executor). - One SELECT (get_image_link_matches) reads every linked positive of the - listing: the filters, the rating sort and the positive count share it. - A plain listing (no ``q``, category, tags or rating sort) reads it only - for its first page; its cursor pages read no links and the returned - count is _COUNT_OMITTED, so the response leaves ``positive_count`` out - and the frontend keeps the first page's value. On that plain first - page a failed read leaves the listing usable and the count unknown - (None); so does a failed read under ``q`` alone, whose search then - matches filenames only (as before the linked search existed). + Returns ``(entries, key, links, image_tags, favorites)``: the listing in + the filters' order, its keyset key, and the link, image-tag and favorite + maps _admitted_entries reads. One SELECT (get_image_link_matches) reads + every linked positive when *read_links*. A failed read raises when the + filters need it (category, tags, the rating sort) or *strict*; else the + listing stays usable without links (a search then matches filenames + only). A failed image-tag read raises under a Tags filter; a search then + matches without image tags. """ order, category, tags = filters["order"], filters["category"], filters["tags"] - needed = ( - bool(filters["query"]) - or category is not None - or bool(tags) - or order == "rating" - ) links = None - if needed or after is None: + if read_links: try: links = self.db.get_image_link_matches( filters["text"], @@ -2169,10 +2236,7 @@ class ImageRoutesMixin: wanted=self._workspace_wanted(listings), ) except Exception: - # Category, tags and the rating sort cannot be served without - # the links. A plain listing and a filename search can: the - # search then matches filenames only, and the count is None. - if category is not None or tags or order == "rating": + if strict or category is not None or tags or order == "rating": raise self.logger.warning( "Workspace prompt links could not be read; %s", @@ -2188,8 +2252,6 @@ class ImageRoutesMixin: try: image_tags = self._image_tag_matches(listings, filters) except Exception: - # A Tags filter cannot be served without them (as without the - # links); a search then matches without image tags. if tags: raise self.logger.warning( @@ -2228,6 +2290,41 @@ class ImageRoutesMixin: def key(entry): return _workspace_key(entry, order) + favorites = ( + self._workspace_favorite_paths(listings) if filters["favorites"] else None + ) + return entries, key, links, image_tags, favorites + + def _select_workspace_page( + self, + listings: list[tuple], + filters: dict, + after: tuple | None, + limit: int, + scope: str, + ) -> tuple: + """One keyset page of the filtered listing (blocking; run in executor). + + The filters, the rating sort and the positive count share one link + read (_workspace_admission). A plain listing (no ``q``, category, tags + or rating sort) reads it only for its first page; its cursor pages read + no links and the returned count is _COUNT_OMITTED, so the response + leaves ``positive_count`` out and the frontend keeps the first page's + value. On that plain first page a failed read leaves the listing usable + and the count unknown (None); so does a failed read under ``q`` alone, + whose search then matches filenames only (as before the linked search + existed). + """ + order = filters["order"] + needed = ( + bool(filters["query"]) + or filters["category"] is not None + or bool(filters["tags"]) + or order == "rating" + ) + entries, key, links, image_tags, favorites = self._workspace_admission( + listings, filters, read_links=needed or after is None + ) start = 0 if after is not None: if order == "name-desc": @@ -2235,9 +2332,6 @@ class ImageRoutesMixin: start = len(entries) - bisect_left(ascending, after, key=key) else: start = bisect_right(entries, after, key=key) - favorites = ( - self._workspace_favorite_paths(listings) if filters["favorites"] else None - ) total, page, positive_count = _filtered_page( entries, start, limit + 1, filters, links, favorites, image_tags ) @@ -2254,6 +2348,61 @@ class ImageRoutesMixin: positive_count, ) + def _select_workspace_matches( + self, listings: list[tuple], filters: dict, cap: int + ) -> ListingSelection: + """Every admitted entry, up to *cap*, as a ListingSelection (blocking). + + The links are read strictly: the positives must be the listing's own. + """ + entries, _key, links, image_tags, favorites = self._workspace_admission( + listings, filters, read_links=True, strict=True + ) + targets, positives, unlinked, scopes = [], {}, 0, {} + for _position, entry, match in _admitted_entries( + entries, filters, links, favorites, image_tags + ): + if len(targets) == cap: + return ListingSelection(targets, list(positives), unlinked, True) + scope = scopes.get(entry.index) + if scope is None: + scope = scopes[entry.index] = self._gallery_root_scope( + entry.root, entry.index + ) + relative = entry.path.relative_to(entry.root).as_posix() + targets.append((entry.index, scope, relative)) + if match is None or match[2] is None: + unlinked += 1 + else: + positives.setdefault(match[2]) + return ListingSelection(targets, list(positives), unlinked, False) + + async def resolve_listing_filter( + self, params: Mapping, cap: int = MAX_LISTING_SELECTION + ) -> ListingSelection: + """The images a parse_listing_filter result admits right now (#392). + + Raises ValueError for a root outside the current roots, and + ListingSourceChanged when the filter's ``root_scope`` no longer names + its root (the sources were renumbered since the listing). + """ + output_dirs = list(self._get_all_output_dirs()) + root_index = _workspace_root(params, output_dirs) + error = self._gallery_scope_error(params, output_dirs) + if error is not None: + if error.status == 409: + raise ListingSourceChanged() + raise ValueError("Invalid gallery root scope") + filters = _workspace_filters(params) + listings = [ + (index, root, await self._get_gallery_files(root)) + for index, root in enumerate(output_dirs) + if root_index is None or index == root_index + ] + return await self._run_in_executor( + self._select_workspace_matches, listings, filters, cap + ) + async def serve_image(self, request): """Serve the actual image file using streamed FileResponse.""" try: diff --git a/py/api/prompts.py b/py/api/prompts.py index 22b2b3d..e48296e 100644 --- a/py/api/prompts.py +++ b/py/api/prompts.py @@ -1178,6 +1178,8 @@ class PromptRoutesMixin: return web.json_response( {"success": False, "error": "Expected a JSON object"}, status=400 ) + if "images_filter" in data: + return await self._bulk_category_by_filter(data) if not data.get("prompt_ids"): return web.json_response( {"success": False, "error": "No prompt IDs provided"}, status=400 @@ -1214,6 +1216,66 @@ class PromptRoutesMixin: status=500, ) + async def _bulk_category_by_filter(self, data: dict) -> web.Response: + """Set the category on every linked positive a listing filter admits. + + #392 "Select all N matching": ``{images_filter, category}``, the filter + being the Images listing's own query (parse_listing_filter), resolved + now. The answer adds ``prompt_count`` (the distinct linked positives) + and ``skipped`` (matching images without one); none linked changes + nothing. + """ + from .images import ( + MAX_LISTING_SELECTION, + ListingSourceChanged, + parse_listing_filter, + ) + + def refused(message, **extra): + return web.json_response( + {"success": False, "error": message, **extra}, status=400 + ) + + if set(data) != {"images_filter", "category"}: + return refused("Request must contain exactly category, images_filter") + category = data["category"] + try: + params = parse_listing_filter(data["images_filter"]) + validate_category(category) + selection = await self.resolve_listing_filter(params) + except ListingSourceChanged: + return web.json_response( + { + "success": False, + "code": "source-changed", + "error": "The source changed. Refresh the image collection.", + }, + status=409, + ) + except ValueError as exc: + return refused(str(exc)) + if selection.truncated: + return refused( + f"Narrow the view to at most {MAX_LISTING_SELECTION:,} images.", + code="too-many-images", + ) + category = (category or "").strip() + ids = selection.positive_ids + updated = ( + await self._run_in_executor(self.db.bulk_set_category, ids, category) + if ids + else 0 + ) + return web.json_response( + { + "success": True, + "message": f"Set category for {updated} prompts", + "updated_count": updated, + "prompt_count": len(ids), + "skipped": selection.unlinked, + } + ) + def _all_prompts_for_export(self): """Every prompt, fetched in EXPORT_PAGE_SIZE pages (blocking).""" prompts, offset = [], 0 diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 292d889..f088e14 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -2189,3 +2189,248 @@ test('scan for metadata opens the review dialog from Import', async ({ page, req await expect.poll(async () => (await (await request.get('/prompt_manager/jobs')).json()).jobs .map(job => [job.kind, job.state])).toEqual([['backup', 'finished']]); }); + +// #392 (D135): file-manager selection beyond the concept's checkbox. +const selectedCount = page => bulkbar(page).locator('.bulkbar > strong'); +const cardAt = (page, index) => page.locator(`article[data-image-key][data-index="${index}"]`); +const boxAt = (page, index) => cardAt(page, index).getByRole('checkbox', { name: /^Select / }); +const checkedIndexes = page => page.locator('article[data-image-key]').evaluateAll(nodes => nodes + .filter(node => node.querySelector('.image-overlays input[type="checkbox"]').checked) + .map(node => Number(node.dataset.index)).sort((a, b) => a - b)); +const range = (from, to) => Array.from({ length: to - from + 1 }, (_, index) => from + index); +async function bulkReset(page, request) { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(40); +} +/** A touch press on `locator` the way a finger makes one: pointerdown, (held), pointerup. */ +const touchDown = locator => locator.evaluate(node => { + const { left, top } = node.getBoundingClientRect(); + node.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, isPrimary: true, pointerType: 'touch', clientX: left + 5, clientY: top + 5 })); +}); +const touchUpAndClick = locator => locator.evaluate(node => { + node.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, isPrimary: true, pointerType: 'touch' })); + // A tap's click counts one press (`detail` 1); a keyboard click (and `HTMLElement.click()`) counts none. + node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1 })); +}); +async function touchTap(locator) { + await touchDown(locator); + await touchUpAndClick(locator); +} + +test('Shift+click selects a range across an appended batch in Grid, List and Masonry', async ({ page, request }) => { + await bulkReset(page, request); + await page.getByRole('button', { name: 'Load more images', exact: true }).click(); + await expect(caption(page)).toContainText('80 of 202 images'); + for (const mode of ['Grid', 'List', 'Masonry']) { + await page.getByRole('group', { name: 'Image layout', exact: true }).getByRole('button', { name: mode, exact: true }).click(); + // The anchor is the last image toggled on its own (List hides the concept's checkbox: the card carries it). + await cardAt(page, 3).locator('.media-info h3').click({ modifiers: ['ControlOrMeta'] }); + await expect(selectedCount(page)).toHaveText('1 selected'); + // The range runs into the second batch (index 40 on), and backwards from the same anchor. + await cardAt(page, 45).locator('.media-info h3').click({ modifiers: ['Shift'] }); + await expect(selectedCount(page)).toHaveText('43 selected'); + expect(await checkedIndexes(page)).toEqual(range(3, 45)); + await cardAt(page, 0).locator('.media-info h3').click({ modifiers: ['Shift'] }); + await expect(selectedCount(page)).toHaveText('46 selected'); + expect(await checkedIndexes(page)).toEqual(range(0, 45)); + await expect(page.locator('dialog[open]')).toHaveCount(0); + if (mode !== 'List') { + // A Shift+click on a checkbox gives its range the box's new state: here, unchecked. + await boxAt(page, 3).uncheck(); + await boxAt(page, 10).click({ modifiers: ['Shift'] }); + await expect(boxAt(page, 10)).not.toBeChecked(); + await expect(selectedCount(page)).toHaveText('38 selected'); + expect(await checkedIndexes(page)).toEqual([0, 1, 2, ...range(11, 45)]); + } + await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).click(); + await expect(bulkbar(page)).toBeEmpty(); + } +}); + +test('Ctrl/Cmd+click toggles a card without opening it, and a plain click still opens the viewer', async ({ page }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + const first = cards(page).first(); + const inspectorBefore = await details(page).innerText(); + await first.getByRole('button', { name: 'Open same.png', exact: true }).click({ modifiers: ['ControlOrMeta'] }); + await expect(selectedCount(page)).toHaveText('1 selected'); + await expect(checkbox(first, 'same.png')).toBeChecked(); + await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click({ modifiers: ['ControlOrMeta'] }); + await expect(selectedCount(page)).toHaveText('2 selected'); + await star(first, 'same.png').click({ modifiers: ['ControlOrMeta'] }); + await expect(selectedCount(page)).toHaveText('1 selected'); + await expect(star(first, 'same.png')).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('dialog[open]')).toHaveCount(0); + expect(await details(page).innerText()).toBe(inspectorBefore); + // A plain click keeps its meaning: the image opens the viewer. + await first.getByRole('button', { name: 'Open same.png', exact: true }).click(); + await expect(page.locator('#viewer-dialog[open]')).toHaveCount(1); + await expect(selectedCount(page)).toHaveText('1 selected'); +}); + +test('Ctrl/Cmd+A, Select all N matching and Add tags tag every matching image on the server', async ({ page, request }) => { + await bulkReset(page, request); + // Ctrl/Cmd+A in the search box stays the field's own. + await search(page).focus(); + await page.keyboard.press('ControlOrMeta+a'); + await expect(bulkbar(page)).toBeEmpty(); + await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus(); + await page.keyboard.press('ControlOrMeta+a'); + await expect(selectedCount(page)).toHaveText('40 selected'); + const offer = bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true }); + await expect(offer).toBeVisible(); + await expect(bulkbar(page).locator('.bulkbar > *').nth(1)).toHaveText('Select all 202 matching'); + await offer.click(); + await expect(selectedCount(page)).toHaveText('All 202 matching selected'); + await expect(offer).toHaveCount(0); + await expect(bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true })).toHaveAttribute('aria-disabled', 'true'); + // A later batch arrives checked. + await page.getByRole('button', { name: 'Load more images', exact: true }).click(); + await expect(caption(page)).toContainText('80 of 202 images'); + await expect(boxAt(page, 70)).toBeChecked(); + await expect(selectedCount(page)).toHaveText('All 202 matching selected'); + // Unchecking one image leaves "all matching": the other loaded images stay selected. + await boxAt(page, 0).uncheck(); + await expect(selectedCount(page)).toHaveText('79 selected'); + await expect(bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true })).not.toHaveAttribute('aria-disabled', 'true'); + await cardAt(page, 1).getByRole('button', { name: /^Open / }).focus(); + await page.keyboard.press('ControlOrMeta+a'); + await bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true }).click(); + await expect(selectedCount(page)).toHaveText('All 202 matching selected'); + // Add tags sends the listing filter, not 202 identities, and the server tags every match. + await bulkbar(page).getByRole('button', { name: 'Add tags', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Tag selected images', exact: true }); + await dialog.getByRole('textbox', { name: 'Tags, separated by commas', exact: true }).fill('picked392'); + const started = page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags'); + await dialog.getByRole('button', { name: 'Apply', exact: true }).click(); + expect((await started).postDataJSON()).toEqual({ mode: 'add', filter: { sort: 'newest' }, tags: ['picked392'] }); + await expect.poll(async () => (await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1&tags=picked392')).json()).total, + { timeout: 20000 }).toBe(202); + await expect(selectedCount(page)).toHaveText('All 202 matching selected'); + // Escape in the grid clears the selection. + await cardAt(page, 2).getByRole('button', { name: /^Open / }).focus(); + await page.keyboard.press('Escape'); + await expect(bulkbar(page)).toBeEmpty(); + await expect(boxAt(page, 2)).not.toBeChecked(); +}); + +test('Shift+arrow keys extend the selection from the focused card, and Escape in the bar clears it', async ({ page, request }) => { + await bulkReset(page, request); + await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus(); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + await expect(cardAt(page, 2).getByRole('button', { name: /^Open / })).toBeFocused(); + await expect(selectedCount(page)).toHaveText('3 selected'); + expect(await checkedIndexes(page)).toEqual([0, 1, 2]); + // A plain arrow moves without selecting; Shift+End reaches the last loaded card from the same anchor. + await page.keyboard.press('ArrowRight'); + await expect(selectedCount(page)).toHaveText('3 selected'); + await page.keyboard.press('Shift+End'); + await expect(selectedCount(page)).toHaveText('40 selected'); + await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).focus(); + await page.keyboard.press('Escape'); + await expect(bulkbar(page)).toBeEmpty(); + await expect(page.locator('#workspace-main h1')).toBeFocused(); +}); + +test('a touch long-press starts selection mode, taps then toggle, and an empty selection ends it', async ({ page }, testInfo) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + const [first, second] = [cards(page).first(), cards(page).last()]; + const open = card => card.getByRole('button', { name: 'Open same.png', exact: true }); + await touchDown(open(first)); + // The context menu of a held press belongs to the long press. + expect(await open(first).evaluate(node => !node.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })))).toBe(true); + // Held: the long press selects the card once its delay passed. + await expect(selectedCount(page)).toHaveText('1 selected'); + await touchUpAndClick(open(first)); + await expect(page.locator('dialog[open]')).toHaveCount(0); + await expect(checkbox(first, 'same.png')).toBeChecked(); + // Taps now toggle instead of opening. + if (testInfo.project.name === 'touch') await open(second).tap(); + else await touchTap(open(second)); + await expect(selectedCount(page)).toHaveText('2 selected'); + // A keyboard Enter keeps its meaning in touch selection mode: the image opens. + await open(first).focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#viewer-dialog[open]')).toHaveCount(1); + await page.keyboard.press('Escape'); + await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0); + await expect(selectedCount(page)).toHaveText('2 selected'); + await touchTap(open(first)); + await touchTap(open(second)); + await expect(bulkbar(page)).toBeEmpty(); + await expect(page.locator('dialog[open]')).toHaveCount(0); + // The selection is empty, so touch selection mode ended: a tap opens the image again. + await touchTap(open(first)); + await expect(page.locator('#viewer-dialog[open]')).toHaveCount(1); +}); + +test('all matching sets the category through the listing filter and survives a return visit as the loaded images', async ({ page, request }) => { + await bulkReset(page, request); + await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus(); + await page.keyboard.press('ControlOrMeta+a'); + await bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true }).click(); + // The offer left with its press: focus moves to the bar's first action. + await expect(bulkbar(page).getByRole('button', { name: 'Add tags', exact: true })).toBeFocused(); + await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus(); + await page.keyboard.press('ControlOrMeta+a'); + await expect(selectedCount(page)).toHaveText('All 202 matching selected'); + // Export metadata waits for loaded images: a press does nothing. + const exportButton = bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true }); + await expect(exportButton).toHaveAttribute('title', 'Export metadata reads each image in the browser, so it works on loaded images only.'); + // No match linked (a stubbed answer): the nothing-linked sentence, and the selection stays. + await page.route('**/prompt_manager/bulk/category', route => route.fulfill({ json: { success: true, updated_count: 0, prompt_count: 0, skipped: 202 } }), { times: 1 }); + const categorize = page.getByRole('dialog', { name: 'Categorize selected images', exact: true }); + await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click(); + await categorize.getByRole('textbox', { name: 'Category', exact: true }).fill('Bulk392'); + await categorize.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(toast(page)).toHaveText("None of the selected images is linked to a saved prompt, so there is no prompt category to set."); + await expect(selectedCount(page)).toHaveText('All 202 matching selected'); + // The real write: one request with the filter, every matching image's positive changes. + await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click(); + await categorize.getByRole('textbox', { name: 'Category', exact: true }).fill('Bulk392'); + const posted = page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/bulk/category'); + await categorize.getByRole('button', { name: 'Apply', exact: true }).click(); + expect((await posted).postDataJSON()).toEqual({ images_filter: { sort: 'newest' }, category: 'Bulk392' }); + await expect(toast(page)).toHaveText(/^Category set on \d+ prompts?\.$/); + await expect(bulkbar(page)).toBeEmpty(); + const filtered = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1&category=Bulk392')).json(); + expect(filtered.total).toBe(202); + // A return visit keeps "all matching" as the loaded images, and the offer shows again. + await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus(); + await page.keyboard.press('ControlOrMeta+a'); + await bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true }).click(); + await page.evaluate(() => { window.location.hash = '/overview'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await page.evaluate(() => { window.location.hash = '/images'; }); + await expect(selectedCount(page)).toHaveText('40 selected'); + await expect(bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true })).toBeVisible(); + // Escape in the bar with nothing selected does nothing; with a selection it clears. + await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).focus(); + await page.keyboard.press('Escape'); + await expect(bulkbar(page)).toBeEmpty(); +}); + +test('all matching over 10,000 images says so before any dialog or request', async ({ page, request }) => { + await page.route(url => url.pathname === '/prompt_manager/images/output' && url.searchParams.get('limit') !== '1', async route => { + const response = await route.fetch(); + const body = await response.json(); + await route.fulfill({ response, json: { ...body, total: 20002 } }); + }); + await bulkReset(page, request); + await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus(); + await page.keyboard.press('ControlOrMeta+a'); + await bulkbar(page).getByRole('button', { name: 'Select all 20,002 matching', exact: true }).click(); + await expect(selectedCount(page)).toHaveText('All 20,002 matching selected'); + let posted = 0; + page.on('request', item => { if (item.method() === 'POST') posted += 1; }); + for (const action of ['Set category', 'Add tags', 'Auto-tag selected']) { + await bulkbar(page).getByRole('button', { name: action, exact: true }).click(); + await expect(toast(page)).toHaveText('Narrow the view to at most 10,000 images.'); + await expect(page.locator('dialog[open]')).toHaveCount(0); + } + expect(posted).toBe(0); +}); diff --git a/tests/js/workspace-images-multiselect.test.js b/tests/js/workspace-images-multiselect.test.js new file mode 100644 index 0000000..9b622f9 --- /dev/null +++ b/tests/js/workspace-images-multiselect.test.js @@ -0,0 +1,110 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/workspace/images-multiselect.mjs'); + +const KEYS = Object.freeze(['0:a', '0:b', '0:c', '0:d', '1:e']); + +test('a range runs from the anchor to the target in display order, either way', async () => { + const { rangeKeys } = await source; + assert.deepEqual(rangeKeys(KEYS, '0:b', '0:d'), ['0:b', '0:c', '0:d']); + assert.deepEqual(rangeKeys(KEYS, '1:e', '0:c'), ['0:c', '0:d', '1:e']); + assert.deepEqual(rangeKeys(KEYS, '0:c', '0:c'), ['0:c']); +}); + +test('a range without a listed anchor is the target alone, and an unlisted target is empty', async () => { + const { rangeKeys } = await source; + assert.deepEqual(rangeKeys(KEYS, null, '0:c'), ['0:c']); + assert.deepEqual(rangeKeys(KEYS, '9:gone', '0:c'), ['0:c']); + assert.deepEqual(rangeKeys(KEYS, '0:a', '9:gone'), []); +}); + +test('applying keys adds or removes them and keeps the same frozen set when nothing changes', async () => { + const { applyKeys } = await source; + const start = Object.freeze(new Set(['0:a'])); + const added = applyKeys(start, ['0:b', '0:c'], true); + assert.deepEqual([...added], ['0:a', '0:b', '0:c']); + assert.ok(Object.isFrozen(added)); + assert.deepEqual([...start], ['0:a']); + const removed = applyKeys(added, ['0:a', '0:b'], false); + assert.deepEqual([...removed], ['0:c']); + assert.equal(applyKeys(removed, ['0:c'], true), removed); + assert.equal(applyKeys(removed, ['0:a'], false), removed); +}); + +test('every listed key is checked only when each one is in the set', async () => { + const { everyChecked } = await source; + assert.equal(everyChecked(new Set(KEYS), KEYS), true); + assert.equal(everyChecked(new Set(KEYS.slice(1)), KEYS), false); + assert.equal(everyChecked(new Set(['x', ...KEYS.slice(1)]), KEYS), false); + assert.equal(everyChecked(new Set(), []), false); +}); + +test('the select-all-matching offer needs every loaded image checked and more to load', async () => { + const { offersAllMatching } = await source; + const all = new Set(KEYS); + assert.equal(offersAllMatching(all, KEYS, { total: 9, hasMore: true }), true); + assert.equal(offersAllMatching(all, KEYS, { total: 5, hasMore: false }), false); + assert.equal(offersAllMatching(new Set(KEYS.slice(1)), KEYS, { total: 9, hasMore: true }), false); + assert.equal(offersAllMatching(new Set(), [], { total: 9, hasMore: true }), false); +}); + +test('the counts read "N selected" and "All N matching selected", grouped', async () => { + const { matchingLabel, matchingOffer } = await source; + assert.equal(matchingLabel(12345), 'All 12,345 matching selected'); + assert.equal(matchingOffer(12345), 'Select all 12,345 matching'); +}); + +function fakeTimers() { + const timers = new Map(); + let next = 1; + return { + set: (callback, delay) => { timers.set(next, { callback, delay }); return next++; }, + clear: id => timers.delete(id), + fire: () => { for (const [id, { callback }] of [...timers]) { timers.delete(id); callback(); } }, + pending: () => timers.size, + delays: () => [...timers.values()].map(timer => timer.delay), + }; +} + +test('a held press fires once after the delay and swallows the click that follows', async () => { + const { createLongPress, LONG_PRESS_MS } = await source; + const timers = fakeTimers(), fired = []; + const press = createLongPress({ onLongPress: target => fired.push(target), setTimer: timers.set, clearTimer: timers.clear }); + press.down(10, 10, 'card-a'); + assert.deepEqual(timers.delays(), [LONG_PRESS_MS]); + assert.equal(LONG_PRESS_MS, 500); + press.move(14, 13); + timers.fire(); + assert.deepEqual(fired, ['card-a']); + press.up(); + assert.equal(press.consumeClick(), true); + assert.equal(press.consumeClick(), false); +}); + +test('moving past the slop, lifting early or cancelling never fires', async () => { + const { createLongPress, LONG_PRESS_SLOP } = await source; + assert.equal(LONG_PRESS_SLOP, 10); + const timers = fakeTimers(), fired = []; + const press = createLongPress({ onLongPress: target => fired.push(target), setTimer: timers.set, clearTimer: timers.clear }); + press.down(0, 0, 'a'); press.move(0, 11); + assert.equal(timers.pending(), 0); + press.down(0, 0, 'b'); press.up(); + assert.equal(timers.pending(), 0); + press.down(0, 0, 'c'); + assert.equal(press.pending(), true); + press.cancel(); + assert.equal(press.pending(), false); + assert.equal(timers.pending(), 0); + press.move(0, 0); press.up(); press.cancel(); + assert.deepEqual(fired, []); + assert.equal(press.consumeClick(), false); + // A new press drops a long press whose click never came. + press.down(0, 0, 'd'); timers.fire(); + press.down(0, 0, 'e'); + assert.equal(press.consumeClick(), false); + timers.fire(); + assert.deepEqual(fired, ['d', 'e']); + // A cancel (a mouse press on a hybrid device) forgets a fired press whose click never came. + press.cancel(); + assert.equal(press.consumeClick(), false); +}); diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index bb81a6e..49cc080 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -494,3 +494,11 @@ test('defaults apply to a new visit but not to the current view or a saved view' assert.deepEqual(initialImagesView({}), { layout: 'grid', density: 'comfortable' }); assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'grid', density: 'comfortable' }); }); + +test('listingFilter is the listing query as an object, without paging (#392)', async () => { + const { listingFilter } = await source; + assert.deepEqual(listingFilter({q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:[' x ','y'], media:'video', sort:'oldest', favorites:true}), + {q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:'x,y', media:'video', sort:'oldest', favorites:'1'}); + assert.deepEqual(listingFilter({root:'all', q:'', tags:[], media:'all'}), {sort:'newest'}); + assert.throws(() => listingFilter({sort:'sideways'}), TypeError); +}); diff --git a/tests/js/workspace-images-tagging-bulk.test.js b/tests/js/workspace-images-tagging-bulk.test.js index 4ed2fa1..ac6dbef 100644 --- a/tests/js/workspace-images-tagging-bulk.test.js +++ b/tests/js/workspace-images-tagging-bulk.test.js @@ -196,3 +196,29 @@ test('a preset starts the Tag image dialog on the chosen model and options', asy assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'gguf', options: { instructions: '' } } }), TypeError); assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'gguf', options: { nope: 1 } } }), TypeError); }); + +test('all matching sends the listing filter instead of targets, up to 10,000 images (#392)', async () => { + const { openAddTags, openAutoTag, MAX_FILTER_IMAGES, TOO_MANY_MATCHING } = module(); + assert.equal(MAX_FILTER_IMAGES, 10000); + assert.equal(TOO_MANY_MATCHING, 'Narrow the view to at most 10,000 images.'); + const toasts = [], posted = [], tagged = []; + let ended = null; + const store = { start: async (kind, body) => { posted.push([kind, body]); return { job: { id: JOB_ID, state: 'running' } }; }, + find: () => ended, subscribe: () => () => {} }; + const filter = { q: 'cat', sort: 'newest' }; + // The loaded images can be more than 500: the filter, not the identities, is sent. + const loaded = Array.from({ length: 600 }, (_, index) => image(`bulk/${index}.png`)); + let dialog = null; + openAddTags({ scope: pageScope(), store, images: loaded, filter, count: 1234, notify: text => toasts.push(text), + onTagged: keys => tagged.push(keys.size), openInput: args => { dialog = args; } }); + assert.ok(dialog, 'the Add tags dialog opens'); + ended = { id: JOB_ID, state: 'finished', counters: { tagged: 3 } }; + await dialog.onApply('sunset'); + assert.deepEqual(posted, [['image-tags', { mode: 'add', filter, tags: ['sunset'] }]]); + // The loaded files are read again when the job tagged any. + assert.deepEqual(tagged, [600]); + openAddTags({ scope: pageScope(), store, images: loaded, filter, count: 10001, notify: text => toasts.push(text), + openInput: () => assert.fail('no dialog over the cap') }); + openAutoTag({ api: {}, scope: pageScope(), store, images: loaded, filter, count: 10001, notify: text => toasts.push(text) }); + assert.deepEqual(toasts, [TOO_MANY_MATCHING, TOO_MANY_MATCHING]); +}); diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index cb2ff6d..f30f394 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -2335,3 +2335,162 @@ class TestWorkspaceImageTags(ImageAPITestCase): ) ] self.assertFalse([step for step in plan if step.startswith("SCAN")], plan) + + +class TestListingFilterSelection(ImageAPITestCase): + """#392: "Select all N matching" resolves the listing filter on the server.""" + + async def tearDownAsync(self): + self.api.db.close_all() + await super().tearDownAsync() + + def media(self, name, mtime=100): + target = self.output_dir / name + target.parent.mkdir(parents=True, exist_ok=True) + make_png(target) + os.utime(target, (mtime, mtime)) + return target + + async def listing(self, **params): + response = await self.client.get( + "/prompt_manager/images/output", + params={"mode": "workspace", "limit": 500, **params}, + ) + return (await response.json())["images"] + + async def test_resolve_lists_every_match_in_listing_order_beyond_a_page(self): + for number in range(7): + self.media(f"bulk/{number:02}.png", 100 + number) + self.media("other/x.png", 500) + from py.api.images import parse_listing_filter + + params = parse_listing_filter({"subfolder": "bulk", "sort": "newest"}) + resolved = await self.api.resolve_listing_filter(params, cap=100) + + listed = await self.listing(subfolder="bulk", sort="newest") + self.assertEqual( + [list(target) for target in resolved.targets], + [[i["root_index"], i["root_scope"], i["path"]] for i in listed], + ) + self.assertEqual(len(resolved.targets), 7) + self.assertFalse(resolved.truncated) + + async def test_resolve_reports_linked_positives_and_unlinked_count(self): + first = self.media("a.png", 300) + second = self.media("b.png", 200) + self.media("c.png", 100) + prompt = self._save_prompt("shared positive") + self.api.db.link_image_to_prompt(prompt, str(first)) + self.api.db.link_image_to_prompt(prompt, str(second)) + from py.api.images import parse_listing_filter + + resolved = await self.api.resolve_listing_filter(parse_listing_filter({}), 100) + + self.assertEqual(resolved.positive_ids, [prompt]) + self.assertEqual(resolved.unlinked, 1) + + async def test_resolve_marks_more_than_the_cap_as_truncated(self): + for number in range(4): + self.media(f"{number}.png", 100 + number) + from py.api.images import parse_listing_filter + + resolved = await self.api.resolve_listing_filter(parse_listing_filter({}), 3) + + self.assertTrue(resolved.truncated) + + def test_parse_listing_filter_rejects_anything_but_listing_strings(self): + from py.api.images import parse_listing_filter + + self.assertEqual( + parse_listing_filter({"q": "cat", "tags": "a,b", "root": "0"}), + {"q": "cat", "tags": "a,b", "root": "0"}, + ) + for bad in ( + None, + [], + "q=cat", + {"mode": "workspace"}, + {"cursor": "x"}, + {"path": "a.png"}, + {"q": 1}, + {"q": "x" * 2049}, + {"sort": "sideways"}, + {"media": "audio"}, + {"favorites": "yes"}, + {"subfolder": "../up"}, + {"root": "-1"}, + ): + with self.subTest(bad=bad): + with self.assertRaises(ValueError): + params = parse_listing_filter(bad) + # Root bounds need the roots: the resolver checks them. + from py.api.images import _workspace_root + + _workspace_root(params, [self.output_dir]) + + async def test_bulk_category_takes_the_listing_filter(self): + first = self.media("keep/a.png", 300) + second = self.media("keep/b.png", 200) + self.media("keep/c.png", 100) + other = self.media("skip/d.png", 400) + prompt = self._save_prompt("in the filter") + outside = self._save_prompt("outside the filter") + self.api.db.link_image_to_prompt(prompt, str(first)) + self.api.db.link_image_to_prompt(prompt, str(second)) + self.api.db.link_image_to_prompt(outside, str(other)) + + response = await self.client.post( + "/prompt_manager/bulk/category", + json={"images_filter": {"subfolder": "keep"}, "category": "Kept"}, + ) + data = await response.json() + + self.assertEqual(response.status, 200, data) + self.assertEqual( + (data["updated_count"], data["prompt_count"], data["skipped"]), (1, 1, 1) + ) + self.assertEqual(self.api.db.get_prompt_by_id(prompt)["category"], "Kept") + self.assertNotEqual(self.api.db.get_prompt_by_id(outside)["category"], "Kept") + + async def test_bulk_category_filter_with_no_linked_image_changes_nothing(self): + self.media("a.png") + + response = await self.client.post( + "/prompt_manager/bulk/category", + json={"images_filter": {}, "category": "Kept"}, + ) + data = await response.json() + + self.assertEqual(response.status, 200, data) + self.assertEqual( + (data["updated_count"], data["prompt_count"], data["skipped"]), (0, 0, 1) + ) + + async def test_bulk_category_refuses_a_bad_or_doubled_filter(self): + self.media("a.png") + prompt = self._save_prompt("p") + for body in ( + {"images_filter": {"sort": "sideways"}, "category": "x"}, + {"images_filter": "all", "category": "x"}, + {"images_filter": {}, "prompt_ids": [prompt], "category": "x"}, + {"images_filter": {"root": "9"}, "category": "x"}, + ): + with self.subTest(body=body): + response = await self.client.post( + "/prompt_manager/bulk/category", json=body + ) + self.assertEqual(response.status, 400, await response.text()) + + async def test_bulk_category_refuses_a_renumbered_source(self): + self.media("a.png") + + response = await self.client.post( + "/prompt_manager/bulk/category", + json={ + "images_filter": {"root": "0", "root_scope": "f" * 64}, + "category": "x", + }, + ) + + self.assertEqual(response.status, 409) + self.assertEqual((await response.json())["code"], "source-changed") diff --git a/tests/test_image_tag_jobs.py b/tests/test_image_tag_jobs.py index 25943b8..0af7498 100644 --- a/tests/test_image_tag_jobs.py +++ b/tests/test_image_tag_jobs.py @@ -11,6 +11,7 @@ engine is B2's fake service. import asyncio import hashlib import json +import os import sqlite3 import threading import unittest @@ -828,3 +829,108 @@ class TestFixRound(ImageTagJobTestCase): if __name__ == "__main__": unittest.main() + + +class TestFilterStart(ImageTagJobTestCase): + """#392: "Select all N matching" starts the job with the listing filter.""" + + async def test_filter_start_tags_every_matching_file_in_listing_order(self): + for number in range(6): + path = self.png(f"bulk/{number:02}.png", color=(number, 1, 1)) + os.utime(path, (100 + number, 100 + number)) + self.png("other/x.png") + + job = await self.run_job( + { + "mode": "add", + "filter": {"subfolder": "bulk", "sort": "oldest"}, + "tags": ["picked"], + } + ) + + self.assertEqual(job.scope, {"mode": "add", "images": 6}) + self.assertEqual(job.counters["tagged"], 6) + self.assertEqual( + [path for path, _detail in self.rows(job, "tagged")], + [f"bulk/{number:02}.png" for number in range(6)], + ) + self.assertEqual(self.stored("bulk/03.png")["tags"], ["picked"]) + self.assertEqual(self.stored("other/x.png")["tags"], []) + + async def test_auto_filter_start_runs_the_model_on_the_matching_files(self): + self.png("bulk/a.png") + self.png("other/b.png", color=(1, 2, 3)) + + job = await self.run_job( + { + "mode": "auto", + "filter": {"subfolder": "bulk"}, + "model_type": "wd14-vit", + "options": dict(WD14), + } + ) + + self.assertEqual(job.scope["images"], 1) + self.assertEqual(job.counters["tagged"], 1) + self.assertEqual(self.stored("other/b.png")["tags"], []) + + async def test_filter_with_no_match_is_refused_without_a_job(self): + self.png("a.png") + + status, data = await self.start( + {"mode": "add", "filter": {"subfolder": "missing"}, "tags": ["x"]} + ) + + self.assertEqual((status, data["code"]), (400, "no-images")) + self.assertEqual(self.api._jobs.list(), []) + + async def test_filter_over_the_cap_is_refused_without_a_job(self): + from py.api import image_tag_jobs + + for number in range(3): + self.png(f"{number}.png", color=(number, 0, 0)) + with patch.object(image_tag_jobs, "MAX_FILTER_TARGETS", 2): + status, data = await self.start( + {"mode": "add", "filter": {}, "tags": ["x"]} + ) + + self.assertEqual((status, data["code"]), (400, "too-many-images")) + self.assertEqual(self.api._jobs.list(), []) + self.assertEqual(image_tag_jobs.MAX_FILTER_TARGETS, 10_000) + + async def test_filter_bodies_are_validated(self): + self.png("a.png") + target = self.target("a.png") + for body in ( + {"mode": "add", "filter": {}, "targets": [target], "tags": ["x"]}, + {"mode": "add", "filter": "all", "tags": ["x"]}, + {"mode": "add", "filter": {"sort": "sideways"}, "tags": ["x"]}, + {"mode": "add", "filter": {"cursor": "x"}, "tags": ["x"]}, + {"mode": "add", "filter": {"root": "7"}, "tags": ["x"]}, + {"mode": "add", "filter": {}, "tags": []}, + ): + with self.subTest(body=body): + status, data = await self.start(body) + self.assertEqual(status, 400, data) + self.assertEqual(self.api._jobs.list(), []) + + async def test_a_filter_whose_source_was_renumbered_is_refused(self): + self.png("a.png") + scope = self.target("a.png")["root_scope"] + + job = await self.run_job( + {"mode": "add", "filter": {"root": "0", "root_scope": scope}, "tags": ["x"]} + ) + self.assertEqual(job.counters["tagged"], 1) + status, data = await self.start( + { + "mode": "add", + "filter": {"root": "0", "root_scope": "f" * 64}, + "tags": ["x"], + } + ) + self.assertEqual((status, data["code"]), (409, "source-changed")) + status, data = await self.start( + {"mode": "add", "filter": {"root_scope": scope}, "tags": ["x"]} + ) + self.assertEqual(status, 400, data) diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 140905b..de6c241 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -275,6 +275,12 @@ "functions": 85, "statements": 98 }, + "workspace/images-multiselect.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, "workspace/images-selection.mjs": { "lines": 98, "branches": 93, diff --git a/web/js/workspace/images-multiselect.mjs b/web/js/workspace/images-multiselect.mjs new file mode 100644 index 0000000..4fd2436 --- /dev/null +++ b/web/js/workspace/images-multiselect.mjs @@ -0,0 +1,80 @@ +/** + * File-manager selection on the Images page (#392, D135): pure helpers for the selection bar (images-selection.mjs). + * Keys are image keys (`root_index:path`) in the listing's display order, so a range spans every loaded batch + * whether or not its cards are mounted. + */ + +/** The press that turns on touch selection mode, and how far it may drift before it counts as a scroll. */ +export const LONG_PRESS_MS = 500; +export const LONG_PRESS_SLOP = 10; + +/** + * The keys from `from` to `to`, inclusive, in `keys` order (either direction). Without a listed `from` (no anchor + * yet, or it left the listing) the range is `to` alone; an unlisted `to` gives none. + */ +export function rangeKeys(keys, from, to) { + const end = keys.indexOf(to); + if (end < 0) return []; + const start = from === null || from === undefined ? -1 : keys.indexOf(from); + if (start < 0) return [to]; + return keys.slice(Math.min(start, end), Math.max(start, end) + 1); +} + +/** `selected` with `keys` added (`on`) or removed, frozen; the same set when nothing changes. */ +export function applyKeys(selected, keys, on) { + if (keys.every(key => selected.has(key) === on)) return selected; + const next = new Set(selected); + for (const key of keys) { + if (on) next.add(key); else next.delete(key); + } + return Object.freeze(next); +} + +/** Whether `keys` is non-empty and every one of them is in `selected`. */ +export function everyChecked(selected, keys) { + return keys.length > 0 && selected.size >= keys.length && keys.every(key => selected.has(key)); +} + +/** "Select all N matching" shows once every loaded image is checked and the listing holds more than is loaded. */ +export function offersAllMatching(selected, keys, { total, hasMore }) { + return Boolean(hasMore) && total > keys.length && everyChecked(selected, keys); +} + +const grouped = count => count.toLocaleString('en-US'); +export const matchingLabel = total => `All ${grouped(total)} matching selected`; +export const matchingOffer = total => `Select all ${grouped(total)} matching`; + +/** + * A touch long-press: `down` starts the timer, a `move` past the slop, `up` or `cancel` stops it, and + * `onLongPress(target)` runs once it fires. `consumeClick()` answers whether the click the press ends in belongs to + * the long press (so the page swallows it); it answers once. A new `down` forgets an unclaimed long press. + */ +export function createLongPress({ onLongPress, delay = LONG_PRESS_MS, slop = LONG_PRESS_SLOP, + setTimer = setTimeout, clearTimer = clearTimeout }) { + let timer = null, origin = null, fired = false; + const stop = () => { + if (timer !== null) clearTimer(timer); + timer = null; origin = null; + }; + return { + down(x, y, target) { + stop(); + fired = false; + origin = { x, y }; + timer = setTimer(() => { timer = null; origin = null; fired = true; onLongPress(target); }, delay); + }, + move(x, y) { + if (origin && Math.hypot(x - origin.x, y - origin.y) > slop) stop(); + }, + up: stop, + /** A press that ends without its click (a mouse press next, a scroll): its long press is forgotten too. */ + cancel() { stop(); fired = false; }, + /** Whether a touch press is held and has not yet fired (its context menu belongs to the long press). */ + pending: () => timer !== null, + consumeClick() { + const claimed = fired; + fired = false; + return claimed; + }, + }; +} diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index c30894a..ee5cd69 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -4,11 +4,16 @@ import { inspectPath, toInspectionRecord } from './inspection-state.mjs'; import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs'; import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs'; import { openInputDialog } from './workspace-dialogs.mjs'; -import { openAddTags, openAutoTag } from './images-tagging-bulk.mjs'; +import { MAX_FILTER_IMAGES, TOO_MANY_MATCHING, openAddTags, openAutoTag } from './images-tagging-bulk.mjs'; import { showWorkspaceToast } from './toast.mjs'; +import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs'; const CHECKBOX = '.image-overlays input[type="checkbox"]'; +const CHECKBOX_LABEL = '.image-overlays > label'; +const CARD = 'article[data-image-key]'; const CATEGORY_URL = '/prompt_manager/bulk/category'; +// D135: Export metadata reads every image's inspection in the browser, so it waits for loaded images. +export const EXPORT_LOADED_ONLY = 'Export metadata reads each image in the browser, so it works on loaded images only.'; function el(tag, text, className) { const element = document.createElement(tag); @@ -29,60 +34,196 @@ function el(tag, text, className) { * - `retain(kept)` drops the checked files a refreshed listing no longer holds (a tag save under a filter, I7). * - With the Activity store `activity`, Add tags and Auto-tag selected (D85) follow the count, as the concept's Add tags * leads its actions; `onTagged(keys)` re-reads the files a finished tagging job tagged. The selection stays. + * + * File-manager selection (#392, D135, beyond the concept's checkbox): the anchor is the last image toggled on its own; + * Shift+click (a checkbox or anywhere on a card) applies the anchor's range, Ctrl/Cmd+click toggles a card without + * opening it, a touch long-press starts touch selection mode in which taps toggle. `selectAll`, `extend` and + * `clear` serve the grid's keys (images.mjs). `getListing()` gives `{total, hasMore, positiveCount}` and + * `getFilter()` the listing filter: once every loaded image is checked and more match, the bar offers "Select all N + * matching", whose actions send that filter to the server. */ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged, - checked: initial = Object.freeze(new Set()) }) { + getListing, getFilter, checked: initial = Object.freeze(new Set()) }) { let checked = initial; let exporting = false, categorizing = false; + // D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox. + let anchor = null, allMatching = false, touchMode = false, lastPointer = 'mouse', shiftBox = null; const element = el('div'); element.id = 'bulkbar'; const bulk = el('div', undefined, 'bulkbar'), count = el('strong'); + const selectMatching = createButton(''); + scope.listen(selectMatching, 'click', () => { + allMatching = true; + render(); + focusAfterOffer(); + }); const setCategory = createButton('Set category'); scope.listen(setCategory, 'click', () => openCategory(setCategory)); const exportMetadata = createButton('Export metadata'); - scope.listen(exportMetadata, 'click', () => exportChecked(exportMetadata)); + scope.listen(exportMetadata, 'click', () => { if (!allMatching) exportChecked(exportMetadata); }); const clearButton = createButton('Clear'); // Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page. scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); }); bulk.append(count); if (activity) { const addTags = createButton('Add tags'); - scope.listen(addTags, 'click', () => openAddTags({ scope, store: activity, images: checkedImages(), trigger: addTags, onTagged })); + scope.listen(addTags, 'click', () => openAddTags({ scope, store: activity, images: checkedImages(), trigger: addTags, onTagged, + ...matchingTarget() })); const autoTag = createButton('Auto-tag selected'); // D85 - scope.listen(autoTag, 'click', () => openAutoTag({ api, scope, store: activity, images: checkedImages(), trigger: autoTag, onTagged })); + scope.listen(autoTag, 'click', () => openAutoTag({ api, scope, store: activity, images: checkedImages(), trigger: autoTag, onTagged, + ...matchingTarget() })); bulk.append(addTags, autoTag); } bulk.append(setCategory, exportMetadata, clearButton); /** The count updates in place: the same bar and `strong`, its text written only when it changes. */ function render() { - if (!checked.size) { bulk.remove(); return; } - const label = selectionLabel(checked.size); + if (!checked.size && !allMatching) { touchMode = false; bulk.remove(); return; } + const listing = getListing(); + const label = allMatching ? matchingLabel(listing.total) : selectionLabel(checked.size); if (count.textContent !== label) count.textContent = label; + // "Select all N matching" (D135) follows the count while every loaded image is checked and more match. + if (!allMatching && offersAllMatching(checked, loadedKeys(), listing)) { + const offer = matchingOffer(listing.total); + if (selectMatching.textContent !== offer) selectMatching.textContent = offer; + if (count.nextElementSibling !== selectMatching) count.after(selectMatching); + } else selectMatching.remove(); + if (allMatching) { + exportMetadata.setAttribute('aria-disabled', 'true'); + exportMetadata.title = EXPORT_LOADED_ONLY; + } else if (exportMetadata.title) { + exportMetadata.removeAttribute('title'); + if (!exporting) exportMetadata.removeAttribute('aria-disabled'); + } if (bulk.parentNode !== element) element.append(bulk); } - function clear() { - if (!checked.size) return; - checked = Object.freeze(new Set()); - for (const box of grid.querySelectorAll(CHECKBOX)) box.checked = false; + /** Every rendered checkbox shows the selection (cards mounted later read `isChecked`). */ + function paint() { + for (const box of grid.querySelectorAll(CHECKBOX)) { + const on = isChecked(box.closest(CARD).dataset.imageKey); + if (box.checked !== on) box.checked = on; + } + } + function update(next) { + checked = next; + paint(); render(); } + /** Leaving "all N matching": the loaded images become the explicit selection the next change applies to. */ + function settleMatching() { + if (!allMatching) return; + allMatching = false; + checked = Object.freeze(new Set(loadedKeys())); + } + function clear() { + anchor = null; touchMode = false; + if (!checked.size && !allMatching) return; + allMatching = false; + update(Object.freeze(new Set())); + } /** Keep only the checked files `kept(key)` still lists (a refreshed result), so the count is what the actions apply to. */ function retain(kept) { + if (allMatching) { render(); return; } const next = [...checked].filter(kept); if (next.length === checked.size) return; checked = Object.freeze(new Set(next)); render(); } + function loadedKeys() { + return getItems().map(imageKey); + } + function isChecked(key) { + return allMatching || checked.has(key); + } function checkedImages() { - return getItems().filter(image => checked.has(imageKey(image))); + return allMatching ? getItems() : getItems().filter(image => checked.has(imageKey(image))); + } + /** The actions' images for "all N matching": the listing filter and its total, which the server resolves. */ + function matchingTarget() { + return allMatching ? { filter: getFilter(), count: getListing().total } : {}; + } + /** Toggle one image on its own; it becomes the anchor. */ + function toggle(key, on = !isChecked(key)) { + settleMatching(); + anchor = key; + update(toggleSelection(checked, key, on)); + } + /** The anchor's range to `key` takes `on`; without an anchor `key` is toggled on and becomes it. */ + function range(key, on = true) { + settleMatching(); + const keys = rangeKeys(loadedKeys(), anchor, key); + if (anchor === null || keys.length === 1) anchor = key; + update(applyKeys(checked, keys, on)); + } + /** Shift+arrow keys: the range from the anchor (`from`, the card focus left, when there is none) to `to`. */ + function extend(from, to) { + if (anchor === null || !loadedKeys().includes(anchor)) anchor = from; + range(to, true); + } + function selectAll() { + if (allMatching) return; + update(applyKeys(checked, loadedKeys(), true)); + } + function focusAfterOffer() { + // The pressed offer left the bar: focus moves to the count's first action instead of the page. + (bulk.querySelector('button:not([aria-disabled="true"])') ?? focusFallback).focus({ preventScroll: true }); } // Every card carries its image key; its only form control is the checkbox, so a toggle never opens or inspects. scope.listen(grid, 'change', event => { const box = event.target; if (!box.matches(CHECKBOX)) return; - checked = toggleSelection(checked, box.closest('article[data-image-key]').dataset.imageKey, box.checked); - render(); + const key = box.closest(CARD).dataset.imageKey; + if (shiftBox === key) range(key, box.checked); + else toggle(key, box.checked); + shiftBox = null; + }); + // D135: modified clicks select instead of opening, inspecting or starring. Captured, so the page's own click + // handler (images.mjs) never sees them; a plain click keeps its meaning. + grid.addEventListener('click', event => { + const card = event.target.closest(CARD); + if (!card) return; + const key = card.dataset.imageKey; + if (longPress.consumeClick()) { swallow(event); return; } + if (event.target.closest(CHECKBOX_LABEL)) { + // The box toggles natively; its change event applies the range. + if (event.shiftKey) shiftBox = key; + return; + } + // A keyboard click (Enter, Space: `detail` 0) keeps its plain meaning in touch selection mode. + const tapped = touchMode && lastPointer !== 'mouse' && event.detail !== 0; + if (event.shiftKey) { swallow(event); range(key, true); } + else if (event.ctrlKey || event.metaKey || tapped) { swallow(event); toggle(key); } + }, { capture: true, signal: scope.signal }); + function swallow(event) { + event.preventDefault(); + event.stopPropagation(); + } + // A touch long-press selects the card and starts touch selection mode (D135). + const longPress = createLongPress({ onLongPress: key => { + touchMode = true; + anchor = key; + if (!isChecked(key)) toggle(key, true); + } }); + scope.cleanup(() => longPress.cancel()); + scope.listen(grid, 'pointerdown', event => { + lastPointer = event.pointerType || 'mouse'; + shiftBox = null; + const card = event.target.closest(CARD); + if (lastPointer === 'mouse' || !card || !event.isPrimary) { longPress.cancel(); return; } + longPress.down(event.clientX, event.clientY, card.dataset.imageKey); + }); + scope.listen(grid, 'pointermove', event => longPress.move(event.clientX, event.clientY)); + for (const type of ['pointerup', 'pointercancel', 'pointerleave']) scope.listen(grid, type, () => longPress.up()); + // A long-press's context menu (Android) would cover the selection it just started. + scope.listen(grid, 'contextmenu', event => { + if (lastPointer !== 'mouse' && (touchMode || longPress.pending())) event.preventDefault(); + }); + // Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear. + scope.listen(element, 'keydown', event => { + if (event.key !== 'Escape' || (!checked.size && !allMatching)) return; + event.preventDefault(); + clear(); + focusFallback.focus({ preventScroll: true }); }); /** Busy until it settles: `aria-disabled` now, released in a `finally` so a throw cannot leave it stuck. */ @@ -126,8 +267,11 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o */ function openCategory(button) { if (categorizing) return; - const targets = categoryTargets(checkedImages()); - if (!targets.promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; } + // All matching (D135): the listing's positive count says whether any match is linked; the server resolves. + if (allMatching && getListing().total > MAX_FILTER_IMAGES) { showWorkspaceToast(TOO_MANY_MATCHING); return; } + const targets = allMatching ? {filter: getFilter()} : categoryTargets(checkedImages()); + const linked = allMatching ? getListing().positiveCount !== 0 : targets.promptIds.length > 0; + if (!linked) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; } openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label, action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, onApply: category => applyCategory(button, targets, category) }); } @@ -135,12 +279,12 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o * The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page * not to save a visit that predates it). A failure keeps the selection; a success reloads through `onCategorySet`. */ - async function applyCategory(button, { promptIds, skipped }, category) { + async function applyCategory(button, { promptIds, skipped, filter }, category) { categorizing = true; const release = busy(button); let result = null; try { - result = await api.post(CATEGORY_URL, { prompt_ids: promptIds, category }); + result = await api.post(CATEGORY_URL, filter ? { images_filter: filter, category } : { prompt_ids: promptIds, category }); } catch { // Reported below; the selection stays as it was. } finally { @@ -149,7 +293,9 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o } if (scope.signal.aborted) return; if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; } - showWorkspaceToast(categoryOutcome(updatedCount(result, promptIds.length), skipped)); + if (filter && !result.prompt_count) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; } + showWorkspaceToast(filter ? categoryOutcome(updatedCount(result, result.prompt_count), Number(result.skipped) || 0) + : categoryOutcome(updatedCount(result, promptIds.length), skipped)); const focused = document.activeElement === button; onCategorySet(); // The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading. @@ -159,10 +305,16 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o render(); return { element, - isChecked: key => checked.has(key), - checked: () => checked, + isChecked, + // A saved visit keeps "all N matching" as its loaded images; the offer then shows again. + checked: () => (allMatching ? Object.freeze(new Set(loadedKeys())) : checked), + hasSelection: () => allMatching || checked.size > 0, clear, retain, + selectAll, + extend, + /** A loaded batch or a refreshed total: the count and the offer follow it. */ + listingChanged: () => { if (checked.size || allMatching) render(); }, /** Whether a Set category write is still in flight. */ writing: () => categorizing, }; diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index c61daf4..3b702f7 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -56,6 +56,16 @@ function queryFor(filters, batchSize) { return query.toString(); } +/** + * The listing's filter as the bulk routes take it (#392 "Select all N matching"): the same parameters the listing + * query carries, as an object of strings, without `mode`, `limit` and `cursor`. + */ +export function listingFilter(filters) { + const query = new URLSearchParams(queryFor(filters, 1)); + query.delete('mode'); query.delete('limit'); + return Object.fromEntries(query); +} + function validatePage(page, cursor) { if (!page || !Array.isArray(page.images) || !Number.isSafeInteger(page.total) || page.total < 0 || typeof page.has_more !== 'boolean' || diff --git a/web/js/workspace/images-tagging-bulk.mjs b/web/js/workspace/images-tagging-bulk.mjs index b4788be..31fd629 100644 --- a/web/js/workspace/images-tagging-bulk.mjs +++ b/web/js/workspace/images-tagging-bulk.mjs @@ -9,6 +9,10 @@ * apply** (D90, ruling T3-bulk): **Apply automatically** starts the job, **Review each image** starts the browser * review session (images-tagging-review.mjs). A model that is not downloaded keeps Start disabled and says why. * + * - **All N matching** (#392, D135): with `filter` (the listing's, images-state.mjs `listingFilter`) and `count` (its + * total), both send the filter instead of the targets and the server resolves it, up to 10,000 images. Review each + * image walks images in the browser, so it is not offered. + * * The toast says the job started (the Activity store's "Tagging started." / "Auto-tagging started."), or why it did * not: a running record-changing job (named, with its link), a held model, a refused body. Activity shows progress. * The selection stays either way. When a started job ends having tagged files, `onTagged(keys)` re-reads them. @@ -26,6 +30,9 @@ import { showWorkspaceToast } from './toast.mjs'; export const IMAGE_TAG_JOB = 'image-tags'; export const MAX_BULK_IMAGES = 500; export const TOO_MANY = 'Select at most 500 images.'; +// #392: a filter start resolves on the server (py/api/images.py MAX_LISTING_SELECTION). +export const MAX_FILTER_IMAGES = 10000; +export const TOO_MANY_MATCHING = 'Narrow the view to at most 10,000 images.'; export const NO_TAGS = 'Enter at least one tag.'; // D118 export const ADD_DIALOG = Object.freeze({title: 'Tag selected images', label: 'Tags, separated by commas', action: 'Apply'}); export const AUTO_TITLE = 'Auto-tag selected images'; @@ -53,6 +60,16 @@ export function bulkTargets(images) { return images.map(imageTarget).filter(Boolean); } +/** + * The job body's images: `{filter}` for all matching (null over MAX_FILTER_IMAGES), else `{targets}` (null over 500). + * Returns `[images, refusal]`, the refusal being the toast to show. + */ +function jobImages(images, filter, count) { + if (filter) return count > MAX_FILTER_IMAGES ? [null, TOO_MANY_MATCHING] : [{filter}, null]; + const targets = bulkTargets(images); + return targets ? [{targets}, null] : [null, TOO_MANY]; +} + /** The Add tags field's names, deduplicated as the Tag image dialog's manual field is. */ export const parseBulkTags = parseManualTags; @@ -107,13 +124,13 @@ function followTagged({store, scope, job, images, onTagged}) { * to no names keeps the dialog and its text, and reports NO_TAGS on the field (D118). `openInput` is the dialog seam. */ export function openAddTags({scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast, - openInput = openInputDialog}) { - const targets = bulkTargets(images); - if (!targets) { notify(TOO_MANY); return; } + openInput = openInputDialog, filter = null, count = images.length}) { + const [chosen, refusal] = jobImages(images, filter, count); + if (refusal) { notify(refusal); return; } openInput({scope, trigger, ...ADD_DIALOG, validate: text => (parseBulkTags(text).length ? null : NO_TAGS), onApply: async text => { const tags = parseBulkTags(text); - const job = await startTagJob({store, scope, body: {mode: 'add', targets, tags}, notify}); + const job = await startTagJob({store, scope, body: {mode: 'add', ...chosen, tags}, notify}); if (job) followTagged({store, scope, job, images, onTagged}); }}); } @@ -141,15 +158,16 @@ function applyChoice(onChange) { * Auto-tag selected (D85): the model panel, How to apply (D90), Cancel and Start. Start validates the fields as * Generate does, then starts the job or the review session over `images` (the checked files, taken now). */ -export function openAutoTag({api, scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast}) { - const targets = bulkTargets(images); - if (!targets) { notify(TOO_MANY); return; } +export function openAutoTag({api, scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast, + filter = null, count = images.length}) { + const [chosen, refusal] = jobImages(images, filter, count); + if (refusal) { notify(refusal); return; } const listed = images.filter(imageTarget); const local = createScope(); const options = createTaggingOptionsController({api}); // D85: the Tag image dialog's model panel without Generate; its Suggestions field is the job's count (D91). const {element: panel, validate} = renderModelPanel({scope: local, controller: options, - heading: `Auto-tag ${countOf(listed.length, 'image')}`, generate: false}); // D118 + heading: `Auto-tag ${countOf(filter ? count : listed.length, 'image')}`, generate: false}); // D118 let choice = APPLY_CHOICES[0]; const actions = el('div', undefined, 'dialog-actions'); const cancel = createButton('Cancel'); @@ -159,7 +177,8 @@ export function openAutoTag({api, scope, store, images, trigger, onTagged = () = const status = el('div', undefined, 'small'); status.id = 'tag-generation-status'; status.setAttribute('role', 'status'); - const choices = applyChoice(next => { choice = next; start.textContent = next.action; }); // D90 + // D90; all matching (D135) has no Review each image, which walks images in the browser. + const choices = filter ? null : applyChoice(next => { choice = next; start.textContent = next.action; }); const render = state => { if (status.textContent !== state.unavailable) status.textContent = state.unavailable; if (state.canStart) start.removeAttribute('aria-disabled'); @@ -178,11 +197,11 @@ export function openAutoTag({api, scope, store, images, trigger, onTagged = () = return; } closeWorkspaceDialog(); - startTagJob({store, scope, body: {mode: 'auto', targets, ...request}, notify}).then(job => { + startTagJob({store, scope, body: {mode: 'auto', ...chosen, ...request}, notify}).then(job => { if (job) followTagged({store, scope, job, images: listed, onTagged}); }); }); - openContentDialog({scope, title: AUTO_TITLE, nodes: [panel, choices, status, actions], trigger, onClose: () => { + openContentDialog({scope, title: AUTO_TITLE, nodes: [panel, choices, status, actions].filter(Boolean), trigger, onClose: () => { document.getElementById('modal')?.classList.remove('tagging-modal'); local.dispose(); options.dispose(); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index df8e8bd..2b45744 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -1,5 +1,5 @@ import { createScope, createButton } from '../shared/toolkit/core.js'; -import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView } from './images-state.mjs'; +import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, listingFilter } from './images-state.mjs'; import { createImageCard } from './image-card.mjs'; import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs'; import { mountFileInspector } from './file-inspector.mjs'; @@ -254,8 +254,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage body.append(collection, details); // The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The // selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none. - const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state.items, focusFallback, - checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys)}); + const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback, + checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys), + // #392 "Select all N matching": the listing's total and its filter, as the server lists them. + getListing:() => ({total:state?.total ?? 0, hasMore:Boolean(state?.hasMore), positiveCount:state?.positiveCount ?? null}), + // A Source filter carries its root's scope, so a renumbered source is refused (source-changed), not acted on. + getFilter:() => { + const filter = listingFilter(filters), scope = state?.items[0]?.root_scope; + return filter.root !== undefined && typeof scope === 'string' ? {...filter, root_scope:scope} : filter; + }}); if (bar) main.append(bar.element); main.append(body); const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender, @@ -386,8 +393,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const columns = geometry.columns; const target = { Home: 0, End: state.items.length - 1, ArrowRight: index + 1, ArrowLeft: index - 1, ArrowDown: index + columns, ArrowUp: index - columns }[event.key]; - if (target !== undefined) { event.preventDefault(); focusImage(Math.max(0, Math.min(state.items.length - 1, target))); } - else if (event.key === 'Tab') { + if (target !== undefined) { + event.preventDefault(); + const next = Math.max(0, Math.min(state.items.length - 1, target)); + focusImage(next); + // D135: Shift extends the selection from the anchor (this card when there is none) to the new card. + if (event.shiftKey && bar) bar.extend(card.dataset.imageKey, imageKey(state.items[next])); + } else if (bar && (event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'a') { + event.preventDefault(); + bar.selectAll(); + } else if (bar && event.key === 'Escape' && bar.hasSelection()) { + event.preventDefault(); + bar.clear(); + } else if (event.key === 'Tab') { const backward = event.shiftKey && event.target.matches('.image-open'); const forward = !event.shiftKey && event.target.closest('.image-card-actions'); const adjacent = backward ? index - 1 : index + 1; @@ -462,6 +480,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (replaced && selection !== null && !itemIndexes.has(selection)) dropSelection(); if (replaced) bar?.retain(key => itemIndexes.has(key)); state = next; + bar?.listingChanged(); if (!embedded) { setText(count, countLine(next)); setText(captionDay, captionDate(next.items, filters.sort)); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 95bc9e7..8d29e3f 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -142,6 +142,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } absolute. Columns and gap still come from the concept's `.image-grid` tracks. */ .image-grid { position: relative; /* D3 */ } .image-grid > .media-card { position: absolute; } +/* #392 D135: a long-press on a card selects it, so no image callout (iOS) takes the press. */ +#image-grid > .media-card { -webkit-touch-callout: none; } /* #249 D3: a list row's info keeps its own height so it can be measured; centred, it looks as the stretched concept row. #249 D31: it may also shrink below a long nowrap title (the h3 ellipsis then applies, as in the grid). */ .list-grid .media-info { align-self: center; min-width: 0; /* D3, D31 */ }