* 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)
177 lines
9.7 KiB
JavaScript
177 lines
9.7 KiB
JavaScript
export const routes = [
|
||
{ id: 'images', label: 'Images', href: '/prompt_manager/gallery',
|
||
description: 'Browse your images, inspect their metadata and review generated tags.', action: 'Open existing image gallery' },
|
||
{ id: 'library', label: 'Prompt library', href: '/prompt_manager/admin',
|
||
description: 'Find, edit and organize the prompts already in your library.', action: 'Open existing prompt library' },
|
||
// Native screen; `title` is the concept's breadcrumb/title, the href stays for Overview's card.
|
||
{ id: 'tags', label: 'Tags & categories', title: 'Tags', href: '/prompt_manager/admin#/tags',
|
||
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open existing tag manager' },
|
||
// Native screen (#249 local Inspect); the href stays for Overview's card to the legacy metadata page.
|
||
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
|
||
description: 'A standalone file uses the same image viewer and prompt actions.', action: 'Open existing metadata inspector' },
|
||
{ id: 'overview', label: 'Overview' },
|
||
// SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it.
|
||
{ id: 'favorites', label: 'Favorites' },
|
||
// SAVED VIEWS' Needs review row (ruling R5): the concept's Metadata review page (app.js renderReview 749–760; its
|
||
// breadcrumb is "Review", app.js render 1175–1188),
|
||
// the queue of ambiguous sampler evidence. No href: no Overview card.
|
||
{ id: 'review', label: 'Needs review', title: 'Review' },
|
||
// The concept's Activity (app.js renderActivity 732–738): this server session's jobs. No href: no Overview card.
|
||
{ id: 'activity', label: 'Activity' },
|
||
// Native System (#249 System S2): `#/system/<section>`. Q12: Overview's card opens its Overview section (`native`:
|
||
// a workspace hash, not a legacy page); every section is native (S12 retired D94's hand-off). The description is
|
||
// the concept's System heading (app.js renderSystem 700).
|
||
{ id: 'system', label: 'System', href: '#/system/overview', native: true,
|
||
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',
|
||
'collection']);
|
||
|
||
/**
|
||
* System's sections in the concept's order (app.js `systemSections` 635–644); the concept's `general` is production's
|
||
* `overview`, so `#/system/overview` sits beside the production hub `#/overview` (D28).
|
||
*/
|
||
export const SYSTEM_SECTIONS = Object.freeze([
|
||
['overview', 'Overview'], ['appearance', 'Appearance'], ['sources', 'Sources & scans'], ['integrations', 'Integrations'],
|
||
['tagging', 'Auto-tagging'], ['maintenance', 'Maintenance'], ['backups', 'Backup & export'], ['diagnostics', 'Diagnostics & logs'],
|
||
].map(section => Object.freeze(section)));
|
||
const SYSTEM_SECTION_IDS = new Set(SYSTEM_SECTIONS.map(([id]) => id));
|
||
|
||
/** Whether `id` names a System section. */
|
||
export function isSystemSection(id) {
|
||
return typeof id === 'string' && SYSTEM_SECTION_IDS.has(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;
|
||
}
|
||
|
||
/** Routes the workspace does not render yet show the generic legacy handoff panel. */
|
||
export function isHandoff(route) {
|
||
return !NATIVE_ROUTES.has(route.id);
|
||
}
|
||
|
||
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};
|
||
// `#/system` is the Overview section under the sidebar's own hash; an unknown child is Overview, its hash replaced.
|
||
const system = /^#\/system(?:\/(.*))?$/.exec(hash);
|
||
if (system) {
|
||
const route = routes.find(item => item.id === 'system');
|
||
if (system[1] === undefined) return {...route, section:'overview', hash};
|
||
const section = isSystemSection(system[1]) ? system[1] : 'overview';
|
||
return {...route, section, hash:`#/system/${section}`};
|
||
}
|
||
const match = /^#\/library\/([1-9]\d*)$/.exec(hash);
|
||
if (match && Number.isSafeInteger(Number(match[1]))) {
|
||
return {...routes.find(route => route.id === 'library'), positiveId:Number(match[1]), hash};
|
||
}
|
||
return routes.find(route => hash === `#/${route.id}`) || routes[0];
|
||
}
|
||
|
||
export function summarizeStats(stats) {
|
||
return [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]
|
||
.map(([label, key]) => {
|
||
const count = stats?.[key];
|
||
if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid library statistics');
|
||
return [label, count];
|
||
});
|
||
}
|
||
|
||
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the
|
||
// Favorites page's own listing, so its count is what that page lists.
|
||
const NAV_COUNT_SOURCES = Object.freeze([
|
||
['images', '/prompt_manager/images/output?mode=workspace&limit=1'],
|
||
['favorites', '/prompt_manager/images/output?mode=workspace&favorites=1&limit=1'],
|
||
['library', '/prompt_manager/library?limit=1'],
|
||
['tags', '/prompt_manager/vocabulary/tags?limit=1'],
|
||
// Needs review (ruling R5): the queue's `total` is the exact pending count.
|
||
['review', '/prompt_manager/review/items?limit=1'],
|
||
]);
|
||
|
||
/** Route id -> count for the sidebar links; null when a page is missing or its total is not a count. */
|
||
export function summarizeNavCounts(pages = {}) {
|
||
return new Map(NAV_COUNT_SOURCES.map(([id]) => {
|
||
const total = pages?.[id]?.total;
|
||
return [id, Number.isSafeInteger(total) && total >= 0 ? total : null];
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Read the counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read settles, so a
|
||
* slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
|
||
*/
|
||
/** The sidebar routes that carry a count (WORKSPACE, plus SAVED VIEWS' Favorites and Needs review). */
|
||
export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id));
|
||
|
||
/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */
|
||
export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
|
||
|
||
/** The count a confirmed favorite change can change. */
|
||
export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']);
|
||
/** The count a review decision or a finished metadata scan can change. */
|
||
export const REVIEW_WRITE_COUNTS = Object.freeze(['review']);
|
||
// Trailing delay that coalesces quick star toggles into one Favorites count read (#249 M1).
|
||
export const FAVORITE_COUNT_DELAY_MS = 300;
|
||
|
||
/** Read the given sidebar counts (all by default), reporting each as its own read settles. */
|
||
export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) {
|
||
const pages = {};
|
||
await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => {
|
||
try { pages[id] = await api.get(url, { signal }); } catch { pages[id] = undefined; }
|
||
onCount(id, summarizeNavCounts(pages).get(id));
|
||
}));
|
||
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
|
||
}
|
||
|
||
/** Fired on the window to render the current route again, where setting the same hash fires no `hashchange`. */
|
||
const ROUTE_REFRESH_EVENT = 'prompt-manager:route-refresh';
|
||
|
||
/** The concept's `go()` (app.js 292–297): a link to the route already shown re-renders it instead of doing nothing. */
|
||
export function navigateTo(hash, win = window) {
|
||
if (win.location.hash === hash) win.dispatchEvent(new Event(ROUTE_REFRESH_EVENT));
|
||
else win.location.hash = hash;
|
||
}
|
||
|
||
export function mountRouter(win, onRoute) {
|
||
let current;
|
||
const refresh = () => {
|
||
current = undefined;
|
||
update();
|
||
};
|
||
const update = () => {
|
||
const route = resolveRoute(win.location.hash);
|
||
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?.collectionId === route.collectionId && current?.jobId === route.jobId && current?.section === route.section) return;
|
||
current = route;
|
||
onRoute(route);
|
||
};
|
||
win.addEventListener('hashchange', update);
|
||
win.addEventListener(ROUTE_REFRESH_EVENT, refresh);
|
||
update();
|
||
return () => {
|
||
win.removeEventListener('hashchange', update);
|
||
win.removeEventListener(ROUTE_REFRESH_EVENT, refresh);
|
||
};
|
||
}
|