diff --git a/TESTING.md b/TESTING.md index 97fc8a6..728afb1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -292,8 +292,9 @@ The shared inline and collection inspector reads the current rooted file's suppo embedded records, exact generation facts and recorded or inferred prompt evidence. It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows only the concept content: cover, title, filename, the evidence badge with a category -chip, the + and − prompt blocks with copy and send, tag chips (the linked positive's -own tags filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and +chip, the + and − prompt blocks with copy and send, tag chips (the file's own image +tags first, D88, then the linked tags; image tags and the linked positive's own tags +filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and "Prompt history" only when prompt roles resolved or a link is saved; the sticky panel scrolls inside the window. The viewer's details (`#viewer-details`) repeat that content without the cover, then "File & provenance" (the honest file facts, the @@ -949,6 +950,8 @@ Recorded deviations on this page (spec §8): history appear only with resolved prompt roles or a saved link; a video file keeps `video.detail-cover`; and only the linked positive's own tags are `button.chip` filters, while recipe, recorded-run and embedded tags stay plain `span.chip`. + Amended by D88 (#249 PR C): the file's own current image tags lead the same row + and are filters too; a linked tag already shown (case-insensitive) is left out. - **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept applies its negative wording to both roles), and a confirmed `p.prompt-text` is focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable. @@ -1080,7 +1083,7 @@ unconfirmed save), where the other workspace dialogs place their Retry. The Tags page re-reads its lists after every confirmed save, so the Images counts follow. The image-tagging plan's deviation numbers were renumbered by +23 because the -Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D68 → D91). +Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68 → D91). - **D83** (plan D60, ruling T4): the suggestions are removable chips instead of checkbox rows: `span.chip.tag-suggestion` in a `.tags` row, in the concept's @@ -1097,6 +1100,19 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D68 → D91). - **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model change keeps it. +- **D88** (plan D65, ruling T7/C2): the inspector's chip row (side panel and viewer + details, after the prompt blocks) shows the file's current image tags first (the + B3 envelope's `image_tags`, `state: "current"`, S1), then D21's linked tags not + already shown (case-insensitive, the image tag's spelling wins). In the Images + panel every image tag is a Tags filter `button.chip` (the filter matches image + tags too) and linked tags keep D21's rule; in the viewer, and wherever no Tags + filter exists (the Library-embedded gallery, Activity and recorded runs), every + chip is a plain `span.chip`. `none` and `unavailable` add no chips and no other + chrome; a malformed facet fails the inspection like any malformed field. The + concept has one row of the image's own tags (`i.tags`, app.js a402), so with the + concept image's tags saved as its image tags production still equals the concept. + After a Tag save the selected file's inspection is re-read, so the panel redraws + the row without reselecting and the viewer, which shares that inspection, shows it. - **D115** (beyond the plan's list): the concept always has a selected image, so its "Auto-tag images" always opens the dialog. Production opens it for the image selected in Images when it was last left; with none it is `aria-disabled` and @@ -1107,8 +1123,11 @@ Test files: `tests/browser/workspace-tagging.spec.mjs` (the fixture is pair, an equal-byte copy, a video, an unlinked file, the fake model with `hf`, and no model files; every test asserts no download request), `tests/js/workspace-image-tagging.test.js`, -`tests/js/workspace-image-tagging-state.test.js` and -`tests/js/workspace-images-lifecycle.test.js`. +`tests/js/workspace-image-tagging-state.test.js`, +`tests/js/workspace-images-lifecycle.test.js`, and for D88 +`tests/js/workspace-file-inspector.test.js`, +`tests/js/workspace-inspection-state.test.js` and `tests/browser/inspector-concept.spec.mjs` +("image tags saved on the concept image keep the inspector equal to the concept"). ```sh node --test tests/js/workspace-image-tagging*.test.js tests/js/workspace-images-lifecycle.test.js diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 0bc9dfb..862ad30 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -78,6 +78,23 @@ export async function seedConcept(request,data={}) { return response.json(); } +/** + * Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag + * routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names. + */ +export async function saveImageTags(request,path,tags) { + const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json(); + const target={root_index:0,root_scope:listing.images.find(item=>item.root_index===0).root_scope,path}; + const observed=await (await request.post('/prompt_manager/image-annotations/observe',{data:{target}})).json(); + expect(observed.success).toBe(true); + try { + const receipt=await (await request.put('/prompt_manager/image-annotations/tags',{data:{target,capture_token:observed.capture.token, + sha256:observed.capture.sha256,expected_epoch:observed.library_epoch,expected_tag_revision:observed.tag_revision,tags}})).json(); + expect(receipt.success).toBe(true); + return receipt.tags; + } finally {await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);} +} + async function selectTheme(page,theme) { await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); } diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 951c833..1c8f77e 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from './fixtures.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} from './concept-inventory.mjs'; +import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags} from './concept-inventory.mjs'; // Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i, // true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes. @@ -319,6 +319,27 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']); }); +// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names +// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain +// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes. +for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{ + test.setTimeout(120_000); + const viewport={width,height};await page.setViewportSize(viewport); + await openConceptPanel(page,'dark'); + const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents(); + expect(names).toEqual(['cinematic','blue_hour','architecture']); + await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector'); + const reference=await inventory(page,true); + const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png'); + expect(await saveImageTags(request,path,names)).toEqual(names); + const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`); + await openProductionPanel(page,'dark'); + expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names}); + await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names); + await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector'); + assertInspectorMatch(reference,await inventory(page),viewport); +}); + // The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS // mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5) // its pair actions removed and D38's Show in folder removed; each applied after an exact match. diff --git a/tests/browser/workspace-tagging.spec.mjs b/tests/browser/workspace-tagging.spec.mjs index ab6d9fc..5c20e2f 100644 --- a/tests/browser/workspace-tagging.spec.mjs +++ b/tests/browser/workspace-tagging.spec.mjs @@ -3,7 +3,7 @@ // downloaded or loaded: the fake answers, and every test checks that no download request was sent. import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; import { test, expect } from './fixtures.mjs'; -import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs'; +import { seedConcept, openConcept, openProduction, saveImageTags } from './concept-inventory.mjs'; const images = '/prompt_manager/workspace#/images'; const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs'; @@ -22,7 +22,7 @@ const status = page => dialog(page).locator('#tag-generation-status'); const toast = page => page.locator('#workspace-toast'); const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) }); const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png'; -const VIDEO = 'tagging/clip.webm'; +const VIDEO = 'tagging/clip.webm', UNLINKED = 'loose/unlinked.png'; let manifest; test.beforeEach(async ({ page, request, browserName }) => { @@ -461,3 +461,72 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) test(`paired Tag dialo await shot(`${side}-tag-dialog-generated-${width}`); } }); + +// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked +// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain. +const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true }); +const tagRow = page => panel(page).locator('.detail-inner > .tags').last(); +const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`); +const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true }); + +test('inspector shows saved image tags after Save without reselecting', async ({ page }) => { + await page.goto(images); + await expect(card(page, FIRST)).toHaveClass(/\bselected\b/); + // D21/D39: the linked recipe's own tag, plain. + await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags); + await expect(tagRow(page).locator('button.chip')).toHaveCount(0); + await openTag(page, FIRST, { goto: false }); + await manual(page).fill('dunes_tag, Editorial'); + const reread = inspected(page, FIRST); + await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click(); + expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] }); + // The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial". + await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']); + await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']); + await expect(card(page, FIRST)).toHaveClass(/\bselected\b/); + await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title); +}); + +test('clicking an image tag chip filters Images to the tagged unlinked file', async ({ page, request }) => { + expect(await saveImageTags(request, UNLINKED, ['loose_tag'])).toEqual(['loose_tag']); + await page.goto(images); + const read = inspected(page, UNLINKED); + await infoButton(page, UNLINKED).click(); + await read; + await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png'); + await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']); + const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag'); + await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click(); + await listing; + await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1); + await expect(card(page, UNLINKED)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Filters · 1', exact: true })).toBeVisible(); +}); + +test('viewer details show the same chips', async ({ page, request }) => { + await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']); + await page.goto(images); + await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']); + await card(page, FIRST).locator('.image-open').click(); + const details = page.locator('#viewer-dialog[open] #viewer-details'); + await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title); + const row = details.locator('.tags').last(); + await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']); + await expect(row.locator('span.chip')).toHaveCount(3); + await expect(row.locator('button')).toHaveCount(0); + // A file without image tags: the viewer shows only D21's linked tags. + await page.getByRole('button', { name: 'Next media', exact: true }).click(); + await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`); + await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags); +}); + +test('search finds an image by its image tag', async ({ page, request }) => { + await saveImageTags(request, UNLINKED, ['Loose_Tag']); + await page.goto(images); + await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(manifest.files.length); + const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('q') === 'loose_t'); + await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('loose_t'); + await listing; + await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1); + await expect(card(page, UNLINKED)).toBeVisible(); +}); diff --git a/tests/browser/workspace_tagging_fixture.py b/tests/browser/workspace_tagging_fixture.py index d4677ca..9ca8aca 100644 --- a/tests/browser/workspace_tagging_fixture.py +++ b/tests/browser/workspace_tagging_fixture.py @@ -187,6 +187,9 @@ def register_tagging_commands( raise web.HTTPBadRequest() model.unavailable = set() model.loaded = None + # A test that ends with the dialog open leaves its capture behind; drop + # every capture so the next test starts below the store's limits. + await api._run_in_executor(api.close_annotation_captures) manifest = await api._run_in_executor(seed_tagging, api, root, extra) return web.json_response({"success": True, **manifest}) diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index 16d5a17..15c512f 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -86,3 +86,34 @@ test('Show in folder outcomes use the D38 toasts',async()=>{ for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')]) assert.equal(revealOutcome(error),'The folder could not be opened. Try again.'); }); + +// #249 PR C Task C3 (D88): one chip row, the file's current image tags first, then D21's linked tags not already shown. +function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) { + return {...record(), imageTags, library}; +} +test('renders current image tags before linked tags without duplicates', async () => { + const {inspectorTagChips} = await load(); + assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported'); + assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}), + [{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]); + // D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter. + assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}), + [{name:'Editorial', filter:true}, {name:'cover', filter:false}]); + // An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones. + assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]); + assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']); + assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []); +}); +test('image tag chips filter in the panel and are plain in the viewer', async () => { + const {inspectorTagChips} = await load(); + const source = tagged({state:'current', tags:['dunes']}); + assert.deepEqual(inspectorTagChips(source, {filterable:true}).map(chip => chip.filter), [true, true, true, true]); + assert.deepEqual(inspectorTagChips(source, {filterable:false}).map(chip => chip.filter), [false, false, false, false]); +}); +test('none and unavailable render no image chips', async () => { + const {inspectorTagChips} = await load(); + for (const state of ['none', 'unavailable']) { + assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']); + assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), []); + } +}); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index 6fca178..ef65b55 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -702,3 +702,29 @@ test('a workflow the legacy parser throws on keeps the server result instead of assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' }); assert.equal(record.prompts.negative.status, 'ambiguous'); }); + +// #249 PR C Task C3: the B3 envelope's `image_tags` facet, the file's own current tags (S1). +test('image_tags facet is kept exactly and absent stays null', () => { + const entry = image(), project = contract('toInspectionRecord'); + assert.equal(project(envelope(entry), entry, extractGenerationFacts).imageTags, null); + for (const facet of [{ state: 'current', tags: ['dunes', 'warm_light'] }, { state: 'current', tags: [] }, { state: 'none', tags: [] }, { state: 'unavailable', tags: [] }]) { + const data = { ...envelope(entry), image_tags: facet }; + const record = project(data, entry, extractGenerationFacts); + assert.deepEqual(record.imageTags, facet); + assert.notEqual(record.imageTags.tags, facet.tags); + } + const hundred = Array.from({ length: 100 }, (_, index) => `tag_${index}`); + assert.deepEqual(project({ ...envelope(entry), image_tags: { state: 'current', tags: hundred } }, entry, extractGenerationFacts).imageTags.tags, hundred); +}); + +test('rejects a malformed image_tags facet', () => { + const entry = image(); + for (const facet of [null, [], 'current', { state: 'current' }, { tags: [] }, { state: 'current', tags: ['a'], extra: 1 }, + { state: 'stale', tags: [] }, { state: 'none', tags: ['a'] }, { state: 'unavailable', tags: ['a'] }, { state: 'current', tags: 'a' }, + { state: 'current', tags: [1] }, { state: 'current', tags: [''] }, { state: 'current', tags: [' a'] }, { state: 'current', tags: ['a,b'] }, + { state: 'current', tags: ['a\nb'] }, { state: 'current', tags: ['x'.repeat(51)] }, + { state: 'current', tags: Array.from({ length: 101 }, (_, index) => `tag_${index}`) }]) { + assert.throws(() => module.toInspectionRecord({ ...envelope(entry), image_tags: facet }, entry, extractGenerationFacts), TypeError, JSON.stringify(facet)); + } + assert.equal(module.toInspectionRecord({ ...envelope(entry), image_tags: { state: 'current', tags: ['é'.repeat(50)] } }, entry, extractGenerationFacts).imageTags.tags[0].length, 50); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index ff84c23..49d5436 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -271,7 +271,7 @@ }, "workspace/inspection-state.mjs": { "lines": 98, - "branches": 93, + "branches": 94, "functions": 98, "statements": 98 }, diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index b7b592d..13d21b5 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -143,6 +143,25 @@ function evidenceBadge(prompts) { function extensionOf(record) { return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase(); } +/** + * The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already + * shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and + * a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain. + */ +export function inspectorTagChips(record, {filterable}) { + const shown = new Set(), chips = []; + const add = (names, filter) => { + for (const name of names) { + const key = name.toLowerCase(); + if (shown.has(key)) continue; + shown.add(key); chips.push({name, filter}); + } + }; + if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable); + const {library} = record; + if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive'); + return chips; +} /** A concept chip whose visible text carries a visually hidden prefix naming what it is. */ function prefixedChip(prefix, text) { const chip = el('span', undefined, 'chip'); @@ -211,15 +230,16 @@ export function mountFileInspector(container, {api, sender, parentScope, variant const prompts = el('div'); inner.append(prompts); const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title}); blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose); - if (linked && library.tags.length) { - // D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are - // not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the - // D39 fallback a recipe without tags shows its positive's, which are filter buttons again. - const filterable = onTagFilter && library.tags_scope === 'positive'; + // D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are + // not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the + // D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own + // image tags lead the row and, where a Tags filter exists, always set it (it matches them too). + const chips = inspectorTagChips(record, {filterable:!!onTagFilter}); + if (chips.length) { const tags = el('div', undefined, 'tags'); - for (const tag of library.tags) { - if (!filterable) {tags.append(el('span', tag, 'chip')); continue;} - const chip = createButton(tag, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(tag)); tags.append(chip); + for (const {name, filter} of chips) { + if (!filter) {tags.append(el('span', name, 'chip')); continue;} + const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip); } inner.append(tags); } diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index bfb61ed..5f792a4 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -167,6 +167,20 @@ function libraryFor(value) { return library; } +// #249 PR C (D88): the file's own current image tags (S1). `none` and `unavailable` carry no names; a name is a +// vocabulary tag name (1–50 characters, trimmed, no comma or control character), at most 100 of them. +const IMAGE_TAG_STATES = ['current', 'none', 'unavailable'], MAX_IMAGE_TAGS = 100, IMAGE_TAG_LENGTH = 50; +const imageTagName = name => typeof name === 'string' && name !== '' && name === name.trim() && [...name].length <= IMAGE_TAG_LENGTH && + !/[,\x00-\x1f\x7f]/.test(name); + +function imageTagsFor(value) { + if (value === undefined) return null; + if (!object(value) || Object.keys(value).sort().join() !== 'state,tags' || !IMAGE_TAG_STATES.includes(value.state) || + !Array.isArray(value.tags) || value.tags.length > MAX_IMAGE_TAGS || !value.tags.every(imageTagName) || + (value.state !== 'current' && value.tags.length)) throw new TypeError('Invalid inspection image tags'); + return { state: value.state, tags: [...value.tags] }; +} + // The legacy workflow parse (comfy-metadata.js, as of #170) also feeds the gallery and // admin pages; it fills any prompt or setting the server could not decide, so this page // never shows less than they do. A definite server answer is never replaced. @@ -279,9 +293,10 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false, parser = comfyMetadata()) { const record = { identity, file: null, prompts: promptsFor(result.prompts), library: null, metadata: null, - generation: unavailableFacts(), historyTarget: null, download, + generation: unavailableFacts(), historyTarget: null, download, imageTags: null, }; if (legacy) return record; + record.imageTags = imageTagsFor(result.image_tags); const resolution = resolutionFor(result.resolution); record.file = fileFor(result.file, identity); record.metadata = metadataFor(result.metadata);