diff --git a/TESTING.md b/TESTING.md
index e77e01ed..9483c16c 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -861,9 +861,10 @@ Recorded deviations on this page (spec §8):
- **D7**: the star is a real persisted favorite with "Added to favorites." /
"Removed from favorites." and a rollback toast on failure.
- **D8**: every Images card has the concept's select checkbox before the star; the
- `#bulkbar` shows "N selected" and only the real actions in the concept's order (Set
- category, Export metadata, Clear). Add tags and Add to collection wait for image tags
- and collections; "Remove links…" is dropped. The selection survives Load more and
+ `#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
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
@@ -1201,6 +1202,79 @@ production-versus-concept difference of PR C and its record:
| Retry leads the actions when the controller allows it | D116 (ruling T5) |
| States the concept lacks (busy, conflict, changed storage or file, unconfirmed save, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
+### Bulk tagging from the selection bar (#249 PR D)
+
+The Images selection bar shows the concept's **Add tags** after the count, then
+**Auto-tag selected** (D85), before Set category. Both take the checked files when
+pressed, in listing order, as their reveal identity (`{root_index, root_scope, path}`);
+more than 500 toasts "Select at most 500 images." and opens nothing. The selection
+stays after every start, refusal and review.
+
+- **Add tags** opens the concept's `simpleInput` (app.js a1378–1383) as Set category
+ does: "Tag selected images", "Tags, separated by commas" (empty: the concept's
+ "cinematic, concept" is sample text), Cancel / Apply. The names are parsed as the Tag
+ image dialog's manual field parses them (trimmed, case-insensitive repeats dropped,
+ first spelling wins); none toasts "Enter at least one tag.". Apply starts the
+ `image-tags` job (`POST /prompt_manager/jobs/image-tags`, `{mode: "add", targets,
+ tags}`) and toasts "Tagging started."; Activity shows its progress.
+- A refused start toasts why and changes nothing: another record-changing job (a scan)
+ gives "Another job is running:
." with a link to it (D57's toast), a held model
+ gives the server's `model-busy` sentence, any other coded refusal its own sentence,
+ and anything else "Tagging could not start. Try again." / "Auto-tagging could not
+ start. Try again.".
+- When a job this page started ends having tagged files, those files are read again
+ once: the selected file's inspection (its chips, D88) and, under a Tags filter or a
+ search, the loaded range, as after a Tag save.
+
+- **D85** (plan D62, ruling L1, beyond the concept): **Auto-tag selected** opens
+ "Auto-tag selected images" in the Tag image dialog's shell (`tagging-modal`) with
+ its model panel, headed "Auto-tag image(s)" (the panel's "Auto-tag this image"
+ for N files), without the preview, the manual field, the suggestions or Generate.
+ Its fields show the server's defaults (`GET /prompt_manager/autotag/models`, the
+ D91 Suggestions count included) once that read lands; Start waits for it. A model
+ the status lists as not downloaded keeps Start `aria-disabled` and
+ `#tag-generation-status` says " is not downloaded. Download it from the
+ legacy Auto-tag page." (the server's own sentence), so no job starts only to fail.
+ Start validates the fields as Generate does (native validity; nothing is sent).
+ The concept comparison (`images-concept.spec.mjs`) uses the concept page itself as
+ the oracle, with Add to collection and Remove links… removed and a copy of its Add
+ tags button reading "Auto-tag selected" inserted after it.
+- **D90** (plan D67, ruling T3-bulk): the dialog's **How to apply** radio group
+ (`.field[role=radiogroup]`, radios beside their labels): **Apply automatically**
+ (checked; primary "Start auto-tagging") starts the `image-tags` job
+ (`{mode: "auto", targets, model_type, options}`) and toasts "Auto-tagging started.";
+ **Review each image** (primary "Start review") walks the selection in the browser
+ (`images-tagging-review.mjs`). Each image opens the Tag image dialog titled "Tag
+ image · Image of " on the chosen model and options with Generate already
+ running; its actions are Stop reviewing, Skip and Approve and next (Approve and
+ finish on the last). Approve saves the manual field plus the remaining chips as Save
+ does, without its per-image toast, and opens the next image; Skip writes nothing and
+ advances; Stop reviewing, Escape and × end the session. Each image is one gated
+ `suggest` call, so the session holds no model between images; a `model-busy` answer
+ stays on that image with its sentence in `#tag-generation-status` and D116's Retry
+ until the user retries or skips. Ending toasts " of images tagged." and
+ returns focus to Auto-tag selected.
+- **D91** extends to the bulk dialog: its Suggestions field is the job's and the
+ review's `suggestion_limit`.
+
+Browser tests (`tests/browser/workspace-tagging-bulk.spec.mjs`, on the tagging
+fixture): "selection bar shows Add tags then Auto-tag selected before Set category",
+"Add tags runs a job and the cards show the tags after it finishes", "Auto-tag
+selected with Apply automatically runs a job with the chosen model, thresholds and
+suggestion count", "a model that is not downloaded keeps Start disabled and says
+why", "Suggestions field prefills from the setting and caps chips at the entered
+count", "Review each image walks the selection one dialog at a time and only approved
+images gain tags" and "a running scan makes the start report busy". Units:
+`tests/js/workspace-images-tagging-bulk.test.js`,
+`tests/js/workspace-images-tagging-review.test.js`.
+
+| Difference | Record |
+| --- | --- |
+| Add tags ships (Add to collection and Remove links… still omitted) | D8 (amended) |
+| Auto-tag selected and its dialog | D85 (ruling L1) |
+| How to apply and the Review each image session | D90 (ruling T3-bulk) |
+| Suggestions count in the bulk dialog | D91 |
+
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/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs
index 862ad302..39122861 100644
--- a/tests/browser/concept-inventory.mjs
+++ b/tests/browser/concept-inventory.mjs
@@ -138,10 +138,12 @@ export async function hoverStateOf(page,locator) {
/**
* Mutate the open page so a comparison can be shown to notice a change (the A7 mutation check): `css` is appended as
* the last stylesheet, so it wins over equally specific concept rules, and every element matching a `remove` selector
- * is detached. Returns an async undo that restores the stylesheet and the removed elements in place.
+ * is detached; each `clone` ({selector, text}) inserts a copy of the first match after it, holding `text` and no
+ * `data-action` (a control production adds beside a concept one, D85). Returns an async undo that restores the
+ * stylesheet and the removed elements in place and drops the copies.
*/
-export async function mutate(page,{css=null,remove=[]}={}) {
- const token=await page.evaluate(({css,remove})=>{
+export async function mutate(page,{css=null,remove=[],clone=[]}={}) {
+ const token=await page.evaluate(({css,remove,clone})=>{
const token=`mutation-${Math.random().toString(36).slice(2)}`;
const undo=[];
if(css) {
@@ -156,9 +158,15 @@ export async function mutate(page,{css=null,remove=[]}={}) {
node.remove();undo.push(()=>parent.insertBefore(node,next));
}
}
+ for(const {selector,text} of clone) {
+ const node=document.querySelector(selector);
+ if(!node)throw new Error(`mutate: nothing matches ${selector}`);
+ const copy=node.cloneNode(true);copy.textContent=text;copy.removeAttribute('data-action');
+ node.after(copy);undo.push(()=>copy.remove());
+ }
(window.__conceptMutations??=new Map()).set(token,undo);
return token;
- },{css,remove});
+ },{css,remove,clone});
return ()=>page.evaluate(token=>{
const undo=window.__conceptMutations?.get(token);
if(!undo)throw new Error('mutate: this mutation was already undone or the page reloaded');
diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs
index e7e219c2..52e5d0bc 100644
--- a/tests/browser/images-concept.spec.mjs
+++ b/tests/browser/images-concept.spec.mjs
@@ -30,9 +30,11 @@ function captionDay(seconds,now=new Date()) {
return `${name} · ${date.toLocaleDateString('en-US',options)}`;
}
-/** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */
-const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']);
-const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
+/** D8 (amended by #249 PR D): the selection bar's real actions only; Add to collection and Remove links… are left out. */
+const OMITTED_BULK=Object.freeze(['Add to collection','Remove links…']);
+const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
+/** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */
+const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]);
/**
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
@@ -443,6 +445,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
const removed=(await inventoryOf(page,'#main')).bulkbar;
await restore();
+ // The oracle for production's bar: the concept page itself without the omitted actions and with D85's button.
+ const restoreProduction=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS],clone:AUTO_TAG_CLONE});
+ const oracle=await inventoryOf(page,'#main');
+ await restoreProduction();
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
await openProductionLoaded(page,theme,'no-preference');
const emptyActual=await inventoryOf(page,'#workspace-main');
@@ -454,12 +460,13 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category',
'Add to collection','Export metadata','Remove links…','Clear']);
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
- expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']);
+ expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
+ 'Set category','Export metadata','Clear']);
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
- expect.soft(compare(reference,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
+ expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
// The bar comparison notices a change to the production bar.
await mutate(page,{css:'.bulkbar { padding: 4px }'});
- expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
+ expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the
diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs
index bd073491..27380a99 100644
--- a/tests/browser/workspace-images.spec.mjs
+++ b/tests/browser/workspace-images.spec.mjs
@@ -1702,7 +1702,8 @@ test('card checkboxes select images into the concept bar and Clear empties it',
const selectedBefore = await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')));
await checkbox(first, 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
- await expect(barButtons(page)).toHaveText(['Set category', 'Export metadata', 'Clear']);
+ // D85: Add tags (the concept's) and Auto-tag selected lead the real actions.
+ await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
// Toggling neither opens the viewer, nor inspects, nor moves focus.
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(checkbox(first, 'same.png')).toBeFocused();
diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs
new file mode 100644
index 00000000..3cb0fb2e
--- /dev/null
+++ b/tests/browser/workspace-tagging-bulk.spec.mjs
@@ -0,0 +1,268 @@
+// The Images selection bar's Add tags and Auto-tag selected (#249 PR D, plan Task D2, Mock inventory M3; D85, D90,
+// D91) against the real `image-tags` job route and the injected fake model (tests/browser/workspace_tagging_fixture.py).
+// No model is ever downloaded or loaded for real: the fake answers, and every test checks no download request was sent.
+import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
+import { test, expect } from './fixtures.mjs';
+
+const images = '/prompt_manager/workspace#/images';
+const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', UNLINKED = 'loose/unlinked.png';
+const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`);
+const bar = page => page.locator('#workspace-main > #bulkbar > .bulkbar');
+const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true });
+const modal = page => page.locator('dialog#modal[open]');
+const title = page => modal(page).locator('#modal-title');
+const toast = page => page.locator('#workspace-toast');
+const toasts = page => page.evaluate(() => window.__toasts);
+const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion');
+const actionButtons = page => modal(page).locator('.dialog-actions button');
+const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
+const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
+const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.';
+
+test.beforeEach(async ({ page, request, browserName }) => {
+ await startCoverage(page, browserName);
+ // Every toast text in order: a fast job's finish toast may replace its start toast before an assertion reads it.
+ await page.addInitScript(() => {
+ window.__toasts = [];
+ new MutationObserver(() => {
+ const text = document.getElementById('workspace-toast')?.textContent ?? '';
+ if (text && window.__toasts.at(-1) !== text) window.__toasts.push(text);
+ }).observe(document, { subtree: true, childList: true, characterData: true });
+ });
+ await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
+ page.taggingErrors = [];
+ page.on('pageerror', error => page.taggingErrors.push(error.message));
+ page.downloads = [];
+ page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
+});
+test.afterEach(async ({ page, browserName }) => {
+ await saveCoverage(page, browserName);
+ expect(page.taggingErrors).toEqual([]);
+ expect(page.downloads).toEqual([]);
+});
+
+/** Images with `paths` checked, in that order. */
+async function select(page, ...paths) {
+ await page.goto(images);
+ for (const path of paths) await card(page, path).getByRole('checkbox').check();
+ await expect(bar(page).locator('strong')).toHaveText(`${paths.length} selected`);
+}
+async function scopeOf(request) {
+ const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
+ return listing.images[0].root_scope;
+}
+/** The saved tags of `path` through the real observe route, then that capture's release. */
+async function savedTags(request, path) {
+ const target = { root_index: 0, root_scope: await scopeOf(request), path };
+ const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
+ expect(observed.success).toBe(true);
+ await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
+ return observed.tags;
+}
+/** Wait until the session's job `id` has ended; returns its summary. */
+async function ended(request, id) {
+ let job;
+ await expect.poll(async () => {
+ const { jobs } = await (await request.get('/prompt_manager/jobs')).json();
+ job = jobs.find(item => item.id === id);
+ return job?.state;
+ }).toBe('finished');
+ return job;
+}
+const jobStart = page => page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags');
+
+test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => {
+ await select(page, FIRST);
+ await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
+});
+
+test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => {
+ await select(page, SECOND, FIRST);
+ await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
+ await barButton(page, 'Add tags').click();
+ await expect(title(page)).toHaveText('Tag selected images');
+ const field = modal(page).getByRole('textbox', { name: 'Tags, separated by commas', exact: true });
+ await expect(field).toHaveValue('');
+ await expect(actionButtons(page)).toHaveText(['Cancel', 'Apply']);
+ await field.fill('sunset, Sunset , blue_hour,,');
+ const started = jobStart(page);
+ const answer = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags');
+ await actionButtons(page).last().click();
+ const scope = await scopeOf(request);
+ // Listing order (the newer FIRST before SECOND), the reveal identity, the dialog's dedupe.
+ expect((await started).postDataJSON()).toEqual({ mode: 'add', tags: ['sunset', 'blue_hour'], targets: [
+ { root_index: 0, root_scope: scope, path: FIRST }, { root_index: 0, root_scope: scope, path: SECOND }] });
+ const { job } = await (await answer).json();
+ await expect.poll(() => toasts(page)).toContain('Tagging started.');
+ await expect(modal(page)).toHaveCount(0);
+ await expect(bar(page).locator('strong')).toHaveText('2 selected');
+ expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 });
+ // The finished job's files are read again: the selected card's panel shows its new image tags.
+ await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
+ expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']);
+ expect(await savedTags(request, UNLINKED)).toEqual([]);
+});
+
+test('Auto-tag selected with Apply automatically runs a job with the chosen model, thresholds and suggestion count', async ({ page, request }) => {
+ await select(page, FIRST, UNLINKED);
+ await barButton(page, 'Auto-tag selected').click();
+ await expect(title(page)).toHaveText('Auto-tag selected images');
+ await expect(modal(page)).toHaveClass(/(^| )tagging-modal( |$)/);
+ const body = modal(page).locator('#modal-body');
+ expect(await body.evaluate(node => [...node.children].map(child => child.className))).toEqual(['tag-model-panel', 'field', 'small', 'dialog-actions']);
+ await expect(body.locator('.tag-model-panel h3')).toHaveText('Auto-tag 2 images');
+ // No preview, manual field, suggestions or Generate: only the model panel's fields.
+ await expect(body.getByRole('button', { name: 'Generate suggestions', exact: true })).toHaveCount(0);
+ await expect(body.locator('#manual-image-tags, .tag-suggestion-panel, .tag-image-preview')).toHaveCount(0);
+ const group = body.getByRole('radiogroup', { name: 'How to apply', exact: true });
+ await expect(group.getByRole('radio', { name: 'Apply automatically', exact: true })).toBeChecked();
+ await expect(group.getByRole('radio', { name: 'Review each image', exact: true })).not.toBeChecked();
+ await expect(actionButtons(page)).toHaveText(['Cancel', 'Start auto-tagging']);
+ await body.getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-vit');
+ await body.getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.5');
+ await body.getByRole('spinbutton', { name: 'Character confidence', exact: true }).fill('0.9');
+ const limit = body.getByRole('spinbutton', { name: 'Suggestions', exact: true });
+ // An invalid count reports its validity and sends nothing.
+ await limit.fill('0');
+ let posted = 0;
+ page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags') posted += 1; });
+ await actionButtons(page).last().click();
+ expect(await limit.evaluate(node => node.validity.valid)).toBe(false);
+ await expect(title(page)).toHaveText('Auto-tag selected images');
+ expect(posted).toBe(0);
+ await limit.fill('2');
+ const started = jobStart(page);
+ const answer = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags');
+ await actionButtons(page).last().click();
+ const scope = await scopeOf(request);
+ expect((await started).postDataJSON()).toEqual({ mode: 'auto', model_type: 'wd14-vit',
+ options: { general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 2 },
+ targets: [{ root_index: 0, root_scope: scope, path: FIRST }, { root_index: 0, root_scope: scope, path: UNLINKED }] });
+ const { job } = await (await answer).json();
+ await expect.poll(() => toasts(page)).toContain('Auto-tagging started.');
+ await expect(modal(page)).toHaveCount(0);
+ await expect(bar(page).locator('strong')).toHaveText('2 selected');
+ expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 });
+ expect(await savedTags(request, FIRST)).toEqual(['sandstone', 'desert']);
+ expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']);
+ expect(await savedTags(request, SECOND)).toEqual([]);
+});
+
+test('a model that is not downloaded keeps Start disabled and says why', async ({ page, request }) => {
+ await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
+ await select(page, FIRST);
+ await barButton(page, 'Auto-tag selected').click();
+ const status = modal(page).locator('#tag-generation-status');
+ await expect(status).toHaveAttribute('role', 'status');
+ await expect(status).toHaveText(NOT_DOWNLOADED);
+ const start = actionButtons(page).last();
+ await expect(start).toHaveAttribute('aria-disabled', 'true');
+ let posted = 0;
+ page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags') posted += 1; });
+ // aria-disabled: a press does nothing.
+ await start.dispatchEvent('click');
+ await expect(title(page)).toHaveText('Auto-tag selected images');
+ await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-vit');
+ await expect(status).toHaveText('');
+ await expect(start).not.toHaveAttribute('aria-disabled', 'true');
+ await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-swinv2');
+ await expect(start).toHaveAttribute('aria-disabled', 'true');
+ await actionButtons(page).first().click();
+ await expect(modal(page)).toHaveCount(0);
+ await expect(barButton(page, 'Auto-tag selected')).toBeFocused();
+ expect(posted).toBe(0);
+});
+
+test('Suggestions field prefills from the setting and caps chips at the entered count', async ({ page, request }) => {
+ await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.5, character: 0.9, limit: 7 } })).toBeOK();
+ await select(page, FIRST);
+ await barButton(page, 'Auto-tag selected').click();
+ const limit = modal(page).getByRole('spinbutton', { name: 'Suggestions', exact: true });
+ await expect(limit).toHaveValue('7');
+ await expect(modal(page).getByRole('spinbutton', { name: 'General confidence', exact: true })).toHaveValue('0.5');
+ await expect(modal(page).getByRole('spinbutton', { name: 'Character confidence', exact: true })).toHaveValue('0.9');
+ await modal(page).getByRole('radio', { name: 'Review each image', exact: true }).check();
+ await expect(actionButtons(page)).toHaveText(['Cancel', 'Start review']);
+ await limit.fill('3');
+ const suggest = page.waitForRequest(item => item.url().endsWith('/image-annotations/suggest'));
+ await actionButtons(page).last().click();
+ await expect(title(page)).toHaveText('Tag image · Image 1 of 1');
+ expect((await suggest).postDataJSON().options).toEqual({ general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 3 });
+ await expect(chips(page)).toHaveCount(3);
+ await expect(modal(page).getByRole('spinbutton', { name: 'Suggestions', exact: true })).toHaveValue('3');
+ await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and finish']);
+ await page.keyboard.press('Escape');
+ await expect(modal(page)).toHaveCount(0);
+ await expect(toast(page)).toHaveText('0 of 1 images tagged.');
+ expect(await savedTags(request, FIRST)).toEqual([]);
+});
+
+test('Review each image walks the selection one dialog at a time and only approved images gain tags', async ({ page, request }) => {
+ await select(page, FIRST, SECOND, UNLINKED);
+ // The second image's first Generate finds the model busy (a legacy batch): the dialog waits for Retry or Skip.
+ let suggests = 0;
+ await page.route('**/prompt_manager/image-annotations/suggest', async route => {
+ suggests += 1;
+ if (suggests === 2) {
+ await route.fulfill({ status: 409, json: { success: false, code: 'model-busy', error: 'The tagging model is busy. Try again when it finishes.' } });
+ return;
+ }
+ await route.continue();
+ });
+ const puts = [];
+ page.on('request', item => { if (item.method() === 'PUT' && item.url().endsWith('/image-annotations/tags')) puts.push(item.postDataJSON()); });
+ await barButton(page, 'Auto-tag selected').click();
+ await modal(page).getByRole('radio', { name: 'Review each image', exact: true }).check();
+ await modal(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('2');
+ await actionButtons(page).last().click();
+ await expect(title(page)).toHaveText('Tag image · Image 1 of 3');
+ await expect(modal(page).getByRole('combobox', { name: 'Tagging model', exact: true })).toHaveValue('wd14-swinv2');
+ await expect(chips(page)).toHaveCount(2);
+ await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and next']);
+ await expect(actionButtons(page).last()).toHaveClass(/ca-primary/);
+ // Approve saves the dialog's manual field plus its remaining chips.
+ await modal(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true }).fill('mine');
+ await modal(page).getByRole('button', { name: 'Remove desert', exact: true }).click();
+ await actionButtons(page).last().click();
+ await expect(title(page)).toHaveText('Tag image · Image 2 of 3');
+ await expect(modal(page).locator('#tag-generation-status')).toHaveText('The tagging model is busy. Try again when it finishes.');
+ await expect(actionButtons(page)).toHaveText(['Retry', 'Stop reviewing', 'Skip', 'Approve and next']);
+ await actionButtons(page).nth(2).click();
+ await expect(title(page)).toHaveText('Tag image · Image 3 of 3');
+ await expect(chips(page)).toHaveCount(2);
+ await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and finish']);
+ await actionButtons(page).last().click();
+ await expect(modal(page)).toHaveCount(0);
+ await expect(toast(page)).toHaveText('2 of 3 images tagged.');
+ await expect(barButton(page, 'Auto-tag selected')).toBeFocused();
+ await expect(bar(page).locator('strong')).toHaveText('3 selected');
+ expect(puts.map(body => [body.target.path, body.tags])).toEqual([[FIRST, ['mine', 'sandstone']], [UNLINKED, ['sandstone', 'desert']]]);
+ expect(await savedTags(request, FIRST)).toEqual(['mine', 'sandstone']);
+ expect(await savedTags(request, SECOND)).toEqual([]);
+ expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']);
+ // The reviewed file's panel shows its saved tags without reselecting.
+ await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
+ await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0);
+});
+
+test('a running scan makes the start report busy', async ({ page, request }) => {
+ const scan = await (await request.post('/__test__/jobs/start', { data: { kind: 'scan', title: 'Scan for metadata' } })).json();
+ try {
+ await select(page, FIRST, SECOND);
+ await barButton(page, 'Add tags').click();
+ await modal(page).getByRole('textbox', { name: 'Tags, separated by commas', exact: true }).fill('sunset');
+ await actionButtons(page).last().click();
+ await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.');
+ await expect(toast(page).getByRole('link', { name: 'Scan for metadata', exact: true })).toHaveAttribute('href', `#/activity/${scan.job.id}`);
+ await expect(bar(page).locator('strong')).toHaveText('2 selected');
+ // Auto-tag is refused the same way.
+ await barButton(page, 'Auto-tag selected').click();
+ await expect(actionButtons(page).last()).not.toHaveAttribute('aria-disabled', 'true');
+ await actionButtons(page).last().click();
+ await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.');
+ await expect(bar(page).locator('strong')).toHaveText('2 selected');
+ expect(await savedTags(request, FIRST)).toEqual([]);
+ } finally {
+ await request.post(`/__test__/jobs/${scan.job.id}/finish`, { data: { state: 'finished' } });
+ }
+});
diff --git a/tests/js/workspace-images-tagging-bulk.test.js b/tests/js/workspace-images-tagging-bulk.test.js
new file mode 100644
index 00000000..55abf664
--- /dev/null
+++ b/tests/js/workspace-images-tagging-bulk.test.js
@@ -0,0 +1,180 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+// Guarded: before the module exists every test fails on an assertion, not on the import.
+const loaded = await import('../../web/js/workspace/images-tagging-bulk.mjs').catch(() => null);
+const module = () => { assert.ok(loaded, 'images-tagging-bulk.mjs is not implemented'); return loaded; };
+const state = await import('../../web/js/workspace/image-tagging-state.mjs');
+const { startToast, parseJob } = await import('../../web/js/workspace/activity-store.mjs');
+const SCOPE = 'b'.repeat(64);
+const JOB_ID = 'c'.repeat(32);
+const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra });
+const pageScope = () => {
+ const controller = new AbortController();
+ const cleanups = [];
+ return { signal: controller.signal, cleanup: fn => cleanups.push(fn), abort: () => controller.abort(), cleanups };
+};
+
+test('targets keep listing order and identity', () => {
+ const { bulkTargets } = module();
+ const listing = [image('b/2.png', { width: 9 }), image('a/1.png'), { ...image('x.png'), root_scope: 'nope' }, image('c/3.webm')];
+ assert.deepEqual(bulkTargets(listing), [
+ { root_index: 0, root_scope: SCOPE, path: 'b/2.png' },
+ { root_index: 0, root_scope: SCOPE, path: 'a/1.png' },
+ { root_index: 0, root_scope: SCOPE, path: 'c/3.webm' },
+ ]);
+ assert.deepEqual(bulkTargets([]), []);
+});
+
+test('over 500 selected refuses with the toast', () => {
+ const { bulkTargets, openAddTags, openAutoTag, MAX_BULK_IMAGES, TOO_MANY } = module();
+ assert.equal(MAX_BULK_IMAGES, 500);
+ assert.equal(TOO_MANY, 'Select at most 500 images.');
+ const many = Array.from({ length: 501 }, (_, index) => image(`bulk/${index}.png`));
+ assert.equal(bulkTargets(many), null);
+ assert.equal(bulkTargets(many.slice(0, 500)).length, 500);
+ const toasts = [];
+ const store = { start: () => assert.fail('no job starts') };
+ // Refused before any dialog opens (no DOM here).
+ openAddTags({ scope: pageScope(), store, images: many, notify: text => toasts.push(text) });
+ openAutoTag({ api: {}, scope: pageScope(), store, images: many, notify: text => toasts.push(text) });
+ assert.deepEqual(toasts, [TOO_MANY, TOO_MANY]);
+});
+
+test('add parses comma tags with the same dedupe as the dialog', () => {
+ const { parseBulkTags, ADD_DIALOG } = module();
+ assert.deepEqual(ADD_DIALOG, { title: 'Tag selected images', label: 'Tags, separated by commas', action: 'Apply' });
+ assert.deepEqual(parseBulkTags(' Sunset, sunset ,, blue_hour,SUNSET, Blue_Hour '), ['Sunset', 'blue_hour']);
+ assert.deepEqual(parseBulkTags(' , ,'), []);
+ assert.equal(parseBulkTags, state.parseManualTags);
+});
+
+test('busy start keeps the selection and names the running job', async () => {
+ const { startTagJob } = module();
+ const toasts = [];
+ const posted = [];
+ const running = { id: JOB_ID, title: 'Scan for metadata' };
+ const store = { start: async (kind, body) => { posted.push([kind, body]); return { busy: running, message: 'busy' }; } };
+ const body = { mode: 'add', targets: [{ root_index: 0, root_scope: SCOPE, path: 'a.png' }], tags: ['x'] };
+ assert.equal(await startTagJob({ store, scope: pageScope(), body, notify: text => toasts.push(text) }), null);
+ assert.deepEqual(posted, [['image-tags', body]]);
+ assert.deepEqual(toasts, [['Another job is running: ', { text: 'Scan for metadata', href: `#/activity/${JOB_ID}` }, '.']]);
+});
+
+test('a started job is returned without a toast of its own; refusals toast their sentence', async () => {
+ const { startTagJob } = module();
+ const toasts = [];
+ const notify = text => toasts.push(text);
+ const job = { id: JOB_ID, kind: 'image-tags' };
+ assert.equal(await startTagJob({ store: { start: async () => ({ job }) }, scope: pageScope(), body: { mode: 'add' }, notify }), job);
+ const refused = Object.assign(new Error('The tagging model is busy. Try again when it finishes.'), { name: 'ApiError', status: 409, code: 'model-busy' });
+ assert.equal(await startTagJob({ store: { start: async () => { throw refused; } }, scope: pageScope(), body: { mode: 'auto' }, notify }), null);
+ assert.equal(await startTagJob({ store: { start: async () => { throw new TypeError('down'); } }, scope: pageScope(), body: { mode: 'auto' }, notify }), null);
+ assert.equal(await startTagJob({ store: { start: async () => { throw new TypeError('down'); } }, scope: pageScope(), body: { mode: 'add' }, notify }), null);
+ assert.deepEqual(toasts, ['The tagging model is busy. Try again when it finishes.', 'Auto-tagging could not start. Try again.',
+ 'Tagging could not start. Try again.']);
+ // A page left meanwhile says nothing.
+ const left = pageScope();
+ left.abort();
+ assert.equal(await startTagJob({ store: { start: async () => { throw refused; } }, scope: left, body: { mode: 'auto' }, notify }), null);
+ assert.equal(toasts.length, 3);
+});
+
+test('the start toast names the tagging mode', () => {
+ const job = mode => parseJob({ id: JOB_ID, state: 'running', kind: 'image-tags', title: 'Add tags to images', scope: { mode, images: 2 } });
+ assert.equal(startToast(job('add')), 'Tagging started.');
+ assert.equal(startToast(job('auto')), 'Auto-tagging started.');
+ assert.equal(startToast(job('other')), 'Add tags to images started.');
+ assert.equal(startToast(parseJob({ id: JOB_ID, state: 'running', kind: 'scan', title: 'Scan for metadata' })), 'Scan started.');
+});
+
+test('a followed job reports its end once', () => {
+ const { followJobEnd } = module();
+ let jobs = [{ id: JOB_ID, state: 'running', counters: {} }];
+ const listeners = new Set();
+ const store = { find: id => jobs.find(job => job.id === id), subscribe: fn => { listeners.add(fn); return () => listeners.delete(fn); } };
+ const ended = [];
+ const scope = pageScope();
+ followJobEnd({ store, scope, id: JOB_ID, onEnd: job => ended.push(job.state) });
+ assert.equal(listeners.size, 1);
+ for (const fn of [...listeners]) fn(store);
+ assert.deepEqual(ended, []);
+ jobs = [{ id: JOB_ID, state: 'finished', counters: { tagged: 1 } }];
+ for (const fn of [...listeners]) fn(store);
+ for (const fn of [...listeners]) fn(store);
+ assert.deepEqual(ended, ['finished']);
+ assert.equal(listeners.size, 0);
+ assert.equal(scope.cleanups.length, 1);
+});
+
+test('a model that is not downloaded is named with the server sentence', () => {
+ const { modelUnavailable } = state;
+ assert.equal(modelUnavailable(null, 'wd14-vit'), '');
+ assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: true } }, 'wd14-vit'), '');
+ assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: false } }, 'wd14-vit'), 'WD14 ViT is not downloaded. Download it from the legacy Auto-tag page.');
+ assert.equal(modelUnavailable({ gguf: { downloaded: false, name: 'GGUF (Recommended)' } }, 'gguf'),
+ 'GGUF (Recommended) is not downloaded. Download it from the legacy Auto-tag page.');
+ assert.throws(() => modelUnavailable({}, 'nope'), TypeError);
+});
+
+test('the bulk options start from the server defaults and wait for them before Start', async () => {
+ const { createTaggingOptionsController } = state;
+ let answer;
+ const api = { get: () => new Promise(resolve => { answer = resolve; }) };
+ const options = createTaggingOptionsController({ api });
+ const seen = [];
+ options.subscribe(value => seen.push(value.optionsRevision));
+ assert.equal(options.state.canStart, false);
+ assert.equal(options.state.canGenerate, false);
+ const opening = options.open();
+ answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, default_prompt: 'Say it', suggestion_limit: 7,
+ models: { 'wd14-swinv2': { downloaded: true }, 'wd14-vit': { downloaded: false }, gguf: { downloaded: true } } });
+ await opening;
+ assert.equal(options.state.canStart, true);
+ assert.deepEqual(options.request(), { model_type: 'wd14-swinv2', options: { general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 7 } });
+ options.setOption('suggestion_limit', '3');
+ options.setModel('wd14-vit');
+ assert.equal(options.state.canStart, false);
+ assert.match(options.state.unavailable, /^WD14 ViT is not downloaded\./);
+ options.setModel('gguf');
+ assert.deepEqual(options.request(), { model_type: 'gguf', options: { instructions: 'Say it', suggestion_limit: 3 } });
+ options.setOption('instructions', ' ');
+ assert.equal(options.request(), null);
+ assert.deepEqual(seen, [1, 1, 2, 3, 3]);
+ assert.throws(() => options.setModel('nope'), TypeError);
+ assert.throws(() => options.setOption('nope', 1), TypeError);
+ options.dispose();
+ options.setOption('suggestion_limit', '4');
+ assert.equal(options.state.options.suggestion_limit, '3');
+});
+
+test('the bulk options keep the documented defaults when the read fails', async () => {
+ const { createTaggingOptionsController } = state;
+ const options = createTaggingOptionsController({ api: { get: async () => { throw new Error('offline'); } } });
+ await options.open();
+ await options.open();
+ assert.equal(options.state.canStart, true);
+ assert.equal(options.state.unavailable, '');
+ assert.deepEqual(options.request(), { model_type: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85, suggestion_limit: 10 } });
+ assert.throws(() => createTaggingOptionsController({}), TypeError);
+});
+
+test('a preset starts the Tag image dialog on the chosen model and options', async () => {
+ const { createImageTaggingController } = state;
+ const target = { root_index: 0, root_scope: SCOPE, path: 'a.png' };
+ const api = {
+ get: async () => ({ wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, default_prompt: 'Describe', suggestion_limit: 10 }),
+ post: async () => ({ success: true, library_epoch: '12345678-1234-1234-1234-123456789abc', filename: 'a.png', title: null, tags: [], tag_revision: 0,
+ capture: { token: 'tok', sha256: 'd'.repeat(64), preview_url: '/prompt_manager/image-annotations/capture/tok', width: 4, height: 4,
+ media_type: 'image', generate: { supported: true, reason: null } } }),
+ put: async () => ({}), del: async () => ({}),
+ };
+ const controller = createImageTaggingController({ api, target, source: 'bulk', preset: { model: 'gguf', options: { instructions: 'Name it', suggestion_limit: 3 } } });
+ assert.equal(controller.state.model, 'gguf');
+ await controller.open();
+ assert.deepEqual([controller.state.model, controller.state.options.instructions, controller.state.options.suggestion_limit], ['gguf', 'Name it', 3]);
+ controller.dispose();
+ assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'nope', options: {} } }), TypeError);
+ assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'gguf', options: { instructions: '' } } }), TypeError);
+ assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'gguf', options: { nope: 1 } } }), TypeError);
+});
diff --git a/tests/js/workspace-images-tagging-review.test.js b/tests/js/workspace-images-tagging-review.test.js
new file mode 100644
index 00000000..123ba635
--- /dev/null
+++ b/tests/js/workspace-images-tagging-review.test.js
@@ -0,0 +1,110 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+// Guarded: before the module exists every test fails on an assertion, not on the import.
+const loaded = await import('../../web/js/workspace/images-tagging-review.mjs').catch(() => null);
+const module = () => { assert.ok(loaded, 'images-tagging-review.mjs is not implemented'); return loaded; };
+const SCOPE = 'e'.repeat(64);
+const images = ['a.png', 'b.png', 'c.png'].map(path => ({ root_index: 0, root_scope: SCOPE, path, filename: path }));
+const OPTIONS = { general_threshold: 0.4, character_threshold: 0.8, suggestion_limit: 3 };
+
+/** A session over fake dialogs: `dialogs` holds every open call's arguments and whether it was closed. */
+function session(extra = {}) {
+ const dialogs = [];
+ const toasts = [];
+ let closes = 0;
+ const saved = [];
+ const handle = module().startReviewSession({
+ api: {}, scope: {}, images, model_type: 'wd14-vit', options: OPTIONS, trigger: 'auto-tag',
+ onSaved: event => saved.push(event.filename),
+ open: args => {
+ const dialog = { args, closed: 0 };
+ dialogs.push(dialog);
+ return { close: () => { dialog.closed += 1; args.onStop(); } };
+ },
+ close: () => { closes += 1; },
+ notify: text => toasts.push(text),
+ ...extra,
+ });
+ return { handle, dialogs, toasts, saved, closes: () => closes };
+}
+const approve = (dialog, tags = ['x']) => dialog.args.onSaved({ filename: dialog.args.image.filename, tags, target: {} });
+
+test('the first image opens at once with the session model, options and its position', () => {
+ const { dialogs } = session();
+ assert.equal(dialogs.length, 1);
+ const { args } = dialogs[0];
+ assert.equal(args.image, images[0]);
+ assert.deepEqual(args.position, { index: 1, total: 3 });
+ assert.deepEqual(args.preset, { model: 'wd14-vit', options: OPTIONS });
+ assert.equal(args.autoGenerate, true);
+ assert.equal(args.actions, 'review');
+ assert.equal(args.source, 'bulk');
+ assert.equal(args.trigger, 'auto-tag');
+ assert.throws(() => module().startReviewSession({ images: [] }), TypeError);
+});
+
+test('advances only on approve or skip and writes nothing on skip or stop', () => {
+ const { dialogs, toasts, saved } = session();
+ // Nothing from the dialog (a Generate in progress, a model-busy answer): the session waits on that image.
+ assert.equal(dialogs.length, 1);
+ dialogs[0].args.onSkip();
+ assert.equal(dialogs.length, 2);
+ assert.deepEqual(dialogs[1].args.position, { index: 2, total: 3 });
+ // A second report from a dialog already left changes nothing.
+ dialogs[0].args.onSkip();
+ dialogs[0].args.onStop();
+ assert.equal(dialogs.length, 2);
+ approve(dialogs[1]);
+ assert.equal(dialogs.length, 3);
+ dialogs[2].args.onStop();
+ assert.deepEqual(saved, ['b.png']);
+ assert.deepEqual(toasts, ['1 of 3 images tagged.']);
+ assert.equal(dialogs.length, 3);
+});
+
+test('approve saves exactly the dialog\'s manual-plus-chips set', () => {
+ // The session never writes itself: each approve is the dialog's own Save (its event carries the saved set).
+ const forwarded = [];
+ const { dialogs } = session({ onSaved: event => forwarded.push(event.tags) });
+ approve(dialogs[0], ['manual', 'chip']);
+ assert.deepEqual(forwarded, [['manual', 'chip']]);
+ assert.deepEqual(dialogs[1].args.preset, { model: 'wd14-vit', options: OPTIONS });
+});
+
+test('last image offers Approve and finish', () => {
+ const { dialogs, toasts, closes } = session();
+ approve(dialogs[0]);
+ dialogs[1].args.onSkip();
+ assert.deepEqual(dialogs[2].args.position, { index: 3, total: 3 });
+ approve(dialogs[2]);
+ // The last approve ends the session: the dialog closes and the count is toasted once.
+ assert.equal(closes(), 1);
+ assert.deepEqual(toasts, ['2 of 3 images tagged.']);
+ dialogs[2].args.onStop();
+ assert.deepEqual(toasts, ['2 of 3 images tagged.']);
+});
+
+test('model-busy on one image waits for retry or skip', () => {
+ const { dialogs, toasts } = session();
+ // The dialog shows the refusal with Retry; a Retry that succeeds then approves, all on the same image.
+ assert.equal(dialogs.length, 1);
+ approve(dialogs[0]);
+ assert.equal(dialogs.length, 2);
+ assert.deepEqual(toasts, []);
+});
+
+test('stop releases listeners and in-flight requests', () => {
+ const { handle, dialogs, toasts, saved } = session();
+ handle.stop();
+ assert.equal(dialogs[0].closed, 1);
+ assert.deepEqual(toasts, ['0 of 3 images tagged.']);
+ // A save that settles after the stop still counts as written, but nothing opens or toasts again.
+ approve(dialogs[0]);
+ dialogs[0].args.onSkip();
+ handle.stop();
+ assert.equal(dialogs.length, 1);
+ assert.deepEqual(saved, ['a.png']);
+ assert.deepEqual(toasts, ['0 of 3 images tagged.']);
+ assert.equal(module().reviewOutcome(1, 1), '1 of 1 images tagged.');
+});
diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json
index d80511f7..90e80707 100644
--- a/tools/vendor/frontend-coverage-baseline.json
+++ b/tools/vendor/frontend-coverage-baseline.json
@@ -229,7 +229,7 @@
},
"workspace/image-tagging.mjs": {
"lines": 97,
- "branches": 88,
+ "branches": 90,
"functions": 87,
"statements": 97
},
@@ -253,7 +253,7 @@
},
"workspace/images-selection.mjs": {
"lines": 98,
- "branches": 92,
+ "branches": 93,
"functions": 98,
"statements": 98
},
@@ -263,6 +263,18 @@
"functions": 98,
"statements": 98
},
+ "workspace/images-tagging-bulk.mjs": {
+ "lines": 98,
+ "branches": 91,
+ "functions": 85,
+ "statements": 98
+ },
+ "workspace/images-tagging-review.mjs": {
+ "lines": 98,
+ "branches": 93,
+ "functions": 98,
+ "statements": 98
+ },
"workspace/images-tagging.mjs": {
"lines": 98,
"branches": 98,
diff --git a/web/js/workspace/activity-store.mjs b/web/js/workspace/activity-store.mjs
index 2e1f9762..0c3420c4 100644
--- a/web/js/workspace/activity-store.mjs
+++ b/web/js/workspace/activity-store.mjs
@@ -82,8 +82,12 @@ export function finishToast(job) {
return `${job.title} finished.`;
}
+// D85: an image tagging job says which of its two modes started.
+const IMAGE_TAG_START_TOASTS = Object.freeze({ add: 'Tagging started.', auto: 'Auto-tagging started.' });
+
/** The toast after this tab starts a job. */
export function startToast(job) {
+ if (job.kind === 'image-tags' && IMAGE_TAG_START_TOASTS[job.scope.mode]) return IMAGE_TAG_START_TOASTS[job.scope.mode];
return START_TOASTS[job.kind] ?? `${job.title} started.`;
}
diff --git a/web/js/workspace/image-tagging-state.mjs b/web/js/workspace/image-tagging-state.mjs
index e37b6b6b..e85b9597 100644
--- a/web/js/workspace/image-tagging-state.mjs
+++ b/web/js/workspace/image-tagging-state.mjs
@@ -128,7 +128,8 @@ export function optionError(model, options) {
return null;
}
-function requestOptions(model, options) {
+/** The options a request sends for `model`: numbers parsed, only the model's own keys. */
+export function requestOptions(model, options) {
const body = {suggestion_limit: numberOf(options.suggestion_limit)};
if (modelOf(model).classifier) {
return {general_threshold: numberOf(options.general_threshold), character_threshold: numberOf(options.character_threshold), ...body};
@@ -177,28 +178,48 @@ function validateReceipt(value) {
return value;
}
-/** Thresholds, instructions and suggestion count from the server, else the documented defaults. */
+/**
+ * Thresholds, instructions and suggestion count from the server, else the documented defaults, and the models'
+ * download status (`models`, null when unknown).
+ */
async function readDefaults(api, signal) {
try {
const value = await api.get(MODELS_URL, {signal, cache: 'no-store'});
- if (!object(value)) return DEFAULTS;
+ if (!object(value)) return {defaults: DEFAULTS, models: null};
return {
- general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold,
- character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold,
- instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions,
- suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit,
+ defaults: {
+ general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold,
+ character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold,
+ instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions,
+ suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit,
+ },
+ models: object(value.models) ? value.models : null,
};
} catch {
// The defaults keep the dialog usable; Generate reports a real failure itself.
- return DEFAULTS;
+ return {defaults: DEFAULTS, models: null};
}
}
-function initialState(source) {
+const UNAVAILABLE_HINT = 'Download it from the legacy Auto-tag page.';
+/**
+ * Why `model` cannot run, from the `/autotag/models` status (the server's own `model-unavailable` sentence), else ''
+ * (downloaded, or the status is unknown: the job then reports a missing model itself).
+ */
+export function modelUnavailable(models, model) {
+ const option = modelOf(model);
+ requireValue(option, 'Unknown tagging model');
+ const status = object(models) && object(models[model]) ? models[model] : null;
+ if (!status || status.downloaded !== false) return '';
+ const name = typeof status.name === 'string' && status.name.trim() ? status.name : option.label.split(' · ')[0];
+ return `${name} is not downloaded. ${UNAVAILABLE_HINT}`;
+}
+
+function initialState(source, preset) {
return {
phase: 'observing', closed: false, source, showScopeNote: source === 'review',
image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '',
- model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, generated: false, suggestions: [],
+ model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [],
suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null,
// Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
optionsRevision: 0, draftRevision: 0,
@@ -219,13 +240,31 @@ function derive(state) {
};
}
-export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}} = {}) {
+/** A `{model, options}` preset (the bulk review's choice) when valid, else a TypeError. */
+function validatePreset(preset) {
+ if (preset === null || preset === undefined) return null;
+ requireValue(object(preset) && modelOf(preset.model) && object(preset.options), 'Invalid tagging preset');
+ const options = {};
+ for (const [key, value] of Object.entries(preset.options)) {
+ requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
+ options[key] = value;
+ }
+ requireValue(!optionError(preset.model, {...DEFAULTS, ...options}), 'Invalid tagging preset');
+ return Object.freeze({model: preset.model, options: Object.freeze(options)});
+}
+
+/**
+ * `preset` ({model, options}, D90's review session) starts the dialog on that model, and every observation applies
+ * those options over the server's defaults.
+ */
+export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}, preset = null} = {}) {
requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required');
const imageTarget = validateTarget(target);
requireValue(SOURCES.has(source), 'Unknown tagging source');
requireValue(typeof onLateSave === 'function', 'onLateSave must be a function');
+ const chosen = validatePreset(preset);
- let state = freeze(derive(initialState(source)));
+ let state = freeze(derive(initialState(source, chosen)));
const listeners = new Set();
let closed = false;
let capture = null;
@@ -297,7 +336,7 @@ export function createImageTaggingController({api, target, source = 'images', on
}
capture = {token: value.capture.token, sha256: value.capture.sha256};
epoch = value.library_epoch;
- defaults = await defaultsRead;
+ ({defaults} = await defaultsRead);
if (closed || serial !== openSerial) return;
observedOnce = true;
const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture;
@@ -311,7 +350,7 @@ export function createImageTaggingController({api, target, source = 'images', on
savedTags: value.tags.slice(), tagRevision: value.tag_revision,
manual: draft === null ? value.tags.join(', ') : changed ? mergeCurrent(value.tags, draft) : draft,
draftRevision: state.draftRevision + 1,
- options: {...defaults}, optionsRevision: state.optionsRevision + 1,
+ options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1,
generateSupported: generate.supported, generateReason: generate.reason,
statusText: changed ? `${COPY.changedElsewhere} ${savedNow(value.tags)}${reason ? ` ${reason}` : ''}` : reason,
});
@@ -482,3 +521,66 @@ export function createImageTaggingController({api, target, source = 'images', on
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose,
};
}
+
+/**
+ * The model and options of the bulk "Auto-tag selected images" dialog (D85): the same fields as the Tag image dialog's
+ * model panel, without an image. open() reads the server's defaults and the models' download status once; until it
+ * lands Start waits (`canStart` false). `unavailable` is why the chosen model cannot run ('' when it can), so the
+ * dialog refuses to start a job that would fail at once.
+ */
+export function createTaggingOptionsController({api} = {}) {
+ requireValue(object(api) && typeof api.get === 'function', 'An api client is required');
+ const listeners = new Set();
+ let closed = false;
+ let defaults = DEFAULTS;
+ let models = null;
+ let abort = null;
+ const deriveOptions = value => ({...value, canGenerate: false, unavailable: modelUnavailable(models, value.model),
+ canStart: value.phase === 'ready' && !modelUnavailable(models, value.model)});
+ let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0}));
+
+ function set(patch) {
+ if (closed) return;
+ state = freeze(deriveOptions({...state, ...patch}));
+ for (const listener of [...listeners]) listener(state);
+ }
+ async function open() {
+ if (closed || abort) return;
+ abort = new AbortController();
+ const read = await readDefaults(api, abort.signal);
+ if (closed) return;
+ ({defaults, models} = read);
+ set({phase: 'ready', options: {...defaults}, optionsRevision: state.optionsRevision + 1});
+ }
+ function setModel(id) {
+ requireValue(modelOf(id), 'Unknown tagging model');
+ if (closed || id === state.model) return;
+ // As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91).
+ set({model: id, optionsRevision: state.optionsRevision + 1,
+ options: {...state.options, general_threshold: defaults.general_threshold,
+ character_threshold: defaults.character_threshold, instructions: defaults.instructions}});
+ }
+ function setOption(key, value) {
+ requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
+ set({options: {...state.options, [key]: value}});
+ }
+ /** `{model_type, options}` for a job or a review session, or null while the options are invalid. */
+ function request() {
+ if (optionError(state.model, state.options)) return null;
+ return {model_type: state.model, options: requestOptions(state.model, state.options)};
+ }
+ function dispose() {
+ closed = true;
+ listeners.clear();
+ abort?.abort();
+ }
+ function subscribe(listener) {
+ requireValue(typeof listener === 'function', 'A listener function is required');
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ }
+ return {
+ get state() {return state;},
+ subscribe, open, setModel, setOption, request, dispose,
+ };
+}
diff --git a/web/js/workspace/image-tagging.mjs b/web/js/workspace/image-tagging.mjs
index 8993c270..534d139b 100644
--- a/web/js/workspace/image-tagging.mjs
+++ b/web/js/workspace/image-tagging.mjs
@@ -101,10 +101,12 @@ function numberInput(value, {min, max, step}) {
/**
* The concept's `.tag-model-panel` (a1049): `heading`, the Tagging model select, the model's options and Generate.
- * It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog). Returns the `element` and
- * `requestGenerate()`, Generate's own path (field validity first), for a Retry after a failed Generate.
+ * It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog, D85, without Generate:
+ * `generate: false`). Returns the `element`, `validate()` (the fields' native validity, then what the controller would
+ * refuse; true when valid, else the first invalid field reports) and `requestGenerate()`, Generate's own path (field
+ * validity first), for a Retry after a failed Generate.
*/
-export function renderModelPanel({scope, controller, heading = 'Auto-tag this image'}) {
+export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true}) {
const panel = el('div', undefined, 'tag-model-panel');
const select = el('select');
for (const option of MODEL_OPTIONS) {
@@ -116,7 +118,8 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
options.id = 'image-tag-model-options';
const generate = createButton('', 'ca-primary');
generate.append(createWorkspaceIcon('tagImage'), ' Generate suggestions');
- panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options, generate);
+ panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options);
+ if (withGenerate) panel.append(generate);
const inputs = new Map();
let shownModel = null, shownOptions = null;
@@ -161,26 +164,28 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
setDisabled(generate, !state.canGenerate);
}
select.addEventListener('change', () => controller.setModel(select.value));
- function requestGenerate() {
+ function validate() {
const {state} = controller;
- if (!state.canGenerate) return;
// The concept's native validity first (a1104–1114), then what the controller would refuse (a blank field).
for (const input of inputs.values()) input.setCustomValidity('');
const invalid = [...inputs.values()].find(input => !input.checkValidity());
- if (invalid) { invalid.reportValidity(); return; }
+ if (invalid) { invalid.reportValidity(); return false; }
const error = optionError(state.model, state.options);
if (error) {
const input = inputs.get(error.field);
input.setCustomValidity(error.message);
input.reportValidity();
- return;
+ return false;
}
- controller.generate();
+ return true;
+ }
+ function requestGenerate() {
+ if (controller.state.canGenerate && validate()) controller.generate();
}
generate.addEventListener('click', () => { if (!disabled(generate)) requestGenerate(); });
scope.cleanup(controller.subscribe(update));
update(controller.state);
- return {element: panel, requestGenerate};
+ return {element: panel, requestGenerate, validate};
}
function previewOf(image, state) {
@@ -192,20 +197,33 @@ function previewOf(image, state) {
/**
* Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
- * `tags`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does.
+ * `tags`, `bulk`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does.
+ *
+ * D90 (the bulk review session, `images-tagging-review.mjs`): `actions: 'review'` with `position: {index, total}`
+ * titles the dialog "Tag image · Image of " and makes its actions Stop reviewing, Skip and Approve and
+ * next (Approve and finish on the last image). `preset` ({model, options}) applies the session's model and options,
+ * and `autoGenerate` runs Generate as soon as the file is observed. Approve saves as Save does, without the per-image
+ * toast (the session toasts its count), and calls `onSaved`; Skip writes nothing and calls `onSkip`; Stop reviewing,
+ * Escape and × write nothing and call `onStop` once. Swapping the next image's dialog in is not a stop.
*/
-export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null}) {
+export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null, preset = null,
+ autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null}) {
const target = imageTarget(image);
if (!target) throw new TypeError('The image has no tagging target');
+ const review = mode === 'review';
+ if (review && !(Number.isSafeInteger(position?.index) && Number.isSafeInteger(position?.total) &&
+ position.index >= 1 && position.index <= position.total)) throw new TypeError('A review needs its position');
+ // Set once this dialog hands over to the session (Approve, Skip): its close is then not a Stop.
+ let advancing = false;
const saved = ({filename: name, tags, toast}) => {
const event = {target, filename: name, tags, source};
announceSaved(event);
+ if (toast) showWorkspaceToast(toast);
onSaved?.(event);
- showWorkspaceToast(toast);
};
// A save that settles after the dialog closed is still reported: its toast, and the pages' refresh (I5).
const onLateSave = outcome => (outcome.saved ? saved(outcome) : showWorkspaceToast(outcome.message));
- const controller = createImageTaggingController({api, target, source, onLateSave});
+ const controller = createImageTaggingController({api, target, source, onLateSave, preset});
const local = createScope();
let open = true;
let completed = false;
@@ -253,9 +271,13 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
// 7. The actions; Retry leads them only while the controller allows it.
const actions = el('div', undefined, 'dialog-actions');
const retry = createButton('Retry');
- const cancel = createButton('Cancel');
+ const cancel = createButton(review ? 'Stop reviewing' : 'Cancel'); // D90
+ const skip = createButton('Skip');
const primary = createButton('Save manual tags', 'ca-primary');
- actions.append(cancel, primary);
+ const last = review && position.index === position.total;
+ const approveLabel = last ? 'Approve and finish' : 'Approve and next';
+ if (review) actions.append(cancel, skip, primary);
+ else actions.append(cancel, primary);
const nodes = [heading];
if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
@@ -301,6 +323,12 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
}
function complete(state) {
completed = true;
+ if (review) {
+ // D90: the session swaps the next image in (or ends), so this dialog is not closed here.
+ advancing = true;
+ saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: null});
+ return;
+ }
closeWorkspaceDialog();
saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast});
}
@@ -315,8 +343,9 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
setText(count, state.countText);
setText(status, state.statusText);
- setText(primary, state.primaryLabel);
+ setText(primary, review ? approveLabel : state.primaryLabel);
setDisabled(primary, !state.canSave);
+ if (review) setDisabled(skip, state.phase === 'saving');
if (state.canRetry !== retry.isConnected) {
if (state.canRetry) actions.prepend(retry); // D116
else {
@@ -328,6 +357,11 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
}
cancel.addEventListener('click', () => closeWorkspaceDialog());
+ skip.addEventListener('click', () => {
+ if (!open || advancing || disabled(skip)) return;
+ advancing = true;
+ onSkip?.();
+ });
// Retry after a failed Generate takes Generate's own path, so an invalid field reports its validity (M4).
retry.addEventListener('click', () => (controller.state.phase === 'generate-error' ? requestGenerate() : controller.retry()));
primary.addEventListener('click', () => {
@@ -345,12 +379,16 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
document.getElementById('modal')?.classList.remove('tagging-modal');
local.dispose();
controller.dispose();
+ if (review && !advancing) onStop?.();
}
- openContentDialog({scope, title: 'Tag image', nodes, trigger, onClose: release});
+ const dialogTitle = review ? `Tag image · Image ${position.index} of ${position.total}` : 'Tag image'; // D90
+ openContentDialog({scope, title: dialogTitle, nodes, trigger, onClose: release});
document.getElementById('modal')?.classList.add('tagging-modal');
local.cleanup(controller.subscribe(render));
render(controller.state);
- controller.open();
+ controller.open().then(() => {
+ if (open && autoGenerate && controller.state.canGenerate) controller.generate();
+ });
return {
close() { if (open) closeWorkspaceDialog(); },
get controller() { return controller; },
diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs
index 6938e529..c30894af 100644
--- a/web/js/workspace/images-selection.mjs
+++ b/web/js/workspace/images-selection.mjs
@@ -4,6 +4,7 @@ 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 { showWorkspaceToast } from './toast.mjs';
const CHECKBOX = '.image-overlays input[type="checkbox"]';
@@ -26,8 +27,11 @@ function el(tag, text, className) {
* - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the
* selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action.
* - `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.
*/
-export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) {
+export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
+ checked: initial = Object.freeze(new Set()) }) {
let checked = initial;
let exporting = false, categorizing = false;
const element = el('div');
@@ -40,7 +44,15 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
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, setCategory, exportMetadata, clearButton);
+ bulk.append(count);
+ if (activity) {
+ const addTags = createButton('Add tags');
+ scope.listen(addTags, 'click', () => openAddTags({ scope, store: activity, images: checkedImages(), trigger: addTags, onTagged }));
+ const autoTag = createButton('Auto-tag selected'); // D85
+ scope.listen(autoTag, 'click', () => openAutoTag({ api, scope, store: activity, images: checkedImages(), trigger: autoTag, onTagged }));
+ 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() {
diff --git a/web/js/workspace/images-tagging-bulk.mjs b/web/js/workspace/images-tagging-bulk.mjs
new file mode 100644
index 00000000..6e04fdc3
--- /dev/null
+++ b/web/js/workspace/images-tagging-bulk.mjs
@@ -0,0 +1,187 @@
+/**
+ * The Images selection bar's tagging actions (#249 PR D, Mock inventory M3):
+ *
+ * - **Add tags** (the concept's `bulk-tags` simpleInput, app.js a1378–1383): "Tag selected images", "Tags, separated
+ * by commas", Apply. The names are parsed as the Tag image dialog's manual field parses them, then the `image-tags`
+ * Activity job adds them to every selected image.
+ * - **Auto-tag selected** (D85, ruling L1, beyond the concept): "Auto-tag selected images" with the Tag image dialog's
+ * model panel (its fields show the server's defaults, D91's Suggestions included) and no Generate, then **How to
+ * 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.
+ *
+ * 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.
+ */
+import { createButton, createScope } from '../shared/toolkit/core.js';
+import { createTaggingOptionsController, parseManualTags } from './image-tagging-state.mjs';
+import { imageTarget, renderModelPanel } from './image-tagging.mjs';
+import { startReviewSession } from './images-tagging-review.mjs';
+import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './workspace-dialogs.mjs';
+import { busyToast } from './activity-scan.mjs';
+import { imageKey } from './images-state.mjs';
+import { countOf } from './plural.mjs';
+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.';
+export const NO_TAGS = 'Enter at least one tag.';
+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';
+export const APPLY_CHOICES = Object.freeze([
+ Object.freeze({value: 'auto', label: 'Apply automatically', action: 'Start auto-tagging'}),
+ Object.freeze({value: 'review', label: 'Review each image', action: 'Start review'}),
+]);
+const START_FAILED = Object.freeze({add: 'Tagging could not start. Try again.', auto: 'Auto-tagging could not start. Try again.'});
+const FINAL = new Set(['finished', 'cancelled', 'failed']);
+
+function el(tag, text, className) {
+ const node = document.createElement(tag);
+ if (text !== undefined) node.textContent = text;
+ if (className) node.className = className;
+ return node;
+}
+
+/**
+ * The job targets of the checked `images` in listing order (their reveal identity), or null when more than 500 are
+ * checked. A file without a tagging identity is left out.
+ */
+export function bulkTargets(images) {
+ if (images.length > MAX_BULK_IMAGES) return null;
+ return images.map(imageTarget).filter(Boolean);
+}
+
+/** The Add tags field's names, deduplicated as the Tag image dialog's manual field is. */
+export const parseBulkTags = parseManualTags;
+
+// Duck-typed: this ES module cannot import the UMD api-client.js.
+const apiMessage = error => (error?.name === 'ApiError' && error.code && error.message ? error.message : null);
+
+/**
+ * Start one `image-tags` job with `body` through `store.start`. Returns the job, or null after toasting why not: the
+ * busy answer naming the running job, a coded refusal's own sentence (`model-busy`, a bad body), else a fixed one.
+ */
+export async function startTagJob({store, scope, body, notify = showWorkspaceToast}) {
+ try {
+ const result = await store.start(IMAGE_TAG_JOB, body);
+ if (scope.signal.aborted) return null;
+ if (result.busy !== undefined) {
+ notify(busyToast(result.busy));
+ return null;
+ }
+ return result.job;
+ } catch (error) {
+ if (!scope.signal.aborted) notify(apiMessage(error) ?? START_FAILED[body.mode]);
+ return null;
+ }
+}
+
+/** Call `onEnd(job)` once the job `id` has ended in `store`; returns the unsubscribe. */
+export function followJobEnd({store, scope, id, onEnd}) {
+ let done = false;
+ const check = () => {
+ const job = store.find(id);
+ if (done || !job || !FINAL.has(job.state)) return;
+ done = true;
+ stop();
+ onEnd(job);
+ };
+ const stop = store.subscribe(check);
+ scope.cleanup(stop);
+ check();
+ return stop;
+}
+
+/**
+ * Wire one started job: when it ends having tagged files, `onTagged(keys)` gets the image keys it was started for.
+ */
+function followTagged({store, scope, job, images, onTagged}) {
+ const keys = new Set(images.map(imageKey));
+ followJobEnd({store, scope, id: job.id, onEnd: ended => { if ((ended.counters.tagged ?? 0) > 0) onTagged(keys); }});
+}
+
+/** Add tags: the concept's simpleInput, then the job over `images` (the checked files, taken now). */
+export function openAddTags({scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast}) {
+ const targets = bulkTargets(images);
+ if (!targets) { notify(TOO_MANY); return; }
+ openInputDialog({scope, trigger, ...ADD_DIALOG, onApply: async text => {
+ const tags = parseBulkTags(text);
+ if (!tags.length) { notify(NO_TAGS); return; }
+ const job = await startTagJob({store, scope, body: {mode: 'add', targets, tags}, notify});
+ if (job) followTagged({store, scope, job, images, onTagged});
+ }});
+}
+
+/** The D90 How to apply radio group: Apply automatically (checked) or Review each image. */
+function applyChoice(onChange) {
+ const group = el('div', undefined, 'field');
+ group.setAttribute('role', 'radiogroup');
+ const caption = el('span', 'How to apply');
+ caption.id = 'auto-tag-apply-label';
+ group.setAttribute('aria-labelledby', caption.id);
+ group.append(caption);
+ for (const choice of APPLY_CHOICES) {
+ const label = el('label');
+ const input = el('input');
+ Object.assign(input, {type: 'radio', name: 'auto-tag-apply', value: choice.value, checked: choice.value === 'auto'});
+ input.addEventListener('change', () => { if (input.checked) onChange(choice); });
+ label.append(input, ` ${choice.label}`);
+ group.append(label);
+ }
+ return group;
+}
+
+/**
+ * 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; }
+ 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});
+ let choice = APPLY_CHOICES[0];
+ const actions = el('div', undefined, 'dialog-actions');
+ const cancel = createButton('Cancel');
+ const start = createButton(choice.action, 'ca-primary');
+ actions.append(cancel, start);
+ // A model that is not downloaded: the reason, as the Tag image dialog's status line words it.
+ 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
+ const render = state => {
+ if (status.textContent !== state.unavailable) status.textContent = state.unavailable;
+ if (state.canStart) start.removeAttribute('aria-disabled');
+ else start.setAttribute('aria-disabled', 'true');
+ };
+ local.cleanup(options.subscribe(render));
+ render(options.state);
+ cancel.addEventListener('click', () => closeWorkspaceDialog());
+ start.addEventListener('click', () => {
+ if (start.getAttribute('aria-disabled') === 'true' || !options.state.canStart || !validate()) return;
+ const request = options.request();
+ if (!request) return;
+ if (choice.value === 'review') {
+ // The first image's dialog replaces this one in the shell (D90).
+ startReviewSession({api, scope, images: listed, ...request, trigger});
+ return;
+ }
+ closeWorkspaceDialog();
+ startTagJob({store, scope, body: {mode: 'auto', targets, ...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: () => {
+ document.getElementById('modal')?.classList.remove('tagging-modal');
+ local.dispose();
+ options.dispose();
+ }});
+ document.getElementById('modal')?.classList.add('tagging-modal');
+ options.open();
+}
diff --git a/web/js/workspace/images-tagging-review.mjs b/web/js/workspace/images-tagging-review.mjs
new file mode 100644
index 00000000..f00aabea
--- /dev/null
+++ b/web/js/workspace/images-tagging-review.mjs
@@ -0,0 +1,75 @@
+/**
+ * D90 (ruling T3-bulk): "Review each image", the bulk Auto-tag session in the browser. The selected images are walked
+ * one Tag image dialog at a time (image-tagging.mjs `actions: 'review'`), each opened with the session's model and
+ * options and Generate already running. Approve saves that image as the dialog's Save does and advances; Skip
+ * advances without a write; Stop reviewing, Escape and × end the session. Each image is one gated `suggest` call, so
+ * the session holds no model between images; a refused one (`model-busy`) stays on its image with the dialog's own
+ * status and Retry until the user retries or skips. Ending toasts " of images tagged."; the selection stays.
+ */
+import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
+import { openImageTagging } from './image-tagging.mjs';
+import { showWorkspaceToast } from './toast.mjs';
+
+/** The closing toast: approved saves of the session's images (the plan's copy verbatim). */
+export function reviewOutcome(tagged, total) {
+ return `${tagged} of ${total} images tagged.`;
+}
+
+/**
+ * Walk `images` (listing items with a tagging target, in selection order) with `model_type` and `options`.
+ * `onSaved(event)` follows every approved save. Returns `{stop()}`, which ends the session (its open dialog closes and
+ * writes nothing more). `open`, `close` and `notify` are the dialog seams (the defaults in the page).
+ */
+export function startReviewSession({api, scope, images, model_type: model, options, trigger = null, onSaved = null,
+ open = openImageTagging, close = closeWorkspaceDialog, notify = showWorkspaceToast}) {
+ if (!Array.isArray(images) || !images.length) throw new TypeError('A review needs its images');
+ const total = images.length;
+ let index = 0, tagged = 0, ended = false, handle = null;
+
+ function end() {
+ if (ended) return;
+ ended = true;
+ handle = null;
+ notify(reviewOutcome(tagged, total));
+ }
+ function advance() {
+ if (ended) return;
+ index += 1;
+ if (index >= total) {
+ // The last dialog handed over (advancing), so closing it is not a Stop.
+ end();
+ close();
+ return;
+ }
+ show();
+ }
+ function show() {
+ let current = true;
+ const settled = () => {
+ const was = current;
+ current = false;
+ return was;
+ };
+ handle = open({api, scope, image: images[index], source: 'bulk', trigger, preset: {model, options},
+ autoGenerate: true, actions: 'review', position: {index: index + 1, total},
+ onSaved: event => {
+ // A save that settles after its dialog was left (Skip or Stop while it ran) still counts.
+ tagged += 1;
+ onSaved?.(event);
+ if (settled()) advance();
+ },
+ onSkip: () => { if (settled()) advance(); },
+ onStop: () => { if (settled()) end(); },
+ });
+ }
+
+ show();
+ return {
+ stop() {
+ if (ended) return;
+ const open = handle;
+ end();
+ open?.close();
+ },
+ };
+}
diff --git a/web/js/workspace/images-tagging.mjs b/web/js/workspace/images-tagging.mjs
index babfd781..dc3f0f91 100644
--- a/web/js/workspace/images-tagging.mjs
+++ b/web/js/workspace/images-tagging.mjs
@@ -23,10 +23,13 @@ export function openCardTagging({api, scope, image, action, embedded}) {
/**
* Follow confirmed tag saves until `scope` ends. `filtered()` tells whether a Tags filter or a search is active,
* `selection()` is the selected image key, and `onRefreshing(busy)` marks Load more busy while the re-read runs.
+ * Returns `refresh(keys)`, the same re-read for the files a bulk tagging job tagged (once per job, D85).
*/
export function followImageTagSaves({scope, stream, inspection, filtered, selection, onRefreshing}) {
- scope.cleanup(onImageTagsSaved(async ({target}) => {
- const key = `${target.root_index}:${target.path}`;
+ scope.cleanup(onImageTagsSaved(({target}) => refresh(new Set([`${target.root_index}:${target.path}`]))));
+ return refresh;
+
+ async function refresh(keys) {
if (filtered()) {
onRefreshing(true);
try {
@@ -37,6 +40,6 @@ export function followImageTagSaves({scope, stream, inspection, filtered, select
if (!scope.signal.aborted) onRefreshing(false);
}
}
- if (!scope.signal.aborted && selection() === key) inspection.refresh();
- }));
+ if (!scope.signal.aborted && keys.has(selection())) inspection.refresh();
+ }
}
diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs
index 19045c0a..1ac497a6 100644
--- a/web/js/workspace/images.mjs
+++ b/web/js/workspace/images.mjs
@@ -254,7 +254,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// 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(); }});
+ checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys)});
if (bar) main.append(bar.element);
main.append(body);
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
@@ -529,7 +529,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scheduleLoad();
}
const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined });
- followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q),
+ const refreshTagged = followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q),
onRefreshing:busy => more.setAttribute('aria-disabled', String(busy || Boolean(state?.loading) || recovering))});
scope.cleanup(() => {
stream.dispose();
diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css
index 7394f31d..2e87e8b4 100644
--- a/web/js/workspace/workspace.css
+++ b/web/js/workspace/workspace.css
@@ -376,3 +376,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
/* D83: the chip's × keeps the concept's 40px coarse target. */
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
}
+
+/* Auto-tag selected images (#249 PR D, D85/D90): the Tag image dialog's model panel, then How to apply, a radio group
+ in the concept's `.field` row whose radios keep their own size (the concept's `.field input` is a full-width text
+ field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */
+.field[role="radiogroup"] > label { display: flex; align-items: center; gap: 8px; /* D90 */ }
+.field[role="radiogroup"] input[type="radio"] { width: auto; margin: 0; /* D90 */ }