* fix(images): background jobs skip files hidden by the removal mark; walkers skip trash folders inside output roots (#501) - Thumbnail jobs and the legacy scan_output_dir listing leave library-removed files out (one mark check, mark_hides). - Missing-file links also forgets removal marks that hide nothing any more (file gone from a folder that exists, or changed), only under a readable, non-empty source, re-checked at apply. - The sidebar Images, Favorites and Needs review counts and the collection counts are read again when an image_delete job ends. - utils/media_folders.py: one skip for thumbnails and trash folders (.Trash, .Trash-<uid>, .Trashes, $RECYCLE.BIN) used by every walker, the image monitor and the delete review. * feat(images): Show removed images and Return to library (#498) - GET /images/output?mode=workspace&removed=1 lists only files a removal mark hides; every workspace page carries removed_total. - POST /images/removed/return {items | filter} starts a scan job, "Return images to the library": the marks that still hide their file go in one transaction, then exactly those files are scanned, so their prompt links come back from their metadata. Tags, categories, collections and favorites do not come back. - The marks generation moves again after the commit, so a reader that cached the marks mid-transaction reloads them. - Images: Show removed images (N) heading toggle (D162), Removed chips, a bar with only Return to library and Clear, Return to library in the side panel; the view is never saved or restored. * test(images): record the return route in the legacy contract snapshot; removed_total in the workspace keys (#498) The scan runner passes returned only when set, so a stand-in events function without that keyword still runs. * test(browser): wait for the answers two specs raced in the merge queue (#501) - System diagnostics "level filter": wait for each level's own log answer, and poll the shown levels; the CRITICAL line is in the previous level's answer too, so it proved nothing (firefox). - Library editor "restored storage after a confirmed save": wait for the PATCH answer, which waits for a whole restore in the test's route, instead of the 5 s expect default (webkit). * fix(monitor): skip trash folders only below the watched folder (#501 review) A source named .Trash-1000 (or another trash name) was ignored by the image monitor while every walker still listed it; the monitor now tests the folders below the longest watched folder holding the new file. * fix(images): no default removedView function in the selection bar (#498) The default arrow was never called, so images-selection.mjs fell under its function coverage floor (97.29 < 98). * fix(images): a category or tags chosen in Show removed images ends the view (#498 review) The toolbar's Category and Refine's Tags stayed usable in the removed view, and the listing then refused the combination with a page error. Choosing one now leaves the view, as a removed file has neither.
185 lines
10 KiB
JavaScript
185 lines
10 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']);
|
||
/**
|
||
* Job kind -> the counts re-read once when such a job ends: a scan feeds Needs review and can add prompts, and since
|
||
* #498 "Return images to the library" (a scan) shows removed images again; #501: a delete hides images.
|
||
*/
|
||
export const JOB_END_COUNTS = Object.freeze({
|
||
scan: Object.freeze(['review', 'library', 'images']),
|
||
image_delete: Object.freeze(['images', 'favorites', '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);
|
||
};
|
||
}
|