/** * 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: ` 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/` (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, // #444: how the Tags match ('untagged' never goes with tags; the listing refuses both) and the output folder // ('/'-joined, below a root). tag_mode: choice(['all', 'any', 'untagged'], 'all'), folder: text(1024), }), }); 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; // Untagged and tags are never both a view's filter either: the tags win, as the server's normalizer does. if (normalized.tag_mode === 'untagged' && normalized.tags.length) normalized.tag_mode = 'all'; 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: ` 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, tagMode: view.tag_mode, folder: view.folder }; } export function libraryViewState(filters) { return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags, tag_mode: filters.tagMode ?? 'all', folder: filters.folder ?? '' }); } /** * 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(); }