Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/saved-views.mjs
T
Vito 56d0a4904c feat(collections): COLLECTIONS sidebar, collection page and saved views (#331, PR 2) (#410)
* feat(saved-views): keep a collection in a saved view

Images saved views gain a collection key (#331 D140): empty or a collection
id of 1 to 9 digits. Writes refuse favorites together with a collection;
older rows read an empty collection, and a stored row with both opens the
collection, as the listing does for a URL with both.

Part of #331

* feat(collections): store collections in the library database

* feat(collections): add, remove and rename collections and keep members through duplicate cleanup

* feat(collections): add collection routes and the Images collection filter

* feat(images): store images' own categories in the library database (#332)

One new table, image_categories, and its category index, created empty
by a forward-only migration that reads and writes only its own objects.
Startup checks the structure; restore also checks the values. The store
sets, clears and reads own categories by file key (the deepest output
source holding the file and the path below it), and copies a removed
duplicate's category to a kept copy that has none.

* feat(images): duplicate cleanup keeps images' own categories (#332)

The kept copy's own category wins; a kept copy without one receives the
removed copy's, keyed by the deepest output source holding each file.
The removed copy's row is never deleted, so a give-back or a failed
delete loses nothing. The job log reports how many were kept.

* feat(images): set, remove and list images' own categories (#332)

PUT /prompt_manager/images/category sets the own category of up to 500
listed files, DELETE removes it (listed or not), and
GET /prompt_manager/images/categories lists the values whose file is
listed. Targets are the Images listing identity of an output source,
checked by media_target (shared with Collections). The routes never
touch prompts, recipes or links; POST /prompt_manager/bulk/category is
unchanged.

* fix(images): key symlinked output folders and keep key work off the event loop (#332)

Review fixes: a source matches by its configured spelling too, so a
detected output folder that is a symlink keys its files (routes and
duplicate cleanup). File keys are computed in the executor. A path no
source holds is a plain 403, a lone surrogate a 400. The install repairs
a table that lacks its category index instead of refusing to open.

* fix(collections): address review findings

* feat(images): set or remove a category on all N matching images (#332)

The category routes also accept the Images listing filter of "Select
all N matching" (#392) instead of explicit items, resolved now and
capped at 500 images like an explicit selection.

* feat(collections): browse a collection from the sidebar

The sidebar gains the concept's COLLECTIONS group (D136): one row per
collection with its folder icon, name and listed-member count, without the
PROPOSED badge; the label shows once a collection exists. A row opens
#/collection/<id>, the Images page filtered to that collection (D137): the
collection's name as h1, Collection as breadcrumb and title, Edit to rename
or delete it through the saved views' Edit dialog with collection nouns, and
a note counting members the sources do not list now. An unknown or deleted
collection shows a page that says so with a link to Images. A saved view on
a deleted collection lists nothing and says so (MC6, D140). The list is read
again after a restore and the counts 300 ms after a change.

Part of #331

* test(collections): compare the COLLECTIONS group with the concept

The concept hides COLLECTIONS at 950 px and below, which would leave a phone
no way to open a collection; production shows the rows there in the wrapped
layout of every other sidebar nav (D136). The shell comparison now compares
the group instead of filtering it out: the concept fixture seeds Landscapes
(12) and Experiments (8), the rows compare by name order, and at phone width
the nav compares against the concept's own sidebar-nav layout. The
collection rows are checked at 1600x1100 and 390x844 in both themes.

Part of #331

* test(vendor): count the #332 host case in the execution matrix

image-attribution.spec.mjs gains the image category route case, so the
execution config lists 92 tests (3 attribution cases per project).

* test(browser): install the slideshow clock before wrapping setTimeout

The slideshow fixture wrapped window.setTimeout before page.clock.install(),
so the viewer's timer ran on real time and clock.runFor() did not drive it;
a late WebKit timer failed 'slideshow exposes exact state labels' on CI.

* fix(collections): address review findings

- A list read superseded by a newer one (the 300 ms count read, a restore)
  no longer shows "could not be loaded": the collection and saved-view
  pages wait for the list to settle.
- A listing that finds the open collection gone shows the deleted page once
  the list no longer holds it; when the list read fails or still holds it,
  the page keeps the listing's error and Retry instead of staying blank, and
  each failed read is reported again.
- The missing-members note follows every list read and goes away at zero.
- A rename that settles after its Edit dialog closed leaves any dialog
  opened since alone.
- The deleted-collection, list-failed and saved-view failure pages focus
  their title after a route change, as every page does.

Part of #331

* test(coverage): keep main's floors for files this PR does not touch

coverage:update raised nine floors of untouched files from one run;
metadata-autocomplete.js branches then measured 84.84 against 85.

* test(legacy): record the image category routes in the legacy contract snapshot (#332)

#332 adds PUT/DELETE /prompt_manager/images/category and
GET /prompt_manager/images/categories; the legacy pages' routes are
unchanged.

* test(duplicates): a removed copy with a collection and a category moves both (#331, #332)
2026-10-28 21:45:09 +00:00

326 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Saved views (#249 D16, maintainer ruling 2026-10-21): a named toolbar value plus its "Refine this view" filters, for
* Images or the Prompt library, persisted through `/prompt_manager/saved-views`.
*
* - The toolbar's Save view opens the concept's dialog (app.js a1330–1346 through `simpleInput` a1138): "Save this
* view", "View name", `Search: <q>` or "My workspace view", Cancel / Save; an empty name is "Saved view".
* - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162).
* - A view's state always holds every key of its page (the API requires them all); a value its page no longer
* accepts is dropped to that key's default, so an opened view applies what it can.
* - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name",
* Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`.
*/
import { createButton } from '../shared/toolkit/core.js';
import { openInputDialog, openContentDialog, closeWorkspaceDialog, field } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
export const SAVED_VIEWS_URL = '/prompt_manager/saved-views';
export const SAVED_TOAST = 'Saved view added to the sidebar.';
export const SAVE_FAILED_TOAST = 'The view could not be saved. Try again.';
export const RENAMED_TOAST = 'Saved view renamed.';
export const DELETED_TOAST = 'Saved view deleted.';
export const RENAME_FAILED_TOAST = 'The view could not be renamed. Try again.';
export const DELETE_FAILED_TOAST = 'The view could not be deleted. Try again.';
export const TOO_LARGE_TOAST = 'The view is too large to save. Shorten its search, folder or tags and try again.';
// The API's cap on a view's encoded state (database/saved_views.py MAX_STATE_BYTES), in UTF-8 bytes of compact JSON.
export const MAX_STATE_BYTES = 4096;
const MAX_NAME_LENGTH = 100;
const MAX_TAGS = 20;
const MAX_TAG_LENGTH = 50;
const CONTROL = /[\u0000-\u001f\u007f]/;
// The API's whitelist (database/saved_views.py _STATE_FIELDS), with each key's default.
const text = (limit, fallback = '') => ({ fallback, accepts: value => typeof value === 'string' && value.length <= limit && !CONTROL.test(value) });
const choice = (choices, fallback) => ({ fallback, accepts: value => choices.includes(value) });
const tags = { fallback: [], accepts: value => Array.isArray(value) && value.length <= MAX_TAGS && value.every(tag =>
typeof tag === 'string' && tag.trim() && tag.length <= MAX_TAG_LENGTH && !tag.includes(',') && !CONTROL.test(tag)) };
const FIELDS = Object.freeze({
images: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'),
source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) },
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,
}),
});
export const SAVED_VIEW_PAGES = Object.freeze(Object.keys(FIELDS));
/** The page's full state: every key, each saved value its page accepts, the default otherwise. */
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 : {};
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). */
export function stateFits(state) {
return new TextEncoder().encode(JSON.stringify(state)).length <= MAX_STATE_BYTES;
}
/** The error `create()` rejects with when the state is over the cap; nothing is sent. */
export class SavedViewTooLarge extends RangeError {
constructor() { super('Saved view state is over 4 KB'); this.name = 'SavedViewTooLarge'; }
}
/** The concept's default name (a1336): `Search: <q>` while there is a search, else "My workspace view". */
export function defaultViewName(q) {
const search = typeof q === 'string' ? q.trim() : '';
return (search ? `Search: ${search}` : 'My workspace view').slice(0, MAX_NAME_LENGTH);
}
/** One listed view, or null when the response row is not a usable view. */
export function parseSavedView(row) {
if (!Number.isSafeInteger(row?.id) || row.id < 1 || !SAVED_VIEW_PAGES.includes(row.page)) return null;
if (typeof row.name !== 'string' || !row.name.trim()) return null;
return { id: row.id, page: row.page, name: row.name, state: normalizeViewState(row.page, row.state) };
}
/** The listing's views in the server's order, skipping any row that is not a usable view. */
export function parseSavedViews(data) {
if (!Array.isArray(data?.views)) throw new TypeError('Invalid saved views');
return data.views.map(parseSavedView).filter(Boolean);
}
/** The route of one view. */
export const savedViewHash = id => `#/saved/${id}`;
/** Images filters (images.mjs `filters`) from a saved state, and the state of the current filters. */
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, 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,
collection: filters.collection == null ? '' : String(filters.collection) });
}
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
export function libraryFilters(state) {
const view = normalizeViewState('library', state);
return { text: view.q, category: view.category, sort: view.sort, tags: view.tags };
}
export function libraryViewState(filters) {
return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags });
}
/**
* The shell's list of saved views: `load()` re-reads it (a newer read supersedes an older one), `create()` saves one
* and re-reads the list, `rename(id, name)` and `remove(id)` change one and re-read it (a failure re-reads it too), `subscribe(fn)` reports `{views, status}` on every change, and `settled()` resolves once no
* read is in flight. `status` is 'loading', 'ready' or 'error'; a failed read keeps the last listed views.
*/
export function createSavedViews(api, signal) {
let views = [], status = 'loading', request = null;
const listeners = new Set();
let waiting = [];
const emit = () => {
const snapshot = { views, status };
for (const listener of listeners) listener(snapshot);
if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); }
};
function load() {
request?.abort();
const controller = new AbortController();
request = controller;
const abort = () => controller.abort();
signal?.addEventListener('abort', abort, { once: true });
status = 'loading';
emit();
return api.get(SAVED_VIEWS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => {
if (request !== controller) return;
views = parseSavedViews(data);
status = 'ready';
}).catch(() => {
if (request !== controller) return;
status = 'error';
}).finally(() => {
signal?.removeEventListener('abort', abort);
if (request !== controller || signal?.aborted) return;
request = null;
emit();
});
}
async function create(page, name, state) {
const full = normalizeViewState(page, state);
if (!stateFits(full)) throw new SavedViewTooLarge();
const data = await api.post(SAVED_VIEWS_URL, { page, name, state: full }, { signal });
const view = parseSavedView(data?.view);
if (!view) throw new TypeError('Invalid saved view');
await load();
return view;
}
/** Run one change of a listed view, then re-read the list whatever its outcome (another tab may have changed it). */
async function change(send) {
try { return await send(); } finally { await load(); }
}
const viewUrl = id => {
if (!Number.isSafeInteger(id) || id < 1) throw new TypeError('Invalid saved view id');
return `${SAVED_VIEWS_URL}/${id}`;
};
function rename(id, name) {
const url = viewUrl(id);
return change(async () => {
const view = parseSavedView((await api.patch(url, { name }, { signal }))?.view);
if (!view) throw new TypeError('Invalid saved view');
return view;
});
}
function remove(id) {
const url = viewUrl(id);
return change(async () => {
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;
}
return id;
});
}
return {
load, create, rename, remove,
get views() { return views; },
get status() { return status; },
find: id => views.find(view => view.id === id) ?? null,
settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ views, status }),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
/**
* The concept's Save view (a1330–1346): the name dialog, then the save. `onSave(name)` returns the save's promise;
* the toast reports either outcome.
*/
export function openSaveViewDialog({ scope, trigger, q, onSave }) {
openInputDialog({ scope, trigger, title: 'Save this view', label: 'View name', action: 'Save', value: defaultViewName(q),
maxLength: MAX_NAME_LENGTH, fallback: 'Saved view', onApply: async name => {
try {
await onSave(name);
showWorkspaceToast(SAVED_TOAST);
} catch (error) {
if (!scope.signal.aborted) showWorkspaceToast(error instanceof SavedViewTooLarge ? TOO_LARGE_TOAST : SAVE_FAILED_TOAST);
}
} });
}
/**
* 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 openEditDialog({ scope, trigger, item, nouns, onRename, onDelete, renameError = null }) {
const input = document.createElement('input');
input.value = item.name;
input.maxLength = MAX_NAME_LENGTH;
const remove = createButton('Delete…', 'danger');
const cancel = createButton('Cancel');
const save = createButton('Save', 'ca-primary');
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; }
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);
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, 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();
}
/** 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 = nouns.deleteText(item);
const actions = document.createElement('div');
actions.className = 'dialog-actions';
const cancel = createButton('Cancel');
const confirmButton = createButton('Delete', 'danger');
actions.append(cancel, confirmButton);
cancel.addEventListener('click', () => closeWorkspaceDialog());
confirmButton.addEventListener('click', async () => {
closeWorkspaceDialog();
try {
await onDelete();
showWorkspaceToast(nouns.deleted);
} catch {
if (!scope.signal.aborted) showWorkspaceToast(nouns.deleteFailed);
}
});
openContentDialog({ scope, trigger, title: nouns.deleteTitle, nodes: [text, actions] });
cancel.focus();
}