diff --git a/TESTING.md b/TESTING.md index 76cff525..37b998f6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1256,6 +1256,50 @@ stays after every start, refusal and review. 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`. +- **D117** (copy the concept has no line for; the concept's `bulk-apply` only toasts a + demo sentence): an Add tags field that parses to no names toasts "Enter at least one + tag."; a start refused for no coded reason toasts "Tagging could not start. Try + again." or "Auto-tagging could not start. Try again." (as D55's "The scan could not + start. Try again."); and the bulk dialog's model panel is headed "Auto-tag + image(s)" where the Tag image dialog's reads "Auto-tag this image". Everything else + the selection bar's tagging shows is the concept's or the plan's (D85, D90, D91). +- **D89** (plan D66, Activity for the `image-tags` job): the job's Activity item and its + view are the scan's (D54, D56, D57, D59) with the job kind's own counters, from one + per-kind table (`JOB_VIEWS` in `activity-store.mjs`; a kind without one keeps the + scan's tiles under "Job log" and "Cancel job"). The titles are the server's: "Add + tags to images" and "Auto-tag images". + - The pill's verb is "Tagging" ("Tagging · 41% · 8,240 / 19,622"). + - The item line and the view's heading line read "Started HH:MM · N images · closing + this view does not cancel it" (the job's image count, grouped "en-US"), "Finished + HH:MM · N tagged · M errors", "Cancelled HH:MM at a file boundary · N tagged · + completed work is kept" or "Stopped on an error at HH:MM · N tagged · M errors". + - The view's actions are Download report and, while running, "Cancel tagging" + (`.danger`); the progress bar is " progress". + - Four `dl.stat-row` tiles in the mockup's tones: Tagged (`.added`, green), + Unchanged, Skipped (`.warn`, warning), Errors (`.bad`, red). They keep the + mockup's five-column row (two columns on a phone), so a tile is the scan's width + and the fifth cell stays empty. + - The outcome list reads Tagged, Errors, Skipped, Unchanged (what got tagged and what + went wrong first, as the scan's), and the view opens on the first with files. + - Rows under Tagged and Unchanged are D57's `button.file-row`s that open the file in + the viewer by its listing identity; Skipped and Errors rows are plain text. + - The log is "● Tagging log — raw detail, click to show". + - The row and log wording is the job's (D1), since neither the concept nor the + mockup has a line for it: "Added N tag(s)", "No new tags", and the skip reasons + "Video: manual tags only", "Too large to verify", "File changed", "File not found" + and "Not a supported still image". Log prefixes are `+` tagged, `=` unchanged, `-` + skipped and `!` error. + - Download report saves `prompt-manager-image-tags-<YYYYMMDD-HHMM>.json`. + - The toasts stay the generic ones: "Tagging started." / "Auto-tagging started." + (D85) and "<title> finished.", "<title> was cancelled." or "<title> failed.". + - Tests: `tests/js/workspace-activity-store.test.js` ("image-tags jobs get their own + tiles outcomes and pill verb", "unknown kinds keep the generic view"); + `tests/browser/workspace-activity.spec.mjs` ("an image tagging job shows Tagged + Unchanged Skipped Errors and opens tagged rows", "cancel tagging stops at a file + boundary", "the report download names the kind", on fake `image-tags` jobs from + `workspace_activity_fixture.py`); `tests/browser/activity-concept.spec.mjs` ("image + tagging job view follows the approved mockup", dark and light, with a mutation + check). The scan's view is unchanged. 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", @@ -1274,6 +1318,8 @@ images gain tags" and "a running scan makes the start report busy". Units: | 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 | +| Copy without a concept line: no tags, start fallbacks, the panel heading | D117 | +| Activity item and job view for image tagging jobs | D89 (plan D66) | Native image tagging (#249 PR B routes, PR C UI): `POST /prompt_manager/image-annotations/observe` (capture one listed file, its saved set and diff --git a/tests/browser/activity-concept.spec.mjs b/tests/browser/activity-concept.spec.mjs index 2f4ef4dd..9dd4e646 100644 --- a/tests/browser/activity-concept.spec.mjs +++ b/tests/browser/activity-concept.spec.mjs @@ -249,14 +249,16 @@ const EXPECTED_JOB_VIEW = { pressed: { border: true, background: true, added: true, bad: true, warn: true }, }; -async function openRunningJob(page, request, theme, viewport = { width: 1600, height: 1100 }) { +const SCAN_START = { start: { total: 19622 }, step: { processed: 8240, + counters: { known: 6511, review: 37, no_metadata: 254, errors: 18 }, + records: [{ group: 'added', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added to the Library as “Mabes portrait”' }] } }; + +async function openRunningJob(page, request, theme, viewport = { width: 1600, height: 1100 }, data = SCAN_START) { await page.setViewportSize(viewport); - const started = await request.post('/__test__/jobs/start', { data: { total: 19622 } }); + const started = await request.post('/__test__/jobs/start', { data: data.start }); await expect(started).toBeOK(); const { job } = await started.json(); - await expect(await request.post(`/__test__/jobs/${job.id}/step`, { data: { processed: 8240, - counters: { known: 6511, review: 37, no_metadata: 254, errors: 18 }, - records: [{ group: 'added', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added to the Library as “Mabes portrait”' }] } })).toBeOK(); + await expect(await request.post(`/__test__/jobs/${job.id}/step`, { data: data.step })).toBeOK(); await openProduction(page, `activity/${job.id}`, theme); await expect(page.locator('#workspace-main .file-row')).toHaveCount(1); return job; @@ -278,6 +280,30 @@ for (const theme of THEMES) test(`job view follows the approved mockup ${theme}` expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW); }); +// D89 (plan D66): an image tagging job's view is the same mockup with its own four tiles (Tagged green, Unchanged, +// Skipped warning, Errors red) in the mockup's five-column row, its own outcome list and "Tagging log". +const TAG_START = { start: { kind: 'image-tags', title: 'Auto-tag images', mode: 'auto', images: 4, total: 4 }, step: { processed: 2, + counters: { unchanged: 1, errors: 1 }, records: [{ group: 'tagged', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added 3 tags' }] } }; +const EXPECTED_TAG_VIEW = { + ...EXPECTED_JOB_VIEW, + progress: [MOCKUP_PROGRESS[0], MOCKUP_PROGRESS[1], 'dl.stat-row(div.added.stat,div.stat,div.stat.warn,div.bad.stat)'], + summary: '● Tagging log — raw detail, click to show', +}; + +for (const theme of THEMES) test(`image tagging job view follows the approved mockup ${theme}`, async ({ page, request }) => { + await seedConcept(request); + await openRunningJob(page, request, theme, undefined, TAG_START); + expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_TAG_VIEW); + await expect(page.locator('#workspace-main .outcome-list > button')).toHaveText(['Tagged1', 'Errors1', 'Skipped0', 'Unchanged1']); + // Mutation check: the scan's five tiles and the scan's log label are both noticed. + expect(diffInventory(EXPECTED_JOB_VIEW, await page.evaluate(measureJobView)).map(item => item.path).sort()) + .toEqual(['progress.2', 'summary']); + const undoCss = await mutate(page, { css: '#workspace-main .stat-row .stat.warn dd { color: red; }' }); + expect(diffInventory(EXPECTED_TAG_VIEW, await page.evaluate(measureJobView)).map(item => item.path)).toEqual(['pressed.warn']); + await undoCss(); + expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_TAG_VIEW); +}); + test('job view fits a phone without page scroll', async ({ page, request }) => { await seedConcept(request); await openRunningJob(page, request, 'dark', { width: 390, height: 844 }); diff --git a/tests/browser/workspace-activity.spec.mjs b/tests/browser/workspace-activity.spec.mjs index 05cb2d75..b45b3c7f 100644 --- a/tests/browser/workspace-activity.spec.mjs +++ b/tests/browser/workspace-activity.spec.mjs @@ -799,3 +799,114 @@ test('a failed report download says so and leaves the button usable', async ({ p await expect(toast(page)).toHaveText('The report could not be downloaded. Try again.'); await expect(button).not.toHaveAttribute('aria-disabled', 'true'); }); + +// ── #249 PR D Task D3 (D89, plan D66): image tagging jobs in Activity ───────────────────────── + +const TAG_JOB = { kind: 'image-tags', title: 'Auto-tag images', mode: 'auto', images: 4, total: 4 }; + +test('an image tagging job shows Tagged Unchanged Skipped Errors and opens tagged rows', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const job = await startJob(request, TAG_JOB); + await step(request, job.id, { processed: 3, records: [ + { group: 'tagged', path: 'shared/same.png', root: 'output', detail: 'Added 2 tags' }, + { group: 'unchanged', path: 'shared/same.png', root: 'input', detail: 'No new tags' }, + { group: 'skipped', path: 'video/clip_0004.webm', root: 'output', detail: 'Video: manual tags only' }, + ] }); + await page.goto(`${workspace}#/images`); + // The pill names the kind's verb. + await expect(pill(page)).toHaveText('Tagging · 75% · 3 / 4'); + await openJob(page, job.id); + await expect(heading(page).locator('h1')).toHaveText('Auto-tag images'); + await expect(page).toHaveTitle('Auto-tag images · Prompt Manager'); + // The header's scope is the number of images, and the job cancels as "Cancel tagging" (danger). + await expect(heading(page).locator('p.muted')).toHaveText(/^Started \d\d:\d\d · 4 images · closing this view does not cancel it$/); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel tagging']); + await expect(heading(page).getByRole('button', { name: 'Cancel tagging', exact: true })).toHaveClass(/danger/); + await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 3 of 4'); + await expect(progressSection(page).locator('progress')).toHaveAttribute('aria-label', 'Auto-tag images progress'); + // Four tiles in the scan's tones: Tagged green, Unchanged plain, Skipped warning, Errors red. + expect(await tileValues(page)).toEqual([ + ['stat added', 'Tagged', '1'], ['stat', 'Unchanged', '1'], ['stat warn', 'Skipped', '1'], ['stat bad', 'Errors', '0'], + ]); + // What got tagged and what went wrong first, as the scan's list does. + await expect(outcomeButtons(page)).toHaveText(['Tagged1', 'Errors0', 'Skipped1', 'Unchanged1']); + await expect(outcomeButtons(page).first()).toHaveAttribute('aria-pressed', 'true'); + + // Tagged rows open the viewer on that file (D57's identity). + const tagged = fileRows(page).first(); + expect(await tagged.evaluate(node => node.localName)).toBe('button'); + await expect(tagged.locator('.path')).toHaveText('shared/same.png'); + await expect(tagged.locator('.why')).toHaveText('Added 2 tags'); + await expect(resultsNote(page)).toHaveText('Each row opens the image in the viewer.'); + await tagged.click(); + const viewer = page.locator('dialog#viewer-dialog[open]'); + await expect(viewer.locator('#viewer-stage img').first()).toHaveAttribute('src', /[?&]root=0(&|$)/); + await page.keyboard.press('Escape'); + await expect(viewer).toHaveCount(0); + await expect(tagged).toBeFocused(); + // Unchanged rows open too, here the input root's file. + await outcomeButtons(page).filter({ hasText: 'Unchanged' }).click(); + await fileRows(page).first().click(); + await expect(page.locator('dialog#viewer-dialog[open] #viewer-stage img').first()).toHaveAttribute('src', /[?&]root=1(&|$)/); + await page.keyboard.press('Escape'); + // Skipped rows are plain text with their reason. + await outcomeButtons(page).filter({ hasText: 'Skipped' }).click(); + expect(await fileRows(page).first().evaluate(node => node.localName)).toBe('div'); + await expect(fileRows(page).first().locator('.why')).toHaveText('Video: manual tags only'); + await expect(resultsNote(page)).toHaveCount(0); + // The log is the job's own: "Tagging log", with the runner's prefixes. + await expect(scanLog(page).locator('summary')).toHaveText('● Tagging log — raw detail, click to show'); + await scanLog(page).locator('summary').click(); + await expect(scanLog(page).locator('pre')).toContainText('+ shared/same.png · Added 2 tags'); + + // An error, then the end: the header and the Activity item read "N tagged · M errors". + await step(request, job.id, { processed: 4, records: [ + { group: 'errors', path: 'broken/truncated.png', root: 'output', detail: 'The file could not be tagged.' }, + ] }); + await expect(tiles(page).last().locator('dd')).toHaveText('1'); + await finish(request, job.id); + await expect(toast(page)).toHaveText('Auto-tag images finished.'); + await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 1 tagged · 1 error$/); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']); + await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('4 of 4 files processed'); + await page.goto(`${workspace}#/activity`); + await expect(items(page).first().locator('h3')).toHaveText('✓ Auto-tag images'); + await expect(items(page).first().locator('p')).toHaveText(/^Finished \d\d:\d\d · 1 tagged · 1 error$/); +}); + +test('cancel tagging stops at a file boundary', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const job = await startJob(request, { ...TAG_JOB, title: 'Add tags to images', mode: 'add', images: 1250, total: 1250 }); + await step(request, job.id, { processed: 2, counters: { tagged: 2 } }); + await page.goto(`${workspace}#/activity`); + await expect(items(page).first().locator('p')).toHaveText(/^Started \d\d:\d\d · 1,250 images · closing this view does not cancel it$/); + await openJob(page, job.id); + await heading(page).getByRole('button', { name: 'Cancel tagging', exact: true }).focus(); + await page.keyboard.press('Enter'); + await expect(toast(page)).toHaveText('Add tags to images was cancelled.'); + await expect(heading(page).locator('h1')).toBeFocused(); + await expect(heading(page).locator('p.muted')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 2 tagged · completed work is kept$/); + await expect(heading(page).getByRole('button', { name: 'Cancel tagging', exact: true })).toHaveCount(0); + await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Cancelled after 2 of 1,250 files'); + expect((await tileValues(page)).map(([, , value]) => value)).toEqual(['2', '0', '0', '0']); + expect((await listed(request))[0].state).toBe('cancelled'); +}); + +test('the report download names the kind', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const job = await startJob(request, TAG_JOB); + await step(request, job.id, { processed: 1, records: [ + { group: 'tagged', path: 'sub/a.png', root: 'output', detail: 'Added 1 tag' }, + ] }); + await finish(request, job.id); + await openJob(page, job.id); + const button = heading(page).getByRole('button', { name: 'Download report', exact: true }); + const [download] = await Promise.all([page.waitForEvent('download'), button.click()]); + expect(download.suggestedFilename()).toMatch(/^prompt-manager-image-tags-\d{8}-\d{4}\.json$/); + const report = JSON.parse(await (await import('node:fs/promises')).readFile(await download.path(), 'utf8')); + expect(report.job.kind).toBe('image-tags'); + expect(report.job.scope).toEqual({ mode: 'auto', images: 4 }); + expect(Object.keys(report.outcomes)).toEqual(['tagged', 'unchanged', 'skipped', 'errors']); + expect(report.outcomes.tagged.rows.map(row => [row.path, row.detail])).toEqual([['sub/a.png', 'Added 1 tag']]); + await expect(toast(page)).toHaveText('Job report downloaded.'); +}); diff --git a/tests/browser/workspace_activity_fixture.py b/tests/browser/workspace_activity_fixture.py index 77f102ad..c2a3d4d5 100644 --- a/tests/browser/workspace_activity_fixture.py +++ b/tests/browser/workspace_activity_fixture.py @@ -4,7 +4,9 @@ A fake job is a real registry job whose runner waits for commands instead of reading files, so a test sets its progress and counters exactly: - ``POST /__test__/jobs/start`` starts one (``kind``, ``title``, ``total``, - ``output_folders``) and returns its summary, or 409 while one runs; + ``output_folders``; for ``kind: "image-tags"`` the image tagging job's + groups and its ``{mode, images}`` scope instead) and returns its summary, + or 409 while one runs; - ``POST /__test__/jobs/{id}/step`` records ``records`` (``{group, path, root, root_index, detail}`` file outcomes, counted, listed and logged as the scan records them) and ``bulk`` (``{group, count, root}``: ``count`` numbered @@ -28,6 +30,8 @@ POLL_SECONDS = 0.02 PREFIXES = {"added": "+", "known": "=", "review": "?", "errors": "!"} PREFIXES["no_metadata"] = "-" +# The image tagging job's groups (#249 PR D), logged with the runner's prefixes. +PREFIXES.update({"tagged": "+", "unchanged": "=", "skipped": "-"}) ROOT_INDEXES = {"output": 0, "input": 1} @@ -72,7 +76,13 @@ def outcome_records(data): def register_activity_commands(app, routes, api): - from py.api.jobs import SCAN_GROUP_NAMES, JobBusy, JobRegistry + from py.api.jobs import ( + IMAGE_TAG_GROUP_NAMES, + IMAGE_TAG_JOB_KIND, + SCAN_GROUP_NAMES, + JobBusy, + JobRegistry, + ) streams = set() endings = {} @@ -123,17 +133,29 @@ def register_activity_commands(app, routes, api): async def start_job(request): data = await body(request) ending = asyncio.get_running_loop().create_future() + kind = str(data.get("kind", "scan")) + if kind == IMAGE_TAG_JOB_KIND: + title = "Add tags to images" + scope = { + "mode": str(data.get("mode", "add")), + "images": count(data.get("images"), 0), + } + groups = IMAGE_TAG_GROUP_NAMES + else: + title = "Scan for metadata" + scope = { + "files": str(data.get("files", "new")), + "output_folders": count(data.get("output_folders"), 2), + } + groups = SCAN_GROUP_NAMES try: job = api._jobs.start( - str(data.get("kind", "scan")), - str(data.get("title", "Scan for metadata")), + kind, + str(data.get("title", title)), record_changing=True, - scope={ - "files": str(data.get("files", "new")), - "output_folders": count(data.get("output_folders"), 2), - }, + scope=scope, runner=fake_runner(ending), - groups=SCAN_GROUP_NAMES, + groups=groups, ) except JobBusy: raise web.HTTPConflict() diff --git a/tests/js/workspace-activity-store.test.js b/tests/js/workspace-activity-store.test.js index 89eb72d6..eea5846e 100644 --- a/tests/js/workspace-activity-store.test.js +++ b/tests/js/workspace-activity-store.test.js @@ -283,6 +283,41 @@ test('summaries are validated and the copy follows the spec', async () => { assert.deepEqual([-1, 0, 1, 4, 5, 6, 1e9, 0.5].map(backoffDelay), [1000, 1000, 2000, 16000, 30000, 30000, 30000, 1000]); }); +// D89 (plan D66): an image tagging job's view has its own tiles, outcomes, openable rows, log and cancel labels. +test('image-tags jobs get their own tiles outcomes and pill verb', async () => { + const { parseJob, jobView, pillParts, outcomeLine, startToast } = await source; + assert.equal(typeof jobView, 'function'); + const job = parseJob(summary({ kind: 'image-tags', title: 'Auto-tag images', progress: 40, processed: 2, total: 5, + counters: { tagged: 1420, unchanged: 3, skipped: 2, errors: 1 }, scope: { mode: 'auto', images: 5, model_type: 'wd14-vit' } })); + const view = jobView(job.kind); + assert.deepEqual(view.tiles, [['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'], + ['errors', 'Errors', 'bad']]); + assert.deepEqual(view.outcomes, [['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], ['unchanged', 'Unchanged']]); + assert.deepEqual([...view.openable].sort(), ['tagged', 'unchanged']); + assert.equal(view.log, 'Tagging log'); + assert.equal(view.cancel, 'Cancel tagging'); + assert.deepEqual(pillParts(job), { label: 'Tagging · 40%', files: ' · 2 / 5', text: 'Tagging · 40% · 2 / 5' }); + assert.equal(outcomeLine(job), '1,420 tagged · 1 error'); + assert.equal(outcomeLine({ ...job, counters: {} }), '0 tagged · 0 errors'); + assert.equal(startToast(job), 'Auto-tagging started.'); +}); + +test('unknown kinds keep the generic view', async () => { + const { jobView } = await source; + const scan = jobView('scan'); + assert.deepEqual(scan.tiles, [['added', 'Added to Library', 'added'], ['known', 'Already known', ''], + ['review', 'Needs review', 'warn'], ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad']]); + assert.deepEqual(scan.outcomes, [['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], + ['no_metadata', 'No usable metadata'], ['known', 'Already known']]); + assert.deepEqual([...scan.openable].sort(), ['added', 'known']); + assert.deepEqual([scan.log, scan.cancel], ['Scan log', 'Cancel scan']); + for (const kind of ['maintenance', '', 'constructor', '__proto__']) { + const view = jobView(kind); + assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [scan.tiles, scan.outcomes, [...scan.openable]]); + assert.deepEqual([view.log, view.cancel], ['Job log', 'Cancel job']); + } +}); + test('pagehide stops polling, closes streams and aborts the in-flight read; pageshow resumes', async () => { const { createActivityStore } = await source; const { world, api, ...deps } = harness(); diff --git a/web/js/workspace/activity-job-view.mjs b/web/js/workspace/activity-job-view.mjs index 07329896..9808b7ac 100644 --- a/web/js/workspace/activity-job-view.mjs +++ b/web/js/workspace/activity-job-view.mjs @@ -11,6 +11,9 @@ * filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and * Already known open the file in the existing viewer through its listing identity (D57); the others are plain text. * - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`. + * - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`): + * an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads + * "Tagging log" and "Cancel tagging"; another kind keeps the scan's tiles as "Job log" and "Cancel job". * * The summary follows the Activity store (one SSE stream per running job); the listed rows and the log are read from * `GET /prompt_manager/jobs/<id>` at most once a second while the job runs, and once more when it ends. @@ -19,7 +22,7 @@ import { createButton } from '../shared/toolkit/core.js'; import { pageHeading } from './heading.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { noun } from './plural.mjs'; -import { parseJob, JOBS_URL, LOST_DETAIL } from './activity-store.mjs'; +import { parseJob, jobView, JOB_VIEWS, JOBS_URL, LOST_DETAIL } from './activity-store.mjs'; import { jobDetail, CANCEL_FAILED_TOAST } from './activity.mjs'; import { downloadJobReport } from './activity-scan.mjs'; import { createMediaViewer } from './media-viewer.mjs'; @@ -36,18 +39,6 @@ export const FILE_GONE = 'This file is no longer in Images. Rescan from Import a export const OPEN_FAILED = 'The file could not be opened. Try again.'; const LISTING_LIMIT = 5; -/** The counter tiles, in the spec's order and the mockup's tones. */ -export const TILES = Object.freeze([ - ['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'], - ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'], -]); -/** The outcome list, in the mockup's order: what got added and what went wrong first. */ -export const OUTCOMES = Object.freeze([ - ['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], - ['no_metadata', 'No usable metadata'], ['known', 'Already known'], -]); -const OPENABLE = new Set(['added', 'known']); - function element(tag, text, className) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; @@ -103,9 +94,9 @@ export function filterRows(rows, filter) { return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted)); } -/** The first outcome with files, so a view opens on what happened; Added when nothing has yet. */ -export function defaultOutcome(counters) { - return OUTCOMES.find(([group]) => (counters[group] ?? 0) > 0)?.[0] ?? 'added'; +/** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */ +export function defaultOutcome(counters, outcomes = JOB_VIEWS.scan.outcomes) { + return (outcomes.find(([group]) => (counters[group] ?? 0) > 0) ?? outcomes[0])[0]; } function section(title, id) { @@ -132,13 +123,6 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { bar.max = 100; const stats = element('dl', undefined, 'stat-row'); const tileValues = new Map(); - for (const [group, label, tone] of TILES) { - const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat'); - const value = element('dd', '0'); - tile.append(element('dt', label), value); - tileValues.set(group, value); - stats.append(tile); - } progressPart.node.append(bar, stats); const resultsPart = section('Results', 'job-results-title'); @@ -155,15 +139,6 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { list.setAttribute('aria-label', 'Outcomes'); const files = element('div', undefined, 'outcome-files'); const buttons = new Map(); - for (const [group, label] of OUTCOMES) { - const button = createButton(''); - const count = element('span', '0'); - button.append(element('span', label), count); - button.setAttribute('aria-pressed', 'false'); - button.count = count; - buttons.set(group, button); - list.append(button); - } outcomes.append(list, files); resultsPart.node.append(outcomes); @@ -181,6 +156,8 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { missing.append(element('p', NOT_FOUND, 'empty-state'), back); let detail = null; + // The job's kind decides its tiles, outcomes and labels (D89); it is known once the job is. + let view = null; let selected = null; let mounted = false; let lastRead = 0; @@ -200,13 +177,35 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: inspection, sender: createPromptSender({ scope }), onSelect: image => inspection.select(image) }); + /** The tiles and the outcome list of the job's kind, built once. */ + function layout(kind) { + view = jobView(kind); // D89 + for (const [group, label, tone] of view.tiles) { + const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat'); + const value = element('dd', '0'); + tile.append(element('dt', label), value); + tileValues.set(group, value); + stats.append(tile); + } + for (const [group, label] of view.outcomes) { + const button = createButton(''); + const count = element('span', '0'); + button.append(element('span', label), count); + button.setAttribute('aria-pressed', 'false'); + button.count = count; + buttons.set(group, button); + list.append(button); + } + } + function showBody(job) { if (mounted) return; mounted = true; + if (!view) layout(job.kind); missing.remove(); parts.actions.append(download, cancel); main.append(progressPart.node, resultsPart.node, log); - selected ??= defaultOutcome(job.counters); + selected ??= defaultOutcome(job.counters, view.outcomes); } /** The job is not (or no longer) in this session: say so instead of an empty view. */ @@ -242,7 +241,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { function renderRows(job) { const rows = detail?.outcomes[selected] ?? []; - const openable = OPENABLE.has(selected); + const openable = view.openable.has(selected); const wanted = filterRows(rows.map((row, index) => ({ ...row, index })), input.value); const kept = new Map(wanted.map(row => rowNode(row, row.index, openable))); rowNodes = kept; @@ -270,7 +269,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { parts.h1.textContent = job.title; document.title = `${job.title} · Prompt Manager`; parts.description.textContent = jobDetail(job); - cancel.textContent = scan ? 'Cancel scan' : 'Cancel job'; + cancel.textContent = view.cancel; // A job lost with the server has no report there (review M9). const lost = job.state === 'lost'; if (lost && document.activeElement === download) parts.h1.focus(); @@ -291,7 +290,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { button.count.textContent = number(job.counters[group] ?? 0); button.setAttribute('aria-pressed', String(group === selected)); } - logLabel.textContent = `● ${scan ? 'Scan' : 'Job'} log`; + logLabel.textContent = `● ${view.log}`; const text = detail?.log.length ? detail.log.join('\n') : 'No log lines yet.'; if (pre.textContent !== text) { // Replacing the text clamps the scroll position, so keep it: pinned readers follow the end, others stay. diff --git a/web/js/workspace/activity-store.mjs b/web/js/workspace/activity-store.mjs index 0c3420c4..06be04dd 100644 --- a/web/js/workspace/activity-store.mjs +++ b/web/js/workspace/activity-store.mjs @@ -23,10 +23,52 @@ export const LOST_DETAIL = 'This job ended when ComfyUI restarted.'; const SERVER_STATES = new Set(['running', 'finished', 'cancelled', 'failed']); const FINAL_STATES = new Set(['finished', 'cancelled', 'failed']); const JOB_ID = /^[0-9a-f]{32}$/; -// The pill's verb for each job kind; a kind without one shows its title. -const PILL_VERBS = Object.freeze({ scan: 'Scanning' }); +// The pill's verb for each job kind; a kind without one shows its title (D89: image tagging jobs read "Tagging"). +const PILL_VERBS = Object.freeze({ scan: 'Scanning', 'image-tags': 'Tagging' }); const START_TOASTS = Object.freeze({ scan: 'Scan started.' }); +/** The scan's job view (D56): its counter tiles `[group, label, tone]` and outcome list in the mockup's orders. */ +const SCAN_VIEW = Object.freeze({ + tiles: Object.freeze([ + ['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'], + ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'], + ].map(Object.freeze)), + // What got added and what went wrong first. + outcomes: Object.freeze([ + ['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], + ['no_metadata', 'No usable metadata'], ['known', 'Already known'], + ].map(Object.freeze)), + // Rows that open the file in the viewer (D57); the others are plain text. + openable: Object.freeze(new Set(['added', 'known'])), + log: 'Scan log', + cancel: 'Cancel scan', +}); + +/** + * Each job kind's view: its tiles, outcome list, openable rows, log label and cancel label. D89: an image tagging job + * (`image-tags`) counts Tagged (green), Unchanged, Skipped (warning) and Errors (red), lists what got tagged and what + * went wrong first, and opens Tagged and Unchanged rows in the viewer like the scan's (D57). + */ +export const JOB_VIEWS = Object.freeze({ + scan: SCAN_VIEW, + 'image-tags': Object.freeze({ + tiles: Object.freeze([['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'], + ['errors', 'Errors', 'bad']].map(Object.freeze)), + outcomes: Object.freeze([['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], + ['unchanged', 'Unchanged']].map(Object.freeze)), + openable: Object.freeze(new Set(['tagged', 'unchanged'])), + log: 'Tagging log', + cancel: 'Cancel tagging', + }), +}); +// Another kind keeps the scan's tiles and outcomes under the generic labels. +const GENERIC_VIEW = Object.freeze({ ...SCAN_VIEW, log: 'Job log', cancel: 'Cancel job' }); + +/** The view of a job `kind`; an unknown kind gets the generic one. */ +export function jobView(kind) { + return Object.hasOwn(JOB_VIEWS, kind) ? JOB_VIEWS[kind] : GENERIC_VIEW; +} + /** The wait before the `attempt`-th re-read (0-based): 1 s, 2 s, 4 s … never more than 30 s. */ export function backoffDelay(attempt) { const step = Number.isSafeInteger(attempt) && attempt > 0 ? Math.min(attempt, 16) : 0; @@ -98,9 +140,14 @@ export function pillParts(job) { return { label, files, text: label + files }; } -/** The scan's counters as one line: "N added · M errors"; other kinds have none yet. */ +/** "12 tagged" (D89). */ +export const taggedOf = job => `${number(job.counters.tagged ?? 0)} tagged`; + +/** The counters as one line: "N added · M errors" for a scan, "N tagged · M errors" for image tagging (D89). */ export function outcomeLine(job) { - return job.kind === 'scan' ? `${addedOf(job)} · ${errorsOf(job)}` : ''; + if (job.kind === 'scan') return `${addedOf(job)} · ${errorsOf(job)}`; + if (job.kind === 'image-tags') return `${taggedOf(job)} · ${errorsOf(job)}`; + return ''; } function defaultConnect(url) { diff --git a/web/js/workspace/activity.mjs b/web/js/workspace/activity.mjs index 1a192aa9..48b83c4c 100644 --- a/web/js/workspace/activity.mjs +++ b/web/js/workspace/activity.mjs @@ -11,13 +11,14 @@ * - D54: each `.activity-item` is a real job. Its chip names the state (green "Completed", or blue "Running", * "Cancelled", "Failed", "Ended"), its line says when and what, and a running job shows its `<progress>` and * "Cancel job" (no "Simulate completion"). + * - D89: an image tagging job's line names its images and, once ended, "N tagged · M errors". */ import { createButton } from '../shared/toolkit/core.js'; import { navigateTo } from './state.mjs'; import { pageHeading } from './heading.mjs'; import { showWorkspaceToast } from './toast.mjs'; -import { countOf } from './plural.mjs'; -import { pillParts, outcomeLine, addedOf, LOST_DETAIL } from './activity-store.mjs'; +import { countOf, noun } from './plural.mjs'; +import { pillParts, outcomeLine, addedOf, taggedOf, LOST_DETAIL } from './activity-store.mjs'; export const ACTIVITY_DESCRIPTION = 'Scans, tagging, downloads and maintenance share one history.'; export const EMPTY_ACTIVITY = 'No jobs have run since ComfyUI started. Scans and other long operations appear here.'; @@ -44,12 +45,21 @@ export function clockTime(seconds) { const join = parts => parts.filter(Boolean).join(' · '); -/** What a job covers: "2 output folders" for a scan. */ +const whole = value => Number.isSafeInteger(value) && value >= 0; + +/** What a job covers: "2 output folders" for a scan, "4 images" for image tagging (D89). */ function scopeLine(job) { + // D89: grouped as the server writes its lines ("19,622 images"). + const images = job.scope?.images; + if (job.kind === 'image-tags') return whole(images) ? `${images.toLocaleString('en-US')} ${noun(images, 'image')}` : ''; const folders = job.scope?.output_folders; - return Number.isSafeInteger(folders) && folders >= 0 ? countOf(folders, 'output folder') : ''; + return whole(folders) ? countOf(folders, 'output folder') : ''; } +/** What a cancelled job kept: "N added" for a scan, "N tagged" for image tagging (D89). */ +const KEPT = Object.freeze({ __proto__: null, scan: addedOf, 'image-tags': taggedOf }); // D89 +const keptOf = job => KEPT[job.kind]?.(job) ?? ''; + /** The item's line under its title, by state. */ export function jobDetail(job) { const at = clockTime(job.finished_at); @@ -58,8 +68,7 @@ export function jobDetail(job) { } if (job.state === 'lost') return LOST_DETAIL; if (job.state === 'cancelled') { - return join([`Cancelled ${at} at a file boundary`, job.kind === 'scan' ? addedOf(job) : '', - 'completed work is kept']); + return join([`Cancelled ${at} at a file boundary`, keptOf(job), 'completed work is kept']); } if (job.state === 'failed') return join([`Stopped on an error at ${at}`, outcomeLine(job)]); return join([`Finished ${at}`, outcomeLine(job)]); diff --git a/web/js/workspace/images-tagging-bulk.mjs b/web/js/workspace/images-tagging-bulk.mjs index 6e04fdc3..4d197998 100644 --- a/web/js/workspace/images-tagging-bulk.mjs +++ b/web/js/workspace/images-tagging-bulk.mjs @@ -26,13 +26,14 @@ 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 NO_TAGS = 'Enter at least one tag.'; // D117 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'}), ]); +// D117: the concept has no copy for these; they follow D55's "The scan could not start. Try again.". 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']); @@ -144,7 +145,7 @@ export function openAutoTag({api, scope, store, images, trigger, onTagged = () = 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}); + heading: `Auto-tag ${countOf(listed.length, 'image')}`, generate: false}); // D117 let choice = APPLY_CHOICES[0]; const actions = el('div', undefined, 'dialog-actions'); const cancel = createButton('Cancel');