Tags & categories gains a server-side tag search and sort (keyset pages, cursor scoped to the query) and an "Untagged prompts" row whose Browse opens the Library with tag_mode=untagged. Refine this view gets Tag match, saved Library views keep it, and the routes echo the applied query so a stale server is an error, never an unfiltered list. Recorded as D149. Refs #444
345 lines
19 KiB
JavaScript
345 lines
19 KiB
JavaScript
/**
|
||
* Native Tags & categories state: bounded vocabulary pages and guarded commands.
|
||
*
|
||
* UI-agnostic. The page renders `stream.state` / `command.state` and calls their
|
||
* methods. Reads are owned by their stream: reset() or dispose() aborts the read
|
||
* in flight and a superseded response never lands. Writes are sent once, without
|
||
* an abort signal (aborting cannot undo a write), and are never replayed.
|
||
*
|
||
* Category pages are keyed by each value's lowest prompt id, which can move
|
||
* between pages after an edit, so appended rows are deduplicated (first wins)
|
||
* and every command that may have changed storage calls `onInvalidate` so the
|
||
* page resets its streams to the first page.
|
||
*/
|
||
import { countOf } from './plural.mjs';
|
||
|
||
const LIMIT = 100;
|
||
const BASE = '/prompt_manager/vocabulary';
|
||
const KINDS = {
|
||
// image_count (#249 image tags) is optional until every server reports it.
|
||
tags: {key: row => row.id, valid: row => id(row.id) && typeof row.name === 'string' && row.name.length > 0 && optionalCount(row.image_count)},
|
||
categories: {key: row => row.value, valid: row => typeof row.value === 'string' && row.value.length > 0},
|
||
};
|
||
const RECOVERY = {'storage-changed': 'storage-changed', 'vocabulary-conflict': 'conflict'};
|
||
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
|
||
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
|
||
// Phases that may send a command; done, storage-changed and unknown-outcome need reset().
|
||
const READY = new Set(['idle', 'previewed', 'write-error', 'conflict']);
|
||
const IMPACT_FIELDS = ['positive_prompts', 'other_prompts', 'negative_variants', 'archived_negative_variants'];
|
||
/** #444 tag sorts as [value, label]: `created` (today's order) first, then the legacy tag manager's four. */
|
||
export const TAG_SORTS = Object.freeze([['created', 'Oldest first'], ['name', 'Name A–Z'], ['name-desc', 'Name Z–A'],
|
||
['prompts', 'Most prompts'], ['prompts-asc', 'Fewest prompts']].map(pair => Object.freeze(pair)));
|
||
// The route's cap on a tag search (database/vocabulary.py _MAX_SEARCH).
|
||
export const TAG_SEARCH_MAX = 200;
|
||
const DEFAULT_QUERY = Object.freeze({search: '', sort: 'created'});
|
||
|
||
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||
const id = value => Number.isSafeInteger(value) && value > 0;
|
||
const count = value => Number.isSafeInteger(value) && value >= 0;
|
||
const optionalCount = value => value === undefined || count(value);
|
||
const epochOf = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
|
||
const hexToken = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
||
|
||
function requireValue(valid, message) {if (!valid) throw new TypeError(message);}
|
||
function freeze(value) {
|
||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);}
|
||
return value;
|
||
}
|
||
function storageChanged() {
|
||
return Object.assign(new Error('Prompt storage was restored. Reload the lists before continuing.'), {code: 'storage-changed'});
|
||
}
|
||
// Duck-typed by name: this ES module cannot import the UMD api-client.js.
|
||
function isApiError(error) {return error instanceof Error && error.name === 'ApiError' && Number.isInteger(error.status);}
|
||
|
||
/** A tags query, trimmed; categories take none. Throws on anything the route would refuse. */
|
||
function tagQuery(kind, query) {
|
||
if (query === undefined) return DEFAULT_QUERY;
|
||
requireValue(kind === 'tags' && object(query), 'Only tags take a search and sort');
|
||
const {search = '', sort = 'created'} = query;
|
||
requireValue(typeof search === 'string' && search.trim().length <= TAG_SEARCH_MAX, 'Invalid tag search');
|
||
requireValue(TAG_SORTS.some(([value]) => value === sort), 'Invalid tag sort');
|
||
return Object.freeze({search: search.trim(), sort});
|
||
}
|
||
|
||
function validatePage(page, kind, cursor, query) {
|
||
const rows = page?.[kind];
|
||
const {key, valid} = KINDS[kind];
|
||
// A search or sort the server did not echo was not applied (an older server): never show that list as filtered.
|
||
const plain = query.search === '' && query.sort === 'created' && page?.search === undefined && page?.sort === undefined;
|
||
const applied = plain || (page?.search === query.search && page?.sort === query.sort);
|
||
requireValue(applied && optionalCount(page?.untagged_count), `Unconfirmed ${kind} search`);
|
||
requireValue(object(page) && page.success === true && epochOf(page.library_epoch) && Array.isArray(rows) &&
|
||
rows.every(row => object(row) && valid(row) && count(row.prompt_count)) &&
|
||
new Set(rows.map(key)).size === rows.length && count(page.total) && typeof page.has_more === 'boolean' &&
|
||
(page.next_cursor === null || (typeof page.next_cursor === 'string' && page.next_cursor.length > 0)) &&
|
||
page.has_more === (page.next_cursor !== null) && !(page.has_more && page.next_cursor === cursor),
|
||
`Invalid ${kind} page`);
|
||
}
|
||
|
||
/** One bounded, append-only list of tags or categories. */
|
||
/**
|
||
* One bounded, append-only list of tags or categories. A tags stream takes `query` (`{search, sort}`, #444) and
|
||
* `reset(query)` replaces it; `state.untaggedCount` is the last count a tags page reported (kept across resets so the
|
||
* Untagged prompts row does not flicker), `null` before any.
|
||
*/
|
||
export function createVocabularyStream({api, kind, onChange = () => {}, query: initialQuery}) {
|
||
requireValue(Object.hasOwn(KINDS, kind), 'Unknown vocabulary kind');
|
||
const {key} = KINDS[kind];
|
||
let query = tagQuery(kind, initialQuery);
|
||
const initial = untaggedCount => ({items: Object.freeze([]), total: 0, cursor: null, hasMore: true, loading: false, error: null,
|
||
epoch: null, resetRequired: false, query, untaggedCount});
|
||
let state = Object.freeze(initial(null));
|
||
let request = null, disposed = false, seen = new Set();
|
||
const emit = patch => {state = Object.freeze({...state, ...patch}); onChange(state);};
|
||
|
||
async function loadMore() {
|
||
if (disposed || state.loading || !state.hasMore || state.resetRequired) return 0;
|
||
const current = request = new AbortController();
|
||
const cursor = state.cursor;
|
||
const asked = query;
|
||
const params = new URLSearchParams({limit: String(LIMIT)});
|
||
if (asked.search) params.set('search', asked.search);
|
||
if (asked.sort !== 'created') params.set('sort', asked.sort);
|
||
if (cursor !== null) params.set('cursor', cursor);
|
||
emit({loading: true, error: null});
|
||
try {
|
||
const page = await api.get(`${BASE}/${kind}?${params}`, {signal: current.signal, cache: 'no-store'});
|
||
if (disposed || current !== request) return 0;
|
||
validatePage(page, kind, cursor, asked);
|
||
request = null;
|
||
if (state.epoch !== null && page.library_epoch !== state.epoch) {
|
||
emit({loading: false, error: storageChanged(), resetRequired: true});
|
||
return 0;
|
||
}
|
||
const incoming = page[kind].filter(row => !seen.has(key(row))).map(row => Object.freeze({...row}));
|
||
incoming.forEach(row => seen.add(key(row)));
|
||
emit({items: incoming.length ? Object.freeze([...state.items, ...incoming]) : state.items, total: page.total,
|
||
cursor: page.next_cursor, hasMore: page.has_more, loading: false, error: null, epoch: page.library_epoch,
|
||
untaggedCount: page.untagged_count ?? state.untaggedCount});
|
||
return incoming.length;
|
||
} catch (error) {
|
||
if (!disposed && current === request) {request = null; emit({loading: false, error});}
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
return {
|
||
get state() {return state;},
|
||
loadMore,
|
||
/** Drop every loaded row and read again from the first page, with `next` as the new query when given. */
|
||
reset(next) {
|
||
if (next !== undefined) query = tagQuery(kind, next);
|
||
if (disposed) return Promise.resolve(0);
|
||
request?.abort();
|
||
request = null;
|
||
seen = new Set();
|
||
emit(initial(state.untaggedCount));
|
||
return loadMore();
|
||
},
|
||
dispose() {disposed = true; request?.abort(); request = null;},
|
||
};
|
||
}
|
||
|
||
function validateTag(value) {return object(value) && KINDS.tags.valid(value) && count(value.prompt_count);}
|
||
function validateImpact(value) {return object(value) && IMPACT_FIELDS.every(name => count(value[name])) && optionalCount(value.images);}
|
||
function requireRow(row) {requireValue(object(row) && id(row.id) && typeof row.name === 'string', 'Invalid tag row');}
|
||
function requireEpoch(epoch) {requireValue(epochOf(epoch), 'Invalid library epoch');}
|
||
function requireText(value, name) {requireValue(typeof value === 'string', `${name} must be a string`);}
|
||
|
||
const RECEIPTS = {
|
||
'create-tag': value => typeof value.created === 'boolean' && validateTag(value.tag),
|
||
'rename-tag': value => typeof value.merged === 'boolean' && validateTag(value.tag) &&
|
||
count(value.components_changed) && count(value.pairs_changed) && optionalCount(value.images_changed),
|
||
'delete-tag': value => value.deleted === true && count(value.components_changed) && count(value.pairs_changed) &&
|
||
optionalCount(value.images_changed),
|
||
'apply-category': (value, body) => value.source === body.source && value.target === body.target &&
|
||
typeof value.consolidated === 'boolean' && validateImpact(value.impact),
|
||
};
|
||
|
||
function writePhase(error) {
|
||
if (!isApiError(error)) return 'unknown-outcome';
|
||
if (error.status === 409 && Object.hasOwn(RECOVERY, error.code)) return RECOVERY[error.code];
|
||
if ((error.status >= 200 && error.status < 300) || UNCONFIRMED_STATUS.has(error.status)) return 'unknown-outcome';
|
||
return 'write-error';
|
||
}
|
||
|
||
/**
|
||
* One dialog's command. Phases: idle, saving, previewed, write-error, conflict,
|
||
* storage-changed, unknown-outcome, done (and disposed). `onInvalidate({phase,
|
||
* kind, receipt})` fires whenever stored vocabulary may differ from the loaded
|
||
* lists: after a receipt, a conflict, a restore or an unconfirmed write, including
|
||
* one that lands after `dispose()`.
|
||
*/
|
||
export function createVocabularyCommand({api, onChange = () => {}, onInvalidate = () => {}}) {
|
||
let state = freeze({phase: 'idle', kind: null, error: null, preview: null, receipt: null});
|
||
let disposed = false;
|
||
// Set when the dialog's inputs change while a category preview is in flight.
|
||
let stalePreview = false;
|
||
const emit = patch => {state = freeze({...state, ...patch}); onChange(state);};
|
||
const invalidate = () => onInvalidate(freeze({phase: state.phase, kind: state.kind, receipt: state.receipt}));
|
||
|
||
async function write(kind, method, url, body) {
|
||
emit({phase: 'saving', kind, error: null, preview: null, receipt: null});
|
||
let phase = 'done', error = null, receipt = null;
|
||
try {
|
||
const value = await api[method](url, body);
|
||
if (object(value) && value.success === true && value.library_epoch === body.expected_epoch && RECEIPTS[kind](value, body)) {
|
||
receipt = freeze({...value});
|
||
} else {
|
||
// The request reached the server, so the write may have committed.
|
||
phase = 'unknown-outcome';
|
||
error = new TypeError('The server response could not be confirmed.');
|
||
}
|
||
} catch (failure) {
|
||
phase = writePhase(failure);
|
||
error = failure;
|
||
}
|
||
if (disposed) {
|
||
// A write that may have committed still invalidates cached Library views after the dialog went away
|
||
// (forced close, Back); nothing is rendered then.
|
||
if (phase !== 'write-error') onInvalidate(freeze({phase, kind, receipt}));
|
||
return;
|
||
}
|
||
emit({phase, error, receipt});
|
||
if (phase !== 'write-error') invalidate();
|
||
}
|
||
|
||
async function preview(body) {
|
||
emit({phase: 'saving', kind: 'preview-category', error: null, preview: null, receipt: null});
|
||
stalePreview = false;
|
||
try {
|
||
const value = await api.post(`${BASE}/categories/preview`, body);
|
||
requireValue(object(value) && value.success === true && value.library_epoch === body.expected_epoch &&
|
||
value.source === body.source && value.target === body.target.trim() && typeof value.target_exists === 'boolean' &&
|
||
validateImpact(value.impact) && hexToken(value.impact_token), 'Invalid category preview');
|
||
// Inputs edited while it was pending: it describes values no longer on screen, so Preview is needed again.
|
||
if (!disposed) emit(stalePreview ? {phase: 'idle'} : {phase: 'previewed', preview: freeze({...value})});
|
||
} catch (error) {
|
||
if (disposed) return;
|
||
// A preview writes nothing: only a restore or a changed category needs recovery.
|
||
const phase = isApiError(error) && error.status === 409 && Object.hasOwn(RECOVERY, error.code) ? RECOVERY[error.code] : 'write-error';
|
||
if (phase === 'write-error' && stalePreview) {emit({phase: 'idle'}); return;}
|
||
emit({phase, error});
|
||
if (phase !== 'write-error') invalidate();
|
||
}
|
||
}
|
||
|
||
const ready = () => !disposed && READY.has(state.phase);
|
||
return {
|
||
get state() {return state;},
|
||
createTag(name, epoch) {
|
||
requireText(name, 'Tag name');
|
||
requireEpoch(epoch);
|
||
if (!ready()) return Promise.resolve();
|
||
return write('create-tag', 'post', `${BASE}/tags`, {expected_epoch: epoch, name});
|
||
},
|
||
renameTag(row, newName, epoch) {
|
||
requireRow(row);
|
||
requireText(newName, 'New name');
|
||
requireEpoch(epoch);
|
||
if (!ready()) return Promise.resolve();
|
||
return write('rename-tag', 'patch', `${BASE}/tags/${row.id}`, {expected_epoch: epoch, name: row.name, new_name: newName});
|
||
},
|
||
deleteTag(row, epoch) {
|
||
requireRow(row);
|
||
requireEpoch(epoch);
|
||
if (!ready()) return Promise.resolve();
|
||
return write('delete-tag', 'post', `${BASE}/tags/${row.id}/delete`, {expected_epoch: epoch, name: row.name});
|
||
},
|
||
previewCategory(source, target, epoch) {
|
||
requireText(source, 'Category');
|
||
requireText(target, 'New category name');
|
||
requireEpoch(epoch);
|
||
if (!ready()) return Promise.resolve();
|
||
return preview({expected_epoch: epoch, source, target});
|
||
},
|
||
/** Apply only the preview currently shown; anything else is ignored. */
|
||
applyCategory(shown) {
|
||
if (!ready() || state.phase !== 'previewed' || shown !== state.preview) return Promise.resolve();
|
||
return write('apply-category', 'post', `${BASE}/categories/apply`, {expected_epoch: shown.library_epoch,
|
||
source: shown.source, target: shown.target, impact_token: shown.impact_token});
|
||
},
|
||
/** The user changed a dialog input: a shown or pending preview no longer describes it. */
|
||
edit() {
|
||
if (disposed) return;
|
||
if (state.phase === 'previewed') emit({phase: 'idle', preview: null});
|
||
else if (state.phase === 'saving' && state.kind === 'preview-category') stalePreview = true;
|
||
},
|
||
/** Explicit user recovery (after re-reading the lists); never resends anything. */
|
||
reset() {
|
||
if (!disposed && state.phase !== 'saving') emit({phase: 'idle', kind: null, error: null, preview: null, receipt: null});
|
||
},
|
||
dispose() {
|
||
disposed = true;
|
||
state = freeze({...state, phase: 'disposed'});
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Library filters for a row's Browse: exactly one constraint, the stored value. */
|
||
export function browseFilters(kind, value) {
|
||
requireValue(Object.hasOwn(KINDS, kind), 'Unknown vocabulary kind');
|
||
requireValue(typeof value === 'string' && value.length > 0, 'Invalid vocabulary value');
|
||
return kind === 'tags'
|
||
? {text: '', category: '', tags: [value], sort: 'newest'}
|
||
: {text: '', category: value, tags: [], sort: 'newest'};
|
||
}
|
||
|
||
/** Library filters for the Untagged prompts row's Browse (#444): parents that no tag's Browse returns. */
|
||
export function untaggedBrowseFilters() {
|
||
return {text: '', category: '', tags: [], sort: 'newest', tagMode: 'untagged'};
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
return api.get(`${BASE}/tags/${row.id}/impact`, {signal, cache: 'no-store'}).then(value => {
|
||
requireValue(object(value) && value.success === true && epochOf(value.library_epoch) && validateImpact(value.impact), 'Invalid tag impact');
|
||
return freeze({library_epoch: value.library_epoch, impact: {...value.impact}});
|
||
});
|
||
}
|
||
|
||
/** Owner kinds with a nonzero count, in sentence form ("a, b and c"); empty when nothing is linked. */
|
||
function impactList(impact) {
|
||
const negatives = impact.negative_variants + impact.archived_negative_variants;
|
||
const parts = [];
|
||
if (impact.positive_prompts) parts.push(countOf(impact.positive_prompts, 'positive prompt'));
|
||
if (negatives) {
|
||
const archived = impact.archived_negative_variants ? ` (${impact.archived_negative_variants} archived)` : '';
|
||
parts.push(`${countOf(negatives, 'negative variant')}${archived}`);
|
||
}
|
||
if (impact.other_prompts) parts.push(countOf(impact.other_prompts, 'other saved prompt'));
|
||
if (impact.images) parts.push(countOf(impact.images, 'image'));
|
||
return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}` : parts.join('');
|
||
}
|
||
|
||
/** The Manage categories preview sentence (decision D9); zero counts are left out. */
|
||
export function categoryPreviewText(shown) {
|
||
const outcome = shown.target_exists ? `consolidates it into the existing “${shown.target}”` : `renames it to “${shown.target}”`;
|
||
return `“${shown.source}” is used by ${impactList(shown.impact)}. Apply ${outcome}.`;
|
||
}
|
||
|
||
/** The Delete tag confirmation sentence (decision D14). */
|
||
export function deleteTagText(name, impact) {
|
||
const list = impactList(impact);
|
||
if (!list) return `“${name}” is not linked to any saved prompt or image. Deleting it removes only the name.`;
|
||
return `Deleting “${name}” removes the name and its links from ${list}. The prompts, negative variants, images and history themselves are kept.`;
|
||
}
|