Merge remote-tracking branch 'origin/main' into feat/331-collections-pr2
#332 PR 3 (#428) hands a Tags & categories Browse to the next Images mount; this branch mounts Images as a collection page too. Both imports are kept; a collection page neither restores the last visit nor takes the Browse hand-off (it is not the Images page), and the Images page keeps both the handed category and the collection filter.
This commit is contained in:
@@ -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.';
|
||||
@@ -193,6 +194,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);
|
||||
@@ -268,9 +280,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});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
NONE_FOUND, 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,42 +262,65 @@ 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) { showWorkspaceToast(TOO_MANY); return; }
|
||||
// Checked files the listing no longer holds (a refresh dropped them): nothing to send.
|
||||
if (!allMatching && !targets.items?.length) { showWorkspaceToast(NONE_FOUND); 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, except a changed source, which reloads the
|
||||
* listing as a success does (`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));
|
||||
const focused = document.activeElement === button;
|
||||
if (failure !== null || !result) {
|
||||
showWorkspaceToast(failureMessage(failure));
|
||||
// A changed source reloads the listing (clearing the selection); any other failure keeps it.
|
||||
if (failure?.code !== 'source-changed') return;
|
||||
onCategorySet();
|
||||
if (focused) focusFallback.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
showWorkspaceToast(outcome(result));
|
||||
onCategorySet();
|
||||
// The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading.
|
||||
if (focused) focusFallback.focus({ preventScroll: true });
|
||||
|
||||
+40
-15
@@ -17,6 +17,7 @@ import { openMetadataScan } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
import { VIEW_COLLECTION_DELETED, isCollectionGone } from './collections.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';
|
||||
@@ -84,6 +85,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;
|
||||
@@ -119,10 +131,15 @@ 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());
|
||||
// A Tags & categories Browse (browseImages) is handed to this mount once: a fresh Images visit filtered by its
|
||||
// category, which becomes the Images return state like any plain visit (as Browse does for the Library).
|
||||
const imagesPage = !embedded && !favorites && collectionId === null;
|
||||
const handed = imagesPage && !savedState ? browseState : null;
|
||||
if (imagesPage) browseState = null;
|
||||
const saved = embedded || !savedState ? null : imagesFilters(savedState);
|
||||
// The open collection: the route's, else a saved view's (D140); '' on every other page.
|
||||
const openCollection = embedded ? '' : collectionId !== null ? String(collectionId) : saved?.collection || '';
|
||||
const prior = embedded || saved || favorites || openCollection ? null : lastVisit;
|
||||
const prior = embedded || saved || favorites || openCollection || handed ? null : lastVisit;
|
||||
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
|
||||
const visit = prior?.stale ? null : prior;
|
||||
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
|
||||
@@ -134,11 +151,14 @@ 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();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved,
|
||||
...(handed ? {category: handed.category} : {}),
|
||||
// A collection and favorites are never both the filter (DC10).
|
||||
favorites: !openCollection && (favorites || saved?.favorites === true), collection: openCollection };
|
||||
filters.tags = [...filters.tags];
|
||||
@@ -265,6 +285,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.
|
||||
@@ -737,24 +759,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
* `<datalist>` 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -449,3 +449,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; }
|
||||
|
||||
Reference in New Issue
Block a user