feat(annotations): Add tags and Auto-tag selected in the selection bar

The Images selection bar gains the concept's Add tags and, beside it,
Auto-tag selected (D85). Add tags starts the image-tags Activity job with
the dialog's comma-separated names; Auto-tag selected reuses the Tag image
model panel (server defaults, Suggestions count) without Generate, refuses
a model that is not downloaded, and either starts the job (Apply
automatically) or walks the selection one Tag image dialog at a time
(Review each image, D90). Busy, model-busy and refused starts toast why;
the selection stays; a finished job's files are read again.
This commit is contained in:
Vito Sansevero
2026-10-22 16:29:20 -07:00
parent 62298bcd0c
commit f035e8fc27
17 changed files with 1144 additions and 57 deletions
+77 -3
View File
@@ -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: <title>." 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 <N> 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 "<model> 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 <i> of <M>" 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 "<n> of <M> 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/<token>`
+12 -4
View File
@@ -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');
+13 -6
View File
@@ -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
+2 -1
View File
@@ -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();
@@ -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' } });
}
});
@@ -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);
});
@@ -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.');
});
+14 -2
View File
@@ -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,
+4
View File
@@ -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.`;
}
+116 -14
View File
@@ -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,
};
}
+57 -19
View File
@@ -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 <index> of <total>" 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; },
+14 -2
View File
@@ -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() {
+187
View File
@@ -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();
}
@@ -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 "<n> of <M> 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();
},
};
}
+7 -4
View File
@@ -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();
}
}
+2 -2
View File
@@ -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();
+6
View File
@@ -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 */ }