feat(images): select many images: ranges, keys, touch and all N matching (#392) (#393)

The workspace Images page selects like a file manager in Grid, List and
Masonry, across every loaded batch: Shift+click ranges from the anchor,
Ctrl/Cmd+click toggles a card without opening it, Ctrl/Cmd+A selects the
loaded images, Shift+arrows extend, Escape clears, and a touch long-press
starts a selection mode in which taps toggle. A plain click still opens or
inspects.

Once every loaded image is checked and more match, the bar offers
"Select all N matching". Add tags, Auto-tag selected and Set category then
send the listing filter instead of N identities: the image-tags job takes
`filter` in place of `targets` and /bulk/category takes `images_filter`,
both resolved by the listing's own admission code (up to 10,000 images, a
Source bound by its root_scope). Export metadata waits for loaded images.

Recorded as deviation D135; the concept is unchanged.
This commit is contained in:
Vito
2026-10-27 22:20:27 +00:00
committed by GitHub
parent 37e4e08327
commit 2f8b7cb306
17 changed files with 1371 additions and 93 deletions
+61 -1
View File
@@ -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/<token>`
+75 -10
View File
@@ -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:
+194 -45
View File
@@ -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:
+62
View File
@@ -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
+245
View File
@@ -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);
});
@@ -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);
});
+8
View File
@@ -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);
});
@@ -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]);
});
+159
View File
@@ -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")
+106
View File
@@ -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)
+6
View File
@@ -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,
+80
View File
@@ -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;
},
};
}
+173 -21
View File
@@ -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,
};
+10
View File
@@ -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' ||
+30 -11
View File
@@ -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();
+24 -5
View File
@@ -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));
+2
View File
@@ -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 */ }