Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows-pr2

# Conflicts:
#	TESTING.md
This commit is contained in:
Vito Sansevero
2026-10-28 15:34:09 -07:00
92 changed files with 6632 additions and 429 deletions
+314
View File
@@ -0,0 +1,314 @@
/**
* Collections (#331): named, hand-picked groups of images, persisted through `/prompt_manager/collections*`.
*
* - D136: the sidebar COLLECTIONS group (the concept's `#collections-nav`, index.html 28–31, app.js 336–339) lists
* each collection as `#/collection/<id>` with its listed-member count, without the concept's "PROPOSED" badge.
* - D137: a collection page is Images filtered to the collection; its Edit renames or deletes it through the saved
* views' Edit dialog (D51) with collection nouns.
* - This module holds the client store (`createCollections`), the route hash, the name rule and every user-facing
* string of the design (§7.4, §8), so the selection bar, the inspector and the shell say the same things.
*/
import { noun } from './plural.mjs';
import { openEditDialog } from './saved-views.mjs';
export const COLLECTIONS_URL = '/prompt_manager/collections';
export const COLLECTION_ITEMS_URL = `${COLLECTIONS_URL}/items`;
export const COLLECTION_MEMBERSHIP_URL = `${COLLECTIONS_URL}/membership`;
export const MAX_COLLECTION_NAME_LENGTH = 100;
// The routes' cap on one add or remove (the bulk tagging cap, MAX_BULK_IMAGES).
export const MAX_COLLECTION_ITEMS = 500;
// Counts are re-read once after a run of confirmed changes settles, as the Favorites count (#249 M1).
export const COLLECTION_COUNT_DELAY_MS = 300;
// Strings (design §7.4 and §8).
export const ADD_DIALOG_TITLE = 'Add selected images to collection';
export const ADD_DIALOG_LABEL = 'Collection name';
export const ADD_DIALOG_ACTION = 'Add';
export const TOO_MANY_TOAST = `Select at most ${MAX_COLLECTION_ITEMS} images.`;
export const FILES_NOT_FOUND_TOAST = 'None of the selected images could be found. Refresh the images and try again.';
export const SOURCE_CHANGED_TOAST = 'The sources changed. Refresh the images and try again.';
export const ADD_FAILED_TOAST = 'The images could not be added. Try again.';
export const REMOVE_FAILED_TOAST = 'The images could not be removed. Try again.';
export const COLLECTION_GONE_TOAST = 'That collection no longer exists.';
export const RENAMED_TOAST = 'Collection renamed.';
export const RENAME_FAILED_TOAST = 'The collection could not be renamed. Try again.';
export const DELETED_TOAST = 'Collection deleted.';
export const DELETE_FAILED_TOAST = 'The collection could not be deleted. Try again.';
export const DELETED_PAGE_TEXT = 'This collection no longer exists.';
export const OPEN_IMAGES_LINK = 'Open Images';
export const VIEW_COLLECTION_DELETED = 'The collection this view uses was deleted.';
export const LIST_FAILED_TEXT = 'Collections could not be loaded. Your saved data has not changed.';
export const COLLECTIONS_UPDATED_TOAST = 'Collections updated.';
export const UPDATE_FAILED_TEXT = 'The collections could not be updated. Try again.';
export const MEMBERSHIP_LOADING_TEXT = 'Loading collections…';
export const MEMBERSHIP_FAILED_TEXT = 'Collections could not be loaded.';
export const NO_COLLECTIONS_TEXT = 'No collections yet. Name one below.';
const CONTROL = /[\u0000-\u001f\u007f]/;
const isCount = value => Number.isSafeInteger(value) && value >= 0;
const isId = value => Number.isSafeInteger(value) && value >= 1 && value <= 999_999_999;
/** The route of one collection. */
export function collectionHash(id) {
if (!isId(id)) throw new TypeError('Invalid collection id');
return `#/collection/${id}`;
}
/**
* A name as the database stores it (design §1): trimmed, each run of whitespace one space, 1 to 100 characters
* without control characters; null otherwise. Tabs and newlines are whitespace, so they collapse.
*/
export function normalizeCollectionName(value) {
if (typeof value !== 'string') return null;
const name = value.trim().replace(/\s+/g, ' ');
return name.length >= 1 && name.length <= MAX_COLLECTION_NAME_LENGTH && !CONTROL.test(name) ? name : null;
}
/** One listed collection, or null when the row is not usable. `count` and `missing` are null unless counts. */
export function parseCollection(row) {
if (!isId(row?.id) || typeof row.name !== 'string' || !row.name.trim()) return null;
const counted = isCount(row.count) && isCount(row.missing);
return { id: row.id, name: row.name, count: counted ? row.count : null, missing: counted ? row.missing : null };
}
/** The listing's collections in the server's order (name, then id), skipping unusable rows. */
export function parseCollections(data) {
if (!Array.isArray(data?.collections)) throw new TypeError('Invalid collections');
return data.collections.map(parseCollection).filter(Boolean);
}
/** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */
const nameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC');
const ordered = list => [...list].sort((a, b) => {
const left = nameKey(a.name), right = nameKey(b.name);
return left < right ? -1 : left > right ? 1 : a.id - b.id;
});
/** "Added N images to “X”." with what was already in it and what could not be found. */
export function addedMessage({ collection, added, already, notFound }) {
if (added === 0 && notFound === 0) return `All selected images were already in “${collection.name}”.`;
let text = `Added ${added} ${noun(added, 'image')} to “${collection.name}”.`;
if (already > 0) text += already === 1 ? ' 1 was already in it.' : ` ${already} were already in it.`;
if (notFound > 0) text += ` ${notFound} could not be found.`;
return text;
}
export function removedMessage(name, removed) {
return `Removed ${removed} ${noun(removed, 'image')} from “${name}”.`;
}
export function nameTakenMessage(name) {
return `A collection named “${name}” already exists.`;
}
/** The Delete confirmation, with the stored count (listed plus missing) when the counts are known. */
export function deleteQuestion({ name, count, missing }) {
if (!isCount(count) || !isCount(missing)) {
return `Delete “${name}”? Its images stay where they are; only the collection is removed.`;
}
const stored = count + missing;
return stored === 1
? `Delete “${name}”? Its 1 image stays where it is; only the collection is removed.`
: `Delete “${name}”? Its ${stored} images stay where they are; only the collection is removed.`;
}
/** The collection page's note about members its sources do not list now (MC3); empty when there are none. */
export function missingNote(missing) {
if (!isCount(missing) || missing === 0) return '';
return missing === 1
? '1 image in this collection is not in your current sources.'
: `${missing} images in this collection are not in your current sources.`;
}
export const isCollectionGone = error => error?.code === 'collection-not-found';
/** A failed add or remove's toast: the routes' error codes, else `fallback`. */
export function writeFailure(error, fallback) {
if (isCollectionGone(error)) return COLLECTION_GONE_TOAST;
if (error?.code === 'source-changed') return SOURCE_CHANGED_TOAST;
if (error?.code === 'files-not-found') return FILES_NOT_FOUND_TOAST;
return fallback;
}
/** The Edit dialog's mapping of a failed rename of `name` (openEditDialog `renameError`). */
export function renameFailure(name) {
return error => {
if (error?.code === 'name-taken') return { field: nameTakenMessage(name) };
if (isCollectionGone(error)) return { toast: COLLECTION_GONE_TOAST, close: true };
return { toast: RENAME_FAILED_TOAST };
};
}
/** The collection nouns of the shared Edit dialog (D51, D137). */
export const COLLECTION_NOUNS = Object.freeze({
title: 'Edit collection', label: 'Collection name', deleteTitle: 'Delete collection', deleteText: deleteQuestion,
renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST,
});
/**
* D137: the open collection's Edit. A taken name stays in the dialog under the field; a failed rename keeps it open;
* a collection deleted meanwhile closes it and runs `onGone()` after the toast. `onRename(name)` sends the normalized
* name; a delete's 404 counts as deleted (the store's `delete`).
*/
export function openEditCollectionDialog({ scope, trigger, collection, onRename, onDelete, onGone = () => {} }) {
let attempted = '';
const mapping = error => {
const outcome = renameFailure(attempted)(error);
if (outcome.close) queueMicrotask(onGone);
return outcome;
};
openEditDialog({ scope, trigger, item: collection, nouns: COLLECTION_NOUNS, onDelete, renameError: mapping,
onRename: name => {
attempted = normalizeCollectionName(name) ?? name;
return onRename(attempted);
} });
}
function fileTargets(items) {
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_COLLECTION_ITEMS) {
throw new TypeError('A collection change needs 1 to 500 files');
}
return items.map(item => {
if (!Number.isSafeInteger(item?.root_index) || item.root_index < 0 || typeof item.root_scope !== 'string' ||
!item.root_scope || typeof item.path !== 'string' || !item.path) {
throw new TypeError('Invalid collection file');
}
return { root_index: item.root_index, root_scope: item.root_scope, path: item.path };
});
}
function requireId(id) {
if (!isId(id)) throw new TypeError('Invalid collection id');
return id;
}
function requireName(name) {
const normalized = normalizeCollectionName(name);
if (normalized === null) throw new TypeError('Invalid collection name');
return normalized;
}
/** The `{id, name}` a write answered with. */
function answered(data) {
const collection = parseCollection(data?.collection);
if (!collection) throw new TypeError('Invalid collection');
return { id: collection.id, name: collection.name };
}
function countOf(value) {
if (!isCount(value)) throw new TypeError('Invalid collection count');
return value;
}
/**
* The shell's collection list. `load()` re-reads it (a newer read supersedes an older one), `subscribe(fn)` reports
* `{collections, status}` on every change, `settled()` resolves once no read is in flight; `status` is 'loading',
* 'ready' or 'error' and a failed read keeps the last list. `add`, `remove`, `rename` and `delete` change the listed
* rows at once and re-read the counts once, 300 ms after the last confirmed change; a write to a collection that is
* gone (404 `collection-not-found`) re-reads the list at once and rejects. `timers` is a test seam.
*/
export function createCollections(api, signal, { setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
let collections = [], status = 'loading', request = null, timer = 0;
const listeners = new Set();
let waiting = [];
const emit = () => {
const snapshot = { collections, status };
for (const listener of listeners) listener(snapshot);
if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); }
};
const stopTimer = () => { if (timer) clearTimer(timer); timer = 0; };
signal?.addEventListener('abort', () => { stopTimer(); request?.abort(); }, { once: true });
function load() {
if (signal?.aborted) return Promise.resolve();
request?.abort();
const controller = new AbortController();
request = controller;
status = 'loading';
emit();
return api.get(COLLECTIONS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => {
if (request !== controller) return;
collections = parseCollections(data);
status = 'ready';
}).catch(() => {
if (request !== controller) return;
status = 'error';
}).finally(() => {
if (request !== controller || signal?.aborted) return;
request = null;
emit();
});
}
function changed(next) {
collections = ordered(next);
emit();
stopTimer();
timer = setTimer(() => { timer = 0; load(); }, COLLECTION_COUNT_DELAY_MS);
}
/** Send one write; a collection found gone meanwhile re-reads the list before the error reaches the caller. */
async function send(write) {
try {
return await write();
} catch (error) {
if (isCollectionGone(error)) load();
throw error;
}
}
async function add(target, items) {
const byId = target?.collectionId !== undefined, byName = target?.name !== undefined;
if (byId === byName) throw new TypeError('A collection is named by its id or a name');
const body = byId ? { collection_id: requireId(target.collectionId) } : { name: requireName(target.name) };
body.items = fileTargets(items);
const data = await send(() => api.post(COLLECTION_ITEMS_URL, body, { signal }));
const result = { collection: answered(data), created: data.created === true, added: countOf(data.added),
already: countOf(data.already), notFound: countOf(data.not_found) };
const { id, name } = result.collection;
changed(collections.some(item => item.id === id)
? collections.map(item => item.id === id ? { ...item, name } : item)
: [...collections, { id, name, count: null, missing: null }]);
return result;
}
async function remove(id, items) {
const body = { collection_id: requireId(id), items: fileTargets(items) };
const data = await send(() => api.del(COLLECTION_ITEMS_URL, { signal, body }));
const result = { collection: answered(data), removed: countOf(data.removed) };
changed(collections);
return result;
}
async function membership(identity) {
const [file] = fileTargets([identity]);
const query = new URLSearchParams({ root_index: String(file.root_index), root_scope: file.root_scope, path: file.path });
const data = await api.get(`${COLLECTION_MEMBERSHIP_URL}?${query}`, { signal, cache: 'no-store' });
const ids = data?.collection_ids;
if (!Array.isArray(ids) || !ids.every(isId)) throw new TypeError('Invalid collection membership');
return [...ids];
}
async function rename(id, name) {
const url = `${COLLECTIONS_URL}/${requireId(id)}`;
const body = { name: requireName(name) };
const renamed = answered(await send(() => api.patch(url, body, { signal })));
changed(collections.map(item => item.id === renamed.id ? { ...item, name: renamed.name } : item));
return renamed;
}
async function deleteCollection(id) {
const url = `${COLLECTIONS_URL}/${requireId(id)}`;
try {
await api.del(url, { signal });
} catch (error) {
// Already gone (another tab deleted it): the outcome the user asked for.
if (error?.status !== 404) throw error;
}
changed(collections.filter(item => item.id !== id));
return id;
}
return {
load, add, remove, membership, rename, delete: deleteCollection,
get collections() { return collections; },
get status() { return status; },
find: id => collections.find(item => item.id === id) ?? null,
settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ collections, status }),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
+14 -4
View File
@@ -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});
+35 -4
View File
@@ -203,17 +203,48 @@ async function readDefaults(api, signal) {
}
export const UNAVAILABLE_HINT = 'Download it in System › Auto-tagging.';
const nonEmpty = value => typeof value === 'string' && value.trim() !== '';
const joined = names => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join(''));
/** The pip names a `/autotag/models` entry says its backend cannot import (#424); only non-empty strings count. */
export const missingPackages = entry => (object(entry) && Array.isArray(entry.missing_packages)
? entry.missing_packages.filter(nonEmpty) : []);
/** `pip install …` for `entry`'s missing packages: the server's command, else built from the names; '' when none. */
export function installCommand(entry) {
const missing = missingPackages(entry);
if (!missing.length) return '';
return nonEmpty(entry.install_command) ? entry.install_command : `pip install ${missing.join(' ')}`;
}
/**
* The server's missing-package sentence (`missing_packages_message`, #424) for `name`, else '' (nothing missing):
* "<name> needs llama-cpp-python. Install it into ComfyUI's Python: pip install llama-cpp-python. <note>".
*/
export function needsPackages(name, entry) {
const missing = missingPackages(entry);
if (!missing.length) return '';
const note = nonEmpty(entry.install_note) ? ` ${entry.install_note}` : '';
return `${name} needs ${joined(missing)}. Install ${missing.length === 1 ? 'it' : 'them'} into ComfyUI's Python: `
+ `${installCommand(entry)}.${note}`;
}
/** "a, b and c": the missing package names as the sentences list them. */
export const packageList = entry => joined(missingPackages(entry));
/**
* Why `model` cannot run, from the `/autotag/models` status (the server's own `model-unavailable` sentence), else ''
* (downloaded, or the status is unknown: the job then reports a missing model itself).
* (ready, or the status is unknown: the job then reports a missing model itself). Not downloaded is named first, then
* a backend package that cannot be imported (#424).
*/
export function modelUnavailable(models, model) {
const option = modelOf(model);
requireValue(option, 'Unknown tagging model');
const status = object(models) && object(models[model]) ? models[model] : null;
if (!status || status.downloaded !== false) return '';
const name = typeof status.name === 'string' && status.name.trim() ? status.name : option.label.split(' · ')[0];
return `${name} is not downloaded. ${UNAVAILABLE_HINT}`;
if (!status) return '';
const fallback = option.label.split(' · ')[0];
if (status.downloaded === false) return `${nonEmpty(status.name) ? status.name : fallback} is not downloaded. ${UNAVAILABLE_HINT}`;
return needsPackages(nonEmpty(status.label) ? status.label : fallback.replace(' (Recommended)', ''), status);
}
function initialState(source, preset) {
+50 -30
View File
@@ -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;
}
+50 -26
View File
@@ -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 });
+8
View File
@@ -53,6 +53,14 @@ function queryFor(filters, batchSize) {
const favorites = filters.favorites ?? false;
if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter');
if (favorites) query.set('favorites', '1');
// A collection page (#331 D137): the listing keeps only that collection's members; never together with favorites.
const collection = filters.collection ?? '';
if (collection !== '') {
if (favorites || !/^[1-9]\d{0,8}$/.test(typeof collection === 'number' ? String(collection) : collection)) {
throw new TypeError('Invalid collection filter');
}
query.set('collection', String(collection));
}
return query.toString();
}
+73 -23
View File
@@ -16,6 +16,8 @@ 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 { 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';
@@ -63,6 +65,8 @@ const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name',
['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]);
const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]);
const RETRY_RESCAN = 'Rescan from Import to retry.';
// The concept's empty collection (app.js updateImages).
const NO_MATCHES = 'No matching images. Try another search or category.';
const RETRY_REFRESH = 'Refresh image collection to retry.';
/** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */
function loadFailure(sourcesRetry, categoriesFailed) {
@@ -81,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;
@@ -96,10 +111,13 @@ export function selectedImage() {
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
* `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view.
* `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the
* Images last visit.
* Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is
* off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls
* `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a
* deleted collection, D140) the page lists nothing and says so.
*/
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
favorites = false, onSaveView = () => {}, activity = null} = {}) {
favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null} = {}) {
const scope = createScope();
const dispose = () => scope.dispose();
parentScope.signal.addEventListener('abort', dispose, {once:true});
@@ -113,8 +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);
const prior = embedded || saved || favorites ? null : lastVisit;
// 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 || 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
@@ -126,12 +151,16 @@ 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,
favorites: favorites || saved?.favorites === true };
...(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];
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
@@ -142,7 +171,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
// Images return state (#249 D51, and M2 for saved views).
if (!embedded && !filters.favorites && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
@@ -237,7 +266,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards.
// An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620).
const showsEmpty = !embedded || typeof emptyText === 'string';
const empty = el('div', embedded ? emptyText : 'No matching images. Try another search or category.', 'empty');
const empty = el('div', embedded ? emptyText : NO_MATCHES, 'empty');
empty.hidden = true;
// D47: the history page's shared gallery and dialogs are named by their own heading or title, so they have no caption.
if (!embedded) collection.append(caption);
@@ -256,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.
@@ -457,8 +488,16 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); });
});
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
/**
* The listing refused the open collection: it was deleted. A saved view says so in place (MC6, D140); a collection
* page reports it (`onCollectionMissing`) once per failed read and meanwhile shows the listing's error and Retry,
* so a page whose shell cannot confirm the deletion is never left blank.
*/
const collectionGone = next => Boolean(filters.collection) && !onCollectionMissing && isCollectionGone(next.error);
let reportedMissing = null;
function continuationText(next) {
if (recovering) return 'Images paused until sources and folders are available.';
if (collectionGone(next)) return '';
if (next.error) return 'Images could not be loaded. Retry or rescan the collection.';
if (next.loading && !next.loaded) return 'Loading images…';
if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`;
@@ -492,12 +531,20 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const added = next.loaded - previous.loaded;
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
}
const gone = collectionGone(next);
setText(message, continuationText(next));
setAttr(message, 'role', next.error ? 'alert' : 'status');
retry.hidden = !next.error;
setAttr(message, 'role', next.error && !gone ? 'alert' : 'status');
retry.hidden = !next.error || gone;
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));
if (showsEmpty) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0;
if (onCollectionMissing && filters.collection && isCollectionGone(next.error) && reportedMissing !== next.error) {
reportedMissing = next.error;
queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); });
}
if (showsEmpty) {
setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES);
empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0);
}
if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
if (!selection && next.items.length) inspect(next.items[0]);
scheduleLoad();
@@ -712,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();
+135 -18
View File
@@ -1,6 +1,6 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo,
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
@@ -11,6 +11,8 @@ import { bindCanvasLink, canvasHref } from './topbar.mjs';
import { renderHeading, pageHeading } from './heading.mjs';
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
import { createCollections, collectionHash, openEditCollectionDialog, missingNote, DELETED_PAGE_TEXT, OPEN_IMAGES_LINK,
LIST_FAILED_TEXT } from './collections.mjs';
import { createActivityStore } from './activity-store.mjs';
import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
import { mountActivityJobView } from './activity-job-view.mjs';
@@ -44,6 +46,7 @@ function mountWorkspace() {
const main = document.getElementById('workspace-main');
const primaryNav = document.getElementById('primary-nav');
const savedNav = document.getElementById('saved-nav');
const collectionsNav = document.getElementById('collections-nav');
const systemNav = document.getElementById('system-nav');
scope.listen(document.querySelector('.skip'), 'click', event => {
event.preventDefault();
@@ -83,8 +86,9 @@ function mountWorkspace() {
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
const sidebar = document.querySelector('aside.sidebar');
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`, a collection's `collection/<id>`.
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}`
: route.id === 'collection' ? `collection/${route.collectionId}` : route.id;
let currentKey = null;
const markCurrent = () => {
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
@@ -110,6 +114,24 @@ function mountWorkspace() {
markCurrent();
}));
savedViews.load();
// COLLECTIONS (#331 /* D136 */): the concept's collections-nav rows (folder icon, name, the listed-member count once
// read), without the "PROPOSED" badge. The label shows only while a collection is listed. A failed read keeps the
// last rows; a collection page says the list could not be loaded.
const collectionsLabel = collectionsNav.previousElementSibling;
const collections = createCollections(api, scope.signal);
scope.cleanup(collections.subscribe(({ collections: listed }) => {
collectionsLabel.hidden = listed.length === 0;
collectionsNav.replaceChildren(...listed.map(item => {
const link = element('a');
link.append(createWorkspaceIcon('folder'), document.createTextNode(item.name));
if (Number.isSafeInteger(item.count)) link.append(element('span', item.count.toLocaleString(), 'nav-count'));
link.href = collectionHash(item.id);
link.dataset.route = `collection/${item.id}`;
return link;
}));
markCurrent();
}));
collections.load();
/** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */
const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q,
onSave: name => savedViews.create(page, name, state) });
@@ -167,6 +189,7 @@ function mountWorkspace() {
forgetLibraryVisit();
refreshCounts(NAV_COUNT_ROUTES);
savedViews.load();
collections.load();
if (currentKey !== 'activity') navigateTo(window.location.hash);
}));
// S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back:
@@ -213,6 +236,10 @@ function mountWorkspace() {
openSavedView(route, view);
return;
}
if (route.id === 'collection') {
openCollectionPage(route, view);
return;
}
renderPage(route, view);
}
@@ -223,14 +250,16 @@ function mountWorkspace() {
if (view.signal.aborted) return;
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
await savedViews.load();
// settled(), not load()'s own promise: a newer read (a restore, another change) can supersede this one.
savedViews.load();
await savedViews.settled();
if (view.signal.aborted) return;
}
const saved = savedViews.find(route.savedId);
if (saved) {
const page = routes.find(item => item.id === saved.page);
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
mountEditAction(saved, view);
mountSavedViewEdit(saved, view);
return;
}
if (savedViews.status === 'ready') {
@@ -238,7 +267,8 @@ function mountWorkspace() {
window.location.replace('#/images');
return;
}
const {description} = renderHeading(main, {title:route.label});
const {description, h1} = renderHeading(main, {title:route.label});
focusTitle(h1);
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
description.setAttribute('role', 'alert');
const retry = createButton('Retry');
@@ -249,13 +279,19 @@ function mountWorkspace() {
main.append(retry);
}
// /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and
// retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page.
function mountEditAction(saved, view) {
// /* D51 */ An open sidebar item's Edit (maintainer ruling): first in the heading's actions; `open(trigger)` opens
// its dialog. Saved views and collections (D137) share it.
function mountEditAction(view, open) {
// renderPage has just written the heading.
const edit = createButton('Edit');
pageHeading(main).actions.prepend(edit);
view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved,
view.listen(edit, 'click', () => open(edit));
}
// A rename re-reads the list and retitles the open page; a delete re-reads it and replaces the dead route with the
// view's plain page.
function mountSavedViewEdit(saved, view) {
mountEditAction(view, trigger => openEditViewDialog({ scope: view, trigger, view: saved,
onRename: async name => {
const renamed = await savedViews.rename(saved.id, name);
if (view.signal.aborted) return;
@@ -268,25 +304,106 @@ function mountWorkspace() {
} }));
}
function retitle(name) {
/** `name` becomes the h1; the breadcrumb and title too unless the page keeps its own (a collection's "Collection"). */
function retitle(name, title = name) {
// Only the mounted view retitles (its rename returns once aborted), so its heading is present.
pageHeading(main).h1.textContent = name;
document.getElementById('workspace-breadcrumb').textContent = name;
document.title = `${name} · Prompt Manager`;
document.getElementById('workspace-breadcrumb').textContent = title;
document.title = `${title} · Prompt Manager`;
}
function renderPage(route, view, saved = null) {
/**
* #331 D137: `#/collection/<id>` is Images filtered to the collection, its name the h1, "Collection" the breadcrumb
* and title (concept a1186–1189), with Edit and the note about members the sources do not list now. An unknown id
* is read once more (another tab may have made it), then shows the deleted-collection page.
*/
async function openCollectionPage(route, view) {
main.replaceChildren();
await collections.settled();
if (view.signal.aborted) return;
if (!collections.find(route.collectionId) && collections.status === 'ready') {
// settled(), not load()'s own promise: the 300 ms count read or a restore can supersede this one.
collections.load();
await collections.settled();
if (view.signal.aborted) return;
}
let collection = collections.find(route.collectionId);
const showAgain = () => { if (!view.signal.aborted) navigateTo(window.location.hash); };
if (collection) {
renderPage({...COLLECTION_ROUTE, label:collection.name, title:routeTitle(COLLECTION_ROUTE)}, view, null, {collection:collection.id,
// The listing found the collection gone meanwhile: the list is read again and, once it no longer lists
// the collection, the page is shown again (the deleted-collection page). A failed read, or a list that
// still holds it, leaves the listing's own error and Retry (images.mjs).
onCollectionMissing:async () => {
collections.load();
await collections.settled();
if (collections.status === 'ready' && !collections.find(route.collectionId)) showAgain();
}});
// MC3: the note follows every read of the list for the page's life; none while nothing is missing.
const note = element('p', '', 'small');
const showNote = () => {
const text = missingNote(collections.find(route.collectionId)?.missing);
note.textContent = text;
if (!text) note.remove();
else if (!note.isConnected) pageHeading(main)?.description.after(note);
};
showNote();
view.cleanup(collections.subscribe(showNote));
// The dialog's Delete question uses the latest stored count.
mountEditAction(view, trigger => openEditCollectionDialog({ scope: view, trigger,
collection: collection = {...collection, ...collections.find(collection.id), name:collection.name},
onRename: async name => {
const renamed = await collections.rename(collection.id, name);
if (view.signal.aborted) return;
collection = {...collection, ...renamed};
retitle(renamed.name, routeTitle(COLLECTION_ROUTE));
},
onDelete: async () => {
await collections.delete(collection.id);
window.location.replace('#/images');
},
onGone: () => collections.settled().then(showAgain) }));
return;
}
const {description, h1} = renderHeading(main, {title:route.label});
focusTitle(h1);
document.title = `${routeTitle(route)} · Prompt Manager`;
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
if (collections.status === 'ready') {
// Deleted, or never existed: say so, with the way back to Images.
description.textContent = DELETED_PAGE_TEXT;
const link = element('a', OPEN_IMAGES_LINK, 'workspace-link');
link.href = '#/images';
main.append(link);
return;
}
description.textContent = LIST_FAILED_TEXT;
description.setAttribute('role', 'alert');
const retry = createButton('Retry');
view.listen(retry, 'click', () => {
collections.load();
openCollectionPage(route, view);
});
main.append(retry);
}
/** The concept focuses its page title on a hashchange navigation (app.js 1168), never on the first render. */
function focusTitle(h1) {
if (!firstRender) h1.focus();
firstRender = false;
}
function renderPage(route, view, saved = null, {collection = null, onCollectionMissing = null} = {}) {
document.title = `${routeTitle(route)} · Prompt Manager`;
// The concept's heading(); each view fills its description and actions.
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
const { description, h1 } = renderHeading(main, { title: route.label });
if (!firstRender) h1.focus();
firstRender = false;
focusTitle(h1);
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
if (route.id === 'images' || route.id === 'favorites') {
if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') {
mountNewPromptAction(main, api, view);
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
onSaveView:saveView('images', view), activity});
collection, onCollectionMissing, onSaveView:saveView('images', view), activity});
return;
}
if (route.id === 'library') {
+74 -21
View File
@@ -41,6 +41,8 @@ const FIELDS = Object.freeze({
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
favorites: { fallback: false, accepts: value => typeof value === 'boolean' },
// #331 D140: a view saved on a collection page reopens that collection ('' or 1 to 9 digits, no leading zero).
collection: { fallback: '', accepts: value => typeof value === 'string' && /^(|[1-9][0-9]{0,8})$/.test(value) },
}),
library: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
@@ -54,10 +56,13 @@ export function normalizeViewState(page, state) {
const fields = FIELDS[page];
if (!fields) throw new TypeError(`Unknown saved-view page: ${page}`);
const source = state && typeof state === 'object' && !Array.isArray(state) ? state : {};
return Object.fromEntries(Object.entries(fields).map(([key, field]) => {
const normalized = Object.fromEntries(Object.entries(fields).map(([key, field]) => {
const value = field.accepts(source[key]) ? source[key] : field.fallback;
return [key, Array.isArray(value) ? [...value] : value];
}));
// Favorites and a collection are never both a view's filter: the collection wins, as the listing does (D140).
if (normalized.favorites === true && normalized.collection) normalized.favorites = false;
return normalized;
}
/** True when the state's compact JSON fits the API's 4 KB cap (the server's encoding: no spaces, UTF-8). */
@@ -96,11 +101,12 @@ export const savedViewHash = id => `#/saved/${id}`;
export function imagesFilters(state) {
const view = normalizeViewState('images', state);
return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media,
tags: view.tags, favorites: view.favorites };
tags: view.tags, favorites: view.favorites, collection: view.collection };
}
export function imagesViewState(filters) {
return normalizeViewState('images', { q: filters.q, category: filters.category, sort: filters.sort, source: filters.root,
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true });
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true,
collection: filters.collection == null ? '' : String(filters.collection) });
}
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
@@ -215,14 +221,20 @@ export function openSaveViewDialog({ scope, trigger, q, onSave }) {
}
/**
* D51: the open view's Edit dialog. "View name" (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so nothing is removed by one press and
* no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the store's promise; the toast reports either
* outcome, and the shell navigates after a delete.
* D51: an open sidebar item's Edit dialog, shared by saved views and collections (#331 D137) with their own nouns. The
* name field (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so
* nothing is removed by one press and no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the
* store's promise; the toast reports either outcome, and the shell navigates after a delete.
*
* `nouns`: `title`, `label`, `deleteTitle`, `deleteText(item)`, `renamed`, `renameFailed`, `deleted`, `deleteFailed`.
* By default the dialog closes before the rename is sent (saved views). `renameError(error)` instead keeps it open
* while the rename runs and maps a failure: `{field: text}` writes the message under the field and keeps the dialog,
* `{toast: text, close}` toasts it and closes the dialog only when `close` is true; null is `renameFailed`, kept open.
*/
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
export function openEditDialog({ scope, trigger, item, nouns, onRename, onDelete, renameError = null }) {
const input = document.createElement('input');
input.value = view.name;
input.value = item.name;
input.maxLength = MAX_NAME_LENGTH;
const remove = createButton('Delete…', 'danger');
const cancel = createButton('Cancel');
@@ -230,28 +242,69 @@ export function openEditViewDialog({ scope, trigger, view, onRename, onDelete })
const actions = document.createElement('div');
actions.className = 'dialog-actions';
actions.append(remove, cancel, save);
const message = document.createElement('p');
message.className = 'small';
message.setAttribute('role', 'alert');
message.hidden = true;
const say = text => { message.textContent = text; message.hidden = !text; };
cancel.addEventListener('click', () => closeWorkspaceDialog());
save.addEventListener('click', async () => {
const name = input.value.trim();
if (!name) { input.focus(); return; }
closeWorkspaceDialog();
if (name === view.name) return;
if (!renameError || name === item.name) closeWorkspaceDialog();
if (name === item.name) return;
if (!renameError) {
try {
await onRename(name);
showWorkspaceToast(nouns.renamed);
} catch {
if (!scope.signal.aborted) showWorkspaceToast(nouns.renameFailed);
}
return;
}
// Kept open: one rename at a time, its outcome shown in place.
say('');
for (const button of [remove, cancel, save]) button.disabled = true;
// Closed meanwhile (Escape, Close): the outcome is only toasted, and a dialog opened since is left alone.
const stillOpen = () => input.isConnected;
try {
await onRename(name);
showWorkspaceToast(RENAMED_TOAST);
} catch {
if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST);
if (scope.signal.aborted) return;
if (stillOpen()) closeWorkspaceDialog();
showWorkspaceToast(nouns.renamed);
} catch (error) {
if (scope.signal.aborted) return;
for (const button of [remove, cancel, save]) button.disabled = false;
const outcome = renameError(error) ?? { toast: nouns.renameFailed };
if (!stillOpen()) { showWorkspaceToast(outcome.field ?? outcome.toast); return; }
if (outcome.field) { say(outcome.field); input.focus(); return; }
if (outcome.close) closeWorkspaceDialog();
showWorkspaceToast(outcome.toast);
}
});
remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete }));
openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field('View name', input, 'action-input'), actions] });
remove.addEventListener('click', () => confirmDelete({ scope, trigger, item, nouns, onDelete }));
// The message line exists only where a failure keeps the dialog open (the saved views' dialog is unchanged).
const nodes = renameError ? [field(nouns.label, input, 'action-input'), message, actions] : [field(nouns.label, input, 'action-input'), actions];
openContentDialog({ scope, trigger, title: nouns.title, nodes });
input.focus();
input.select();
}
function confirmDelete({ scope, trigger, view, onDelete }) {
/** The saved views' nouns for the shared Edit dialog (D51). */
export const SAVED_VIEW_NOUNS = Object.freeze({
title: 'Edit saved view', label: 'View name', deleteTitle: 'Delete saved view',
deleteText: view => `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`,
renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST,
});
/** D51: the open view's Edit dialog. */
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
openEditDialog({ scope, trigger, item: view, nouns: SAVED_VIEW_NOUNS, onRename, onDelete });
}
function confirmDelete({ scope, trigger, item, nouns, onDelete }) {
const text = document.createElement('p');
text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`;
text.textContent = nouns.deleteText(item);
const actions = document.createElement('div');
actions.className = 'dialog-actions';
const cancel = createButton('Cancel');
@@ -262,11 +315,11 @@ function confirmDelete({ scope, trigger, view, onDelete }) {
closeWorkspaceDialog();
try {
await onDelete();
showWorkspaceToast(DELETED_TOAST);
showWorkspaceToast(nouns.deleted);
} catch {
if (!scope.signal.aborted) showWorkspaceToast(DELETE_FAILED_TOAST);
if (!scope.signal.aborted) showWorkspaceToast(nouns.deleteFailed);
}
});
openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] });
openContentDialog({ scope, trigger, title: nouns.deleteTitle, nodes: [text, actions] });
cancel.focus();
}
+13 -2
View File
@@ -25,7 +25,8 @@ export const routes = [
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
];
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system']);
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system',
'collection']);
/**
* System's sections in the concept's order (app.js `systemSections` 635–644); the concept's `general` is production's
@@ -45,6 +46,13 @@ export function isSystemSection(id) {
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
/**
* A collection (#331 D137): `#/collection/<id>` is Images filtered to its members. Not a `routes` entry (no Overview
* card, no WORKSPACE link); the shell names the page after the collection, the breadcrumb and title stay "Collection"
* (concept render a1186–1189).
*/
export const COLLECTION_ROUTE = Object.freeze({ id: 'collection', label: 'Collection' });
/** Breadcrumb and document title: the concept names a few pages differently from their nav label. */
export function routeTitle(route) {
return route.title || route.label;
@@ -58,6 +66,9 @@ export function isHandoff(route) {
export function resolveRoute(hash) {
const saved = /^#\/saved\/([1-9]\d*)$/.exec(hash);
if (saved && Number.isSafeInteger(Number(saved[1]))) return {...SAVED_ROUTE, savedId:Number(saved[1]), hash};
// A collection id is the routes' 1 to 9 digits without a leading zero.
const collection = /^#\/collection\/([1-9]\d{0,8})$/.exec(hash);
if (collection) return {...COLLECTION_ROUTE, collectionId:Number(collection[1]), hash};
// One job of the Activity page (its id is the registry's uuid4 hex).
const job = /^#\/activity\/([0-9a-f]{32})$/.exec(hash);
if (job) return {...routes.find(route => route.id === 'activity'), jobId:job[1], hash};
@@ -151,7 +162,7 @@ export function mountRouter(win, onRoute) {
const hash = route.hash || `#/${route.id}`;
if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash);
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId &&
current?.jobId === route.jobId && current?.section === route.section) return;
current?.collectionId === route.collectionId && current?.jobId === route.jobId && current?.section === route.section) return;
current = route;
onRoute(route);
};
+16 -7
View File
@@ -29,7 +29,7 @@ import { HOST_UNAVAILABLE } from './system-appearance.mjs';
import { openCloseOnlyReview, openReviewDialog } from './workspace-dialogs.mjs';
import { BACKUP_COPY, backupText, holding, reviewRefusal } from './maintenance-review.mjs';
import { busyToast, downloadDatabaseBackup } from './activity-scan.mjs';
import { MODEL_OPTIONS } from './image-tagging-state.mjs';
import { MODEL_OPTIONS, installCommand, missingPackages, needsPackages, packageList } from './image-tagging-state.mjs';
import { openImageTagging } from './image-tagging.mjs';
import { selectedImage } from './images.mjs';
import { NO_SELECTION } from './vocabulary.mjs';
@@ -80,6 +80,7 @@ export const STATUS_COPY = Object.freeze({
loaded: 'Downloaded and loaded.',
partial: 'Partly downloaded. Download again to finish.',
missing: size => `Not downloaded. Download size ${size}.`,
needs: (names, command, note) => `Downloaded. Needs ${names}: ${command}.${typeof note === 'string' && note.trim() ? ` ${note}` : ''}`,
});
/** The messages that name a setting the section cannot use (the server's own wording). */
@@ -140,15 +141,21 @@ export function modelOptions(models) {
/**
* D132: the Tagging model description for `status` (`{phase: loading|loaded|failed, models}`) and the chosen `model`.
* GGUF with only one of its two files on disk is partly downloaded.
* GGUF with only one of its two files on disk is partly downloaded. A backend package the server cannot import (#424)
* turns "Downloaded" into "Downloaded. Needs <pkg>: pip install <pkg>." and adds "Also needs <pkg>." otherwise.
*/
export function modelDescription(status, model) {
if (status?.phase === 'loading') return STATUS_COPY.loading;
const entry = status?.models?.[model];
if (status?.phase !== 'loaded' || !object(entry)) return STATUS_COPY.failed;
if (entry.downloaded === true) return entry.loaded === true ? STATUS_COPY.loaded : STATUS_COPY.downloaded;
if (partlyDownloaded(model, entry)) return STATUS_COPY.partial;
return STATUS_COPY.missing(modelSize(model, entry));
// #424: a backend package that cannot be imported; a model on disk without it never reads as ready.
const needs = missingPackages(entry).length ? packageList(entry) : '';
if (entry.downloaded === true) {
if (needs) return STATUS_COPY.needs(needs, installCommand(entry), entry.install_note);
return entry.loaded === true ? STATUS_COPY.loaded : STATUS_COPY.downloaded;
}
const files = partlyDownloaded(model, entry) ? STATUS_COPY.partial : STATUS_COPY.missing(modelSize(model, entry));
return needs ? `${files} Also needs ${needs}.` : files;
}
/** GGUF with only one of its two files on disk (a download that did not finish). */
@@ -316,6 +323,7 @@ export function diskShortNote(disk) {
export function batchCloseNote(plan) {
if (plan.review_required === true) return REPLACE_NOTE;
if (plan.model?.downloaded === false) return notDownloaded(modelLabel(plan.model.type, plan.model));
if (missingPackages(plan.model).length) return needsPackages(modelLabel(plan.model.type, plan.model), plan.model);
if (plan.disk?.sufficient === false) return diskShortNote(plan.disk);
if (!Number.isSafeInteger(plan.images) || plan.images === 0) return NO_IMAGES;
if (plan.to_tag === 0) return NOTHING_TO_TAG;
@@ -484,8 +492,9 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
unload.disabled = !(status.phase === 'loaded' && status.loadedType);
if (review.textContent === 'Reviewing…') return;
const batchProblem = known() ? draftProblem(values, { limit: false }) : unknown;
setAvailable(review, entry?.downloaded === false ? notDownloaded(modelLabel(values.autotag_model_type, entry))
: batchProblem?.message);
const label = modelLabel(values.autotag_model_type, entry);
setAvailable(review, entry?.downloaded === false ? notDownloaded(label)
: needsPackages(label, entry) || batchProblem?.message);
};
let reads = 0;
+17
View File
@@ -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);
+7 -5
View File
@@ -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();
+32 -4
View File
@@ -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 });
}
/**
+7
View File
@@ -12,6 +12,9 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
sidebar's min-content (the concept's `1fr`); the column is capped at the viewport as production's shell was. */
@media (max-width: 950px) {
.app-shell { grid-template-columns: minmax(0, 1fr); /* D31 */ }
/* D136 (#331): the concept hides COLLECTIONS here, which would leave a phone no way to open a collection; its rows
wrap like every other sidebar nav (the concept's `.sidebar nav` flex row). Its label stays hidden, as all are. */
#collections-nav { display: flex; /* D136 */ }
}
@media (forced-colors: active) {
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
@@ -446,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; }