diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index 9966c517..904b7d14 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -364,7 +364,7 @@ test('the D117 wrap rule targets only the settings summary button', async () => assert.doesNotMatch(css, /#viewer-details > div > button/); }); -test('category chip names its origin and shows an own category for any link status (#332)', async () => { +test('category chip names its origin visibly and shows an own category for any link status (#332 D146)', async () => { const base = await localRecord(localPng('a.png')); const chipTexts = record => { const {f, done} = renderFull(record); @@ -373,11 +373,11 @@ test('category chip names its origin and shows an own category for any link stat } finally { done(); } }; const cases = [ - [{status:'none', category:'Scans', category_scope:'image'}, 'Image category: Scans'], - [{status:'ambiguous', category:'Scans', category_scope:'image'}, 'Image category: Scans'], - [{status:'linked', category:'Portraits', category_scope:'image'}, 'Image category: Portraits'], - [{status:'linked', category:'Landscapes', category_scope:'positive'}, 'Positive prompt category: Landscapes'], - [{status:'linked', category:'Recipes', category_scope:'pair'}, 'Recipe category: Recipes'], + [{status:'none', category:'Scans', category_scope:'image'}, 'Image category: Scans · this image'], + [{status:'ambiguous', category:'Scans', category_scope:'image'}, 'Image category: Scans · this image'], + [{status:'linked', category:'Portraits', category_scope:'image'}, 'Image category: Portraits · this image'], + [{status:'linked', category:'Landscapes', category_scope:'positive'}, 'Positive prompt category: Landscapes · prompt'], + [{status:'linked', category:'Recipes', category_scope:'pair'}, 'Recipe category: Recipes · recipe'], ]; for (const [library, text] of cases) { const chips = chipTexts({...base, library:{...base.library, tags:[], tags_scope:null, ...library}}); diff --git a/tests/js/workspace-images-category.test.js b/tests/js/workspace-images-category.test.js index 612e46a7..e037e22e 100644 --- a/tests/js/workspace-images-category.test.js +++ b/tests/js/workspace-images-category.test.js @@ -1,53 +1,61 @@ const test = require('node:test'); const assert = require('node:assert/strict'); + +// #332 D140: Set category writes each selected image's own category; Use prompt category removes it. const source = import('../../web/js/workspace/images-category.mjs'); -const linked = (id, extra = {}) => ({path:`p${id}.png`, linked:{status:'linked', positive_id:id, ...extra}}); - -test('targets are the distinct linked positives, in selection order, and the rest count as skipped', async () => { - const {categoryTargets} = await source; - const images = [ - linked(7), linked(3), linked(7), - {path:'u.png', linked:{status:'unlinked', positive_id:null}}, - {path:'a.png', linked:{status:'ambiguous', positive_id:4}}, - {path:'v.png', linked:{status:'unavailable', positive_id:null}}, - {path:'none.png'}, - {path:'bad.png', linked:{status:'linked', positive_id:'9'}}, - {path:'zero.png', linked:{status:'linked', positive_id:0}}, - {path:'frac.png', linked:{status:'linked', positive_id:1.5}}, - null, - ]; - assert.deepEqual(categoryTargets(images), {promptIds:[7, 3], skipped:8}); - assert.deepEqual(categoryTargets([]), {promptIds:[], skipped:0}); - assert.deepEqual(categoryTargets([linked(2)]), {promptIds:[2], skipped:0}); -}); - -test('the outcome toast counts the prompts and the skipped images', async () => { - const {categoryOutcome} = await source; - assert.equal(categoryOutcome(3, 0), 'Category set on 3 prompts.'); - assert.equal(categoryOutcome(1, 0), 'Category set on 1 prompt.'); - assert.equal(categoryOutcome(2, 4), 'Category set on 2 prompts. 4 selected images had no linked prompt and were skipped.'); - assert.equal(categoryOutcome(1, 1), 'Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.'); -}); - -test('the dialog and failure copy is the plan\'s', async () => { - const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source; - assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'}); - assert.equal(CATEGORY_NONE_LINKED, "None of the selected images is linked to a saved prompt, so there is no prompt category to set."); +test('the dialog keeps the concept\'s simpleInput words and names the routes', async () => { + const {CATEGORY_DIALOG, USE_PROMPT_CATEGORY, REMOVE_DIALOG, CATEGORY_URL, IMAGE_CATEGORIES_URL, CATEGORY_FAILED, + CATEGORY_MAX_LENGTH, MAX_CATEGORY_IMAGES} = await source; + assert.deepEqual({...CATEGORY_DIALOG}, {title:'Categorize selected images', label:'Category', action:'Apply'}); + assert.equal(USE_PROMPT_CATEGORY, 'Use prompt category'); + assert.deepEqual({...REMOVE_DIALOG}, {title:'Use prompt category', action:'Remove'}); + assert.equal(CATEGORY_URL, '/prompt_manager/images/category'); + assert.equal(IMAGE_CATEGORIES_URL, '/prompt_manager/images/categories'); assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.'); - assert.ok(Object.isFrozen(CATEGORY_DIALOG)); + assert.equal(CATEGORY_MAX_LENGTH, 100); + assert.equal(MAX_CATEGORY_IMAGES, 500); }); -test('the updated count is the server\'s when it is a count, else the number of prompts posted', async () => { - const {updatedCount} = await source; - assert.equal(updatedCount({updated_count:0}, 3), 0); - assert.equal(updatedCount({updated_count:2}, 3), 2); - for (const result of [{}, {updated_count:-1}, {updated_count:1.5}, {updated_count:'2'}, null, undefined]) { - assert.equal(updatedCount(result, 3), 3, JSON.stringify(result)); +test('a set counts unchanged files as set and names files that could not be found', async () => { + const {setOutcome} = await source; + assert.equal(setOutcome({updated:1, unchanged:0, not_found:0}, 'Portraits'), 'Category “Portraits” set on 1 image.'); + assert.equal(setOutcome({updated:1, unchanged:2, not_found:0}, 'Scans'), 'Category “Scans” set on 3 images.'); + assert.equal(setOutcome({updated:2, unchanged:0, not_found:1}, 'Scans'), 'Category “Scans” set on 2 images. 1 could not be found.'); + assert.equal(setOutcome({updated:0, unchanged:1, not_found:3}, 'Scans'), 'Category “Scans” set on 1 image. 3 could not be found.'); + assert.equal(setOutcome({}, 'Scans'), 'Category “Scans” set on 0 images.'); +}); + +test('a removal says how many lost their own category, or that none had one', async () => { + const {clearOutcome, NONE_HAD_OWN} = await source; + assert.equal(clearOutcome({cleared:1}), 'Removed the image category from 1 image.'); + assert.equal(clearOutcome({cleared:4}), 'Removed the image category from 4 images.'); + assert.equal(clearOutcome({cleared:0}), NONE_HAD_OWN); + assert.equal(NONE_HAD_OWN, 'None of the selected images had its own category.'); +}); + +test('the confirmation states the count', async () => { + const {removeQuestion} = await source; + assert.equal(removeQuestion(1), "Remove the image category from the 1 selected image? Each one then shows its prompt's " + + 'category, or none if it has no linked prompt. This cannot be undone.'); + assert.match(removeQuestion(1200), /^Remove the image category from the 1,200 selected images\? /); +}); + +test('coded failures have their own sentences, anything else the fixed one', async () => { + const {failureMessage, NONE_FOUND, SOURCES_CHANGED, CATEGORY_FAILED} = await source; + assert.equal(failureMessage({code:'files-not-found'}), NONE_FOUND); + assert.equal(NONE_FOUND, 'None of the selected images could be found. Refresh the images and try again.'); + assert.equal(failureMessage({code:'source-changed'}), SOURCES_CHANGED); + assert.equal(SOURCES_CHANGED, 'The sources changed. Refresh the images and try again.'); + assert.equal(failureMessage({code:'too-many-images', message:'Select at most 500 images.'}), 'Select at most 500 images.'); + for (const error of [null, undefined, new Error('x'), {code:'other', message:'secret'}]) { + assert.equal(failureMessage(error), CATEGORY_FAILED); } }); -test('the category field is limited to the server\'s 100 characters', async () => { - const {CATEGORY_MAX_LENGTH} = await source; - assert.equal(CATEGORY_MAX_LENGTH, 100); +test('the toolbar list merges prompt and image categories once each', async () => { + const {mergeCategories} = await source; + assert.deepEqual(mergeCategories(['Landscapes', 'Portraits'], ['Scans', 'Landscapes']), ['Landscapes', 'Portraits', 'Scans']); + assert.deepEqual(mergeCategories(['A', '', ' ', 7], null), ['A']); + assert.deepEqual(mergeCategories(undefined, ['landscapes', 'Landscapes']), ['landscapes', 'Landscapes']); }); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index 761c2c6e..3ee4cc0c 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -567,6 +567,43 @@ test('an input dialog with a fallback applies it for an empty value', async () = } }); +// #332: Set category's field suggests the categories under its own datalist id, and "Use prompt category" sits +// between Cancel and Apply, closing the dialog before it runs. +test('an input dialog offers suggestions and a secondary action', async () => { + const { openInputDialog, closeWorkspaceDialog, INPUT_SUGGESTIONS_ID } = await dialogsModule; + const dom = fakeDOM(); + const scope = (await coreModule).createScope(); + try { + const calls = []; + openInputDialog({ scope, title: 'Categorize selected images', label: 'Category', action: 'Apply', maxLength: 100, + suggestions: ['Landscapes', 'Scans'], secondary: { label: 'Use prompt category', onClick: () => calls.push('secondary') }, + onApply: value => calls.push(value) }); + assert.equal(INPUT_SUGGESTIONS_ID, 'workspace-input-suggestions'); + const body = byId(dom, 'modal-body'); + const [field, list, actions] = body.children; + const input = field.children[1]; + assert.equal(input.getAttribute('list'), 'workspace-input-suggestions'); + assert.equal(list.tagName.toLowerCase(), 'datalist'); + assert.equal(list.id, 'workspace-input-suggestions'); + assert.deepEqual(list.children.map(option => option.value), ['Landscapes', 'Scans']); + assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Use prompt category', 'Apply']); + actions.children[1].click(); + assert.deepEqual(calls, ['secondary']); + // No suggestions: no datalist and no list attribute. + openInputDialog({ scope, title: 'T', label: 'L', action: 'A', onApply() {} }); + const [plainField, plainActions] = byId(dom, 'modal-body').children; + assert.equal(plainField.children[1].getAttribute('list'), null); + assert.deepEqual(plainActions.children.map(node => node.textContent), ['Cancel', 'A']); + assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [1], onApply() {} }), TypeError); + assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: '' }, onApply() {} }), TypeError); + assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: 'X' }, onApply() {} }), TypeError); + } finally { + closeWorkspaceDialog(); + scope.dispose(); + dom.restore(); + } +}); + test('dialogs restore focus to the trigger on Escape', async () => { const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule; const dom = fakeDOM(); diff --git a/tests/js/workspace-vocabulary-state.test.js b/tests/js/workspace-vocabulary-state.test.js index 6be188cc..5d9d8e80 100644 --- a/tests/js/workspace-vocabulary-state.test.js +++ b/tests/js/workspace-vocabulary-state.test.js @@ -553,3 +553,16 @@ test('delete text names the tag, its real links and what stays unchanged', async assert.equal(deleteTagText('unused', zero), '“unused” is not linked to any saved prompt or image. Deleting it removes only the name.'); }); + +test('a category row counts prompts and images, and an image-only row browses Images (#332 D143)', async () => { + const {categoryRowText, categoryBrowseTarget, categoryPreviewText} = await import('../../web/js/workspace/vocabulary-state.mjs'); + assert.equal(categoryRowText({value: 'L', prompt_count: 3}), '3 positive prompts'); + assert.equal(categoryRowText({value: 'L', prompt_count: 1, image_count: 0}), '1 positive prompt'); + assert.equal(categoryRowText({value: 'L', prompt_count: 3, image_count: 2}), '3 positive prompts · 2 images'); + assert.equal(categoryRowText({value: 'S', prompt_count: 0, image_count: 1}), '1 image'); + assert.equal(categoryBrowseTarget({value: 'L', prompt_count: 3, image_count: 2}), 'library'); + assert.equal(categoryBrowseTarget({value: 'S', prompt_count: 0, image_count: 2}), 'images'); + const impact = {positive_prompts: 3, other_prompts: 0, negative_variants: 0, archived_negative_variants: 0, images: 2}; + assert.equal(categoryPreviewText({source: 'Landscapes', target: 'Landscape', target_exists: false, impact}), + '“Landscapes” is used by 3 positive prompts and 2 images. Apply renames it to “Landscape”.'); +}); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index d78ec554..b7b66ea1 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -20,8 +20,9 @@ const REPRESENTATIONS = { limited: 'Record exceeds inspection limits; text is unavailable', }; const TEXTLESS = new Set(['invalid', 'limited']); -// #332: the hidden prefix that names where a category chip's value came from. +// #332: the hidden prefix and the visible origin word that name where a category chip's value came from (D146). const CATEGORY_PREFIX = {image: 'Image category: ', positive: 'Positive prompt category: ', pair: 'Recipe category: '}; +const CATEGORY_ORIGIN = {image: 'this image', positive: 'prompt', pair: 'recipe'}; const REVEAL_URL = '/prompt_manager/images/reveal'; // D38 Show in folder: the route's answers, in the maintainer's words. const REVEAL_OPENED = 'Opened the folder on this computer.'; @@ -191,6 +192,17 @@ export function inspectorTagChips(record, {filterable}) { return chips; } /** A concept chip whose visible text carries a visually hidden prefix naming what it is. */ +/** + * The category chip (#332 D146): the hidden prefix, the category, then `span.chip-source` " · this image", " · prompt" + * or " · recipe" (visible, never split), so the chip names where its value came from. + */ +export function categoryChip(library) { + const scope = CATEGORY_ORIGIN[library.category_scope] ? library.category_scope : 'positive'; + const chip = prefixedChip(CATEGORY_PREFIX[scope], library.category); + chip.append(el('span', ` · ${CATEGORY_ORIGIN[scope]}`, 'chip-source')); + return chip; +} + function prefixedChip(prefix, text) { const chip = el('span', undefined, 'chip'); chip.append(el('span', prefix, 'ca-visually-hidden'), text); @@ -266,9 +278,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant const badges = el('div', undefined, 'tags'), badge = evidenceBadge(record.prompts); if (badge) badges.append(el('span', badge, 'chip blue')); // #332: an image's own category shows for any link status; an inherited one only when linked. - if (library?.category && (library.category_scope === 'image' || linked)) { - badges.append(prefixedChip(CATEGORY_PREFIX[library.category_scope] ?? CATEGORY_PREFIX.positive, library.category)); - } + if (library?.category && (library.category_scope === 'image' || linked)) badges.append(categoryChip(library)); if (badges.childNodes.length) inner.append(badges); const prompts = el('div'); inner.append(prompts); const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title}); diff --git a/web/js/workspace/images-category.mjs b/web/js/workspace/images-category.mjs index 77c90857..307e8eb9 100644 --- a/web/js/workspace/images-category.mjs +++ b/web/js/workspace/images-category.mjs @@ -1,42 +1,62 @@ -import { countOf, noun } from './plural.mjs'; +import { countOf } from './plural.mjs'; /** - * The Images selection bar's "Set category" (#249 D8). Images have no category of their own: the listing's category - * filter matches the linked positive prompt's, so the action sets the category on those prompts. + * The Images selection bar's "Set category" (#332, D140; the concept's bulk-category → simpleInput). Apply sets each + * selected image's own category (`PUT /prompt_manager/images/category`); "Use prompt category" removes it after a + * confirmation (`DELETE`), so the image shows its prompt's category again. It never writes a prompt: prompt + * categories are set in the Prompt library editor and on the legacy admin page's "Set Category". */ +export const CATEGORY_URL = '/prompt_manager/images/category'; +export const IMAGE_CATEGORIES_URL = '/prompt_manager/images/categories'; export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'}); -export const CATEGORY_NONE_LINKED = "None of the selected images is linked to a saved prompt, so there is no prompt category to set."; +export const USE_PROMPT_CATEGORY = 'Use prompt category'; +export const REMOVE_DIALOG = Object.freeze({title:'Use prompt category', action:'Remove'}); export const CATEGORY_FAILED = 'Category could not be set. Try again.'; +export const NONE_FOUND = 'None of the selected images could be found. Refresh the images and try again.'; +export const NONE_HAD_OWN = 'None of the selected images had its own category.'; +export const SOURCES_CHANGED = 'The sources changed. Refresh the images and try again.'; // The server's limit (`validate_category`): the field stops there instead of failing on Apply. export const CATEGORY_MAX_LENGTH = 100; +// One request names at most 500 images (DC2), as "Add tags" does. +export const MAX_CATEGORY_IMAGES = 500; -function linkedPositive(image) { - const link = image?.linked; - if (link?.status !== 'linked') return null; - return Number.isSafeInteger(link.positive_id) && link.positive_id > 0 ? link.positive_id : null; +/** The success toast of a set: unchanged files count as set; files no source lists are named after. */ +export function setOutcome(result, category) { + const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0; + const missing = Number(result?.not_found) || 0; + const done = `Category “${category}” set on ${countOf(updated + unchanged, 'image')}.`; + return missing ? `${done} ${missing.toLocaleString()} could not be found.` : done; } -/** The distinct linked positives of `images`, first-seen order; `skipped` counts the images without one. */ -export function categoryTargets(images) { - const ids = new Set(); - let skipped = 0; - for (const image of images) { - const id = linkedPositive(image); - if (id === null) skipped += 1; - else ids.add(id); +/** The toast of a removal. */ +export function clearOutcome(result) { + const cleared = Number(result?.cleared) || 0; + return cleared ? `Removed the image category from ${countOf(cleared, 'image')}.` : NONE_HAD_OWN; +} + +/** The confirmation sentence before "Use prompt category" removes the own category of `count` images. */ +export function removeQuestion(count) { + const which = count === 1 ? 'the 1 selected image' : `the ${count.toLocaleString()} selected images`; + return `Remove the image category from ${which}? Each one then shows its prompt's category, or none if it has no ` + + 'linked prompt. This cannot be undone.'; +} + +/** The toast for a failed write: the coded answers the routes give, else the fixed failure. */ +export function failureMessage(error) { + if (error?.code === 'files-not-found') return NONE_FOUND; + if (error?.code === 'source-changed') return SOURCES_CHANGED; + if (error?.code === 'too-many-images' && typeof error.message === 'string' && error.message) return error.message; + return CATEGORY_FAILED; +} + +/** The toolbar's category list: the prompt and recipe categories plus the listed images' own ones, each once. */ +export function mergeCategories(promptCategories, imageCategories) { + const merged = []; + const seen = new Set(); + for (const value of [...(promptCategories ?? []), ...(imageCategories ?? [])]) { + if (typeof value !== 'string' || !value.trim() || seen.has(value)) continue; + seen.add(value); + merged.push(value); } - return {promptIds:[...ids], skipped}; -} - -/** The success toast: the prompts changed, then any selected images that had no linked prompt. */ -export function categoryOutcome(prompts, skipped) { - const done = `Category set on ${countOf(prompts, 'prompt')}.`; - if (!skipped) return done; - return `${done} ${countOf(skipped, 'selected image')} had no linked prompt and ${noun(skipped, 'was', 'were')} skipped.`; -} - -/** The prompts the server reports changed; without a count, the number of prompts posted. */ -export function updatedCount(result, posted) { - const count = result?.updated_count; - return Number.isSafeInteger(count) && count >= 0 ? count : posted; + return merged; } diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index ee5cd69c..c930f916 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -2,16 +2,16 @@ import { createButton } from '../shared/toolkit/core.js'; import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs'; 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 { MAX_FILTER_IMAGES, TOO_MANY_MATCHING, openAddTags, openAutoTag } from './images-tagging-bulk.mjs'; +import { CATEGORY_DIALOG, CATEGORY_MAX_LENGTH, CATEGORY_URL, MAX_CATEGORY_IMAGES, REMOVE_DIALOG, USE_PROMPT_CATEGORY, + clearOutcome, failureMessage, removeQuestion, setOutcome } from './images-category.mjs'; +import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './workspace-dialogs.mjs'; +import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-tagging-bulk.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs'; const CHECKBOX = '.image-overlays input[type="checkbox"]'; const CHECKBOX_LABEL = '.image-overlays > label'; const CARD = 'article[data-image-key]'; -const CATEGORY_URL = '/prompt_manager/bulk/category'; // D135: Export metadata reads every image's inspection in the browser, so it waits for loaded images. export const EXPORT_LOADED_ONLY = 'Export metadata reads each image in the browser, so it works on loaded images only.'; @@ -29,8 +29,9 @@ function el(tag, text, className) { * checkbox from `isChecked`; this module follows the boxes through the grid's `change` events. * * - `getItems()` is the listing in its order; `checked` restores a saved visit's selection. - * - `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. + * - `onCategorySet()` reloads the categories and the listing after a successful Set category or Use prompt category + * (the reload clears the selection through `clear`); focus then moves to `focusFallback` if it was on the hidden + * action. `getCategories()` gives the toolbar's categories, the field's suggestions. * - `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. @@ -43,7 +44,7 @@ function el(tag, text, className) { * matching", whose actions send that filter to the server. */ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged, - getListing, getFilter, checked: initial = Object.freeze(new Set()) }) { + getListing, getFilter, getCategories = () => [], checked: initial = Object.freeze(new Set()) }) { let checked = initial; let exporting = false, categorizing = false; // D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox. @@ -261,41 +262,59 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o timer = setTimeout(release, 1000); } /** - * Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's - * distinct linked positives, taken when the (modal) dialog opens. With no linked image there is nothing to change: - * a toast says so and no dialog opens. + * Set category (app.js bulk-category → simpleInput, #332 D140): Apply sets each selected image's own category, linked + * or not; "Use prompt category" removes it after a confirmation that states the count. Up to 500 images (DC2): an + * explicit selection or "all N matching" (the listing filter, resolved by the server); more opens nothing. */ function openCategory(button) { if (categorizing) return; - // All matching (D135): the listing's positive count says whether any match is linked; the server resolves. - if (allMatching && getListing().total > MAX_FILTER_IMAGES) { showWorkspaceToast(TOO_MANY_MATCHING); return; } - const targets = allMatching ? {filter: getFilter()} : categoryTargets(checkedImages()); - const linked = allMatching ? getListing().positiveCount !== 0 : targets.promptIds.length > 0; - if (!linked) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; } + const count = allMatching ? getListing().total : checked.size; + const targets = allMatching ? {filter: getFilter()} : {items: bulkTargets(checkedImages())}; + if (count > MAX_CATEGORY_IMAGES || (!allMatching && !targets.items)) { showWorkspaceToast(TOO_MANY); return; } openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label, - action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, onApply: category => applyCategory(button, targets, category) }); + action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, suggestions: getCategories(), + secondary: { label: USE_PROMPT_CATEGORY, onClick: () => confirmRemove(button, targets, count) }, + onApply: category => writeCategory(button, () => api.put(CATEGORY_URL, {...targets, category}), + result => setOutcome(result, category)) }); + } + /** "Use prompt category": nothing is sent until Remove; Cancel leaves every category as it was. */ + function confirmRemove(button, targets, count) { + const text = el('p', removeQuestion(count)); + const actions = el('div', undefined, 'dialog-actions'); + const cancel = createButton('Cancel'), remove = createButton(REMOVE_DIALOG.action, 'danger'); + actions.append(cancel, remove); + cancel.addEventListener('click', () => closeWorkspaceDialog()); + remove.addEventListener('click', () => { + closeWorkspaceDialog(); + writeCategory(button, () => api.del(CATEGORY_URL, {body: targets}), clearOutcome); + }); + openContentDialog({ scope, trigger: button, title: REMOVE_DIALOG.title, nodes: [text, actions] }); + cancel.focus(); } /** * The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page - * not to save a visit that predates it). A failure keeps the selection; a success reloads through `onCategorySet`. + * not to save a visit that predates it). A failure keeps the selection (a changed source reloads the listing); a + * success reloads through `onCategorySet`. */ - async function applyCategory(button, { promptIds, skipped, filter }, category) { + async function writeCategory(button, send, outcome) { categorizing = true; const release = busy(button); - let result = null; + let result = null, failure = null; try { - result = await api.post(CATEGORY_URL, filter ? { images_filter: filter, category } : { prompt_ids: promptIds, category }); - } catch { - // Reported below; the selection stays as it was. + result = await send(); + } catch (error) { + failure = error; } finally { categorizing = false; release(); } if (scope.signal.aborted) return; - if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; } - if (filter && !result.prompt_count) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; } - showWorkspaceToast(filter ? categoryOutcome(updatedCount(result, result.prompt_count), Number(result.skipped) || 0) - : categoryOutcome(updatedCount(result, promptIds.length), skipped)); + if (failure !== null || !result) { + showWorkspaceToast(failureMessage(failure)); + if (failure?.code === 'source-changed') onCategorySet(); + return; + } + showWorkspaceToast(outcome(result)); const focused = document.activeElement === button; onCategorySet(); // The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading. diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 2b45744d..57ea4091 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -16,6 +16,7 @@ import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openRefineDialog, resc import { openMetadataScan } from './activity-scan.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { imagesFilters, imagesViewState } from './saved-views.mjs'; +import { IMAGE_CATEGORIES_URL, mergeCategories } from './images-category.mjs'; import { displayPreferences } from './preferences.mjs'; import { onImageTagsSaved } from './image-tagging.mjs'; import { followImageTagSaves, openCardTagging } from './images-tagging.mjs'; @@ -81,6 +82,17 @@ setFavoriteVisitTarget((key, favorite) => { // A tag save made while Images is left (Tags › Auto-tag images) may change what a filtered visit lists: it reloads (M6). onImageTagsSaved(() => { if (lastVisit?.filters.tags.length || lastVisit?.filters.q) lastVisit = {...lastVisit, stale: true}; }); +// The filter state a Tags & categories Browse hands to the next Images mount (#332 MC10). +let browseState = null; +/** + * Open Images filtered by `category` only (every other filter at its default), as Browse opens the Library: Tags & + * categories' Browse of a category only images use (#332 MC10, D143). + */ +export function browseImages({category} = {}) { + if (typeof category !== 'string' || !category) throw new TypeError('browseImages needs a category'); + browseState = {category}; + window.location.hash = '#/images'; +} /** Forget the last Images visit (its listing, scroll and selection): the next visit reads everything again (S9). */ export function forgetImagesVisit() { lastVisit = null; @@ -113,7 +125,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next))); scope.cleanup(() => inspection.dispose()); scope.cleanup(() => detailInspector?.dispose()); - const saved = embedded || !savedState ? null : imagesFilters(savedState); + // A Tags & categories Browse (browseImages) is handed to this mount once, as a saved view's state is. + const handed = !embedded && !favorites && !savedState ? browseState : null; + if (!embedded && !favorites) browseState = null; + const saved = embedded || !(savedState || handed) ? null : imagesFilters(savedState || handed); const prior = embedded || saved || favorites ? null : lastVisit; // A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6). const visit = prior?.stale ? null : prior; @@ -126,6 +141,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false; // Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip. let roots = [], folders = []; + // The toolbar's current category list (loadCategories), also the Set category suggestions. + let categoryNames = []; // The open "Refine this view", so a discovery that settles after it opened still lists its sources and folders. let refine = null; const rendered = new Map(); @@ -256,6 +273,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none. const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback, checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys), + // The Set category field suggests the toolbar's categories (#332 MC13). + getCategories:() => categoryNames, // #392 "Select all N matching": the listing's total and its filter, as the server lists them. getListing:() => ({total:state?.total ?? 0, hasMore:Boolean(state?.hasMore), positiveCount:state?.positiveCount ?? null}), // A Source filter carries its root's scope, so a renumbered source is refused (source-changed), not acted on. @@ -712,24 +731,27 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage return false; } } - /** The toolbar's categories are the Library's (the linked positive's category, D13). */ + /** + * The toolbar's categories: the Library's (prompts and recipes) and the listed images' own ones (D141, #332), read + * in parallel. If only the image list fails, the prompt categories show and the categories alert is raised. + */ async function loadCategories() { if (!toolbar) return; categoryRequest?.abort(); const request = new AbortController(); categoryRequest = request; - try { - const data = await api.get('/prompt_manager/library/options', {signal:request.signal}); - if (scope.signal.aborted || request !== categoryRequest) return; - if (!Array.isArray(data?.categories)) throw new TypeError('Invalid categories'); - // D36: alphabetical (case-insensitive) whatever order the server sends, a restored category among them. - const names = new Set([...data.categories.filter(value => typeof value === 'string' && value), filters.category]); - names.delete(''); - toolbar.setCategories([...names].sort((a, b) => a.localeCompare(b, 'en-US', {sensitivity:'base'}) || (a < b ? -1 : a > b ? 1 : 0))); - categoriesFailed = false; - } catch { - if (scope.signal.aborted || request !== categoryRequest) return; - categoriesFailed = true; - } + const [library, images] = await Promise.allSettled([ + api.get('/prompt_manager/library/options', {signal:request.signal}), + api.get(IMAGE_CATEGORIES_URL, {signal:request.signal}), + ]); + if (scope.signal.aborted || request !== categoryRequest) return; + const valid = result => result.status === 'fulfilled' && Array.isArray(result.value?.categories); + if (!valid(library)) { categoriesFailed = true; return; } + // D36: alphabetical (case-insensitive) whatever order the server sends, a restored category among them. + const names = new Set(mergeCategories(library.value.categories, valid(images) ? images.value.categories : [])); + if (filters.category) names.add(filters.category); + categoryNames = [...names].sort((a, b) => a.localeCompare(b, 'en-US', {sensitivity:'base'}) || (a < b ? -1 : a > b ? 1 : 0)); + toolbar.setCategories(categoryNames); + categoriesFailed = !valid(images); } scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); }); updateScope(); diff --git a/web/js/workspace/vocabulary-state.mjs b/web/js/workspace/vocabulary-state.mjs index 18447f44..c86f0b30 100644 --- a/web/js/workspace/vocabulary-state.mjs +++ b/web/js/workspace/vocabulary-state.mjs @@ -254,6 +254,23 @@ export function browseFilters(kind, value) { : {text: '', category: value, tags: [], sort: 'newest'}; } +/** + * A category row's count (#332 D143): "N positive prompts" when no image holds it as its own category, "N positive + * prompts · M images" when both do, "M images" when only images do (stored rows, files on disk or not). + */ +export function categoryRowText(category) { + const prompts = count(category?.prompt_count) ? category.prompt_count : 0; + const images = count(category?.image_count) ? category.image_count : 0; + if (!images) return countOf(prompts, 'positive prompt'); + if (!prompts) return countOf(images, 'image'); + return `${countOf(prompts, 'positive prompt')} · ${countOf(images, 'image')}`; +} + +/** Where a category's Browse opens: the Library while prompts hold it, else Images (MC10, its Library would be empty). */ +export function categoryBrowseTarget(category) { + return count(category?.prompt_count) && category.prompt_count > 0 ? 'library' : 'images'; +} + /** The exact tag's current owner counts, for the delete confirmation. Throws on any unconfirmed shape. */ export function readTagImpact(api, row, {signal} = {}) { requireRow(row); diff --git a/web/js/workspace/vocabulary.mjs b/web/js/workspace/vocabulary.mjs index a1d1cc7d..f5c32b54 100644 --- a/web/js/workspace/vocabulary.mjs +++ b/web/js/workspace/vocabulary.mjs @@ -9,12 +9,11 @@ * constraint to the existing Library filters. */ import { createButton } from '../shared/toolkit/core.js'; -import { browseFilters, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs'; -import { countOf } from './plural.mjs'; +import { browseFilters, categoryBrowseTarget, categoryRowText, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs'; import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs'; import { createVocabularyDialog } from './vocabulary-dialog.mjs'; import { pageHeading } from './heading.mjs'; -import { selectedImage } from './images.mjs'; +import { browseImages, selectedImage } from './images.mjs'; import { onImageTagsSaved, openImageTagging } from './image-tagging.mjs'; import { showWorkspaceToast } from './toast.mjs'; @@ -64,7 +63,7 @@ function tagRow(tag) { } function categoryRow(category) { - const {row, button} = operation(category.value, countOf(category.prompt_count, 'positive prompt'), 'Browse'); + const {row, button} = operation(category.value, categoryRowText(category), 'Browse'); button.dataset.action = 'browse'; return row; } @@ -211,7 +210,10 @@ export function mountVocabulary(main, api, scope) { scope.listen(categoryRows, 'click', event => { const button = event.target.closest('button[data-action="browse"]'); const category = button && categories.itemOf(button.closest('.operation')); - if (category) browseLibrary(browseFilters('categories', category.value)); + if (!category) return; + // MC10: a category only images hold opens Images filtered by it; the Library would be empty. + if (categoryBrowseTarget(category) === 'images') browseImages({category: category.value}); + else browseLibrary(browseFilters('categories', category.value)); }); let settled = Promise.resolve(); diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 257f3fba..fff32920 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -316,18 +316,29 @@ export function openCopyFallback(text, { scope, trigger = null, title = 'Copy pr area.select(); } +export const INPUT_SUGGESTIONS_ID = 'workspace-input-suggestions'; + /** * The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary * `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or * reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field. * `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field - * reports the message (its native validity) until it is edited. + * reports the message (its native validity) until it is edited. `suggestions` (text list) offers values in a + * `` under the field; `secondary` (`{label, onClick}`) adds a button between Cancel and the action that + * closes the dialog and runs `onClick` (#332). */ export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null, - validate = null }) { + validate = null, suggestions = null, secondary = null }) { requireScope(scope); requireCallbacks({ onApply }); if (validate !== null) requireCallbacks({ validate }); + if (suggestions !== null && (!Array.isArray(suggestions) || suggestions.some(item => typeof item !== 'string'))) { + throw new TypeError('The input dialog suggestions must be text'); + } + if (secondary !== null && (typeof secondary?.label !== 'string' || !secondary.label)) { + throw new TypeError('The input dialog secondary action needs a label'); + } + if (secondary !== null) requireCallbacks({ onClick: secondary.onClick }); for (const [name, text] of Object.entries({ title, label, action })) { if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`); } @@ -355,8 +366,25 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe onApply(entered); }); const actions = el('div', undefined, 'dialog-actions'); - actions.append(cancel, apply); - show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input }); + actions.append(cancel); + if (secondary !== null) { + // Between Cancel and the primary action; it closes the dialog first (#332 "Use prompt category"). + const other = createButton(secondary.label); + other.addEventListener('click', () => { finish(); secondary.onClick(); }); + actions.append(other); + } + actions.append(apply); + const nodes = [field(label, input, 'action-input')]; + if (suggestions?.length) { + // Its own id: `datalist#categories` and `#vocabulary-category-options` belong to other pages' fields. + const list = el('datalist'); + list.id = INPUT_SUGGESTIONS_ID; + for (const value of suggestions) { const option = el('option'); option.value = value; list.append(option); } + input.setAttribute('list', INPUT_SUGGESTIONS_ID); + nodes.push(list); + } + nodes.push(actions); + show(title, nodes, { scope, trigger, focusTarget: input }); } /** diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 8d29e3f2..0532e9a5 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -443,3 +443,7 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc outside 1–500 is highlighted as a refused setting is (D92). */ .toolbar #log-limit { inline-size: 6em; min-height: 36px; font-size: 11px; /* D109 */ } .toolbar #log-limit[aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D109 */ } + +/* #332 D146: the category chip names its origin (" · this image", " · prompt", " · recipe") in muted text that never + splits; at 390px the chip may wrap between the category and its origin. */ +.chip .chip-source { color: var(--ca-muted); white-space: nowrap; }