* feat(system): back up, restore and export natively System > Backup & export replaces its hand-off with the concept's section on the S8 backend: Back up downloads the verified copy and the status line shows only what GET /backup/status reports (D103); Review restore stages a chosen file and shows the server's review in the shared review dialog, Cancel discards the stage and Restore runs the Database restore job, after which the open views reload (D105); Export JSON plus the components and saved-pairs exports (D103); Import / inspect opens the shared Import & inspect dialog; the safety-backup policy saves through the Save row and scheduled backups offer Manual only (D104). Overview's Backup status row reads the same status. Refs #326 * test(system): compare Backup & export with the concept system-backups-concept.spec compares the section, the Restore preview and the Import & inspect dialog with the concept in both themes at 1600 and 390 wide, mapping only D103, D104, D105 (and D15/D55 for the shared Import dialog), and proves a renamed label, a removed row, a lost danger mark and swapped tokens are noticed. A review's grid values now wrap anywhere (D105), so a long backup file name keeps the concept's two equal columns at phone width. Refs #326 * docs(testing): Backup & export contract (D103-D105) Refs #326 * test(coverage): raise the frontend floors S9 measured system-backups.mjs gets its measured floor in place of the provisional one; admin.js, activity-scan.mjs and maintenance-review.mjs branches go up. Refs #326 * fix(system): keep a refused or handed-back restore review usable A Restore refused as busy now opens the same review again with Discard / Restore, and a restore job whose leases did not drain hands its review back (scope.review_kept) to the page that started it, which reopens it; every other path that abandons a token after Restore sends its DELETE, so held reviews can no longer lock restore out. The kept-review line says where the review is, and a full review store says what to do (I1). applyPlan resolves with its outcome and its job tracking now has failing-before proof (N5). Also: the safety-backup toggle says what the policy does (D104, M2); the restore picker has no type filter so safety backups can be chosen (M3); "Export all prompts JSON" names what /export holds (N1); small snapshots show KB (N3); the duplicated holding and save helpers are shared (N4); a dismissal test covers Cancel, x, Escape, a replacing dialog and leaving (M1). Refs #326 * test(coverage): raise the system-backups branch floor Refs #326
377 lines
19 KiB
JavaScript
377 lines
19 KiB
JavaScript
import '../api-client.js';
|
|
import { createButton, createScope } from '../shared/toolkit/core.js';
|
|
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo,
|
|
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
|
|
import { mountWorkspaceTheme } from './theme.mjs';
|
|
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
|
|
import { forgetLibraryVisit, mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
|
|
import { mountVocabulary } from './vocabulary.mjs';
|
|
import { createWorkspaceIcon } from './icons.mjs';
|
|
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
|
import { renderHeading, pageHeading } from './heading.mjs';
|
|
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
|
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
|
|
import { createActivityStore } from './activity-store.mjs';
|
|
import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
|
|
import { mountActivityJobView } from './activity-job-view.mjs';
|
|
import { mountInspect } from './inspect.mjs';
|
|
import { mountReview } from './review.mjs';
|
|
import { createPromptSender } from './prompt-sender.mjs';
|
|
import { mountSystem } from './system.mjs';
|
|
import { onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
|
|
import { mountLibraryBanner } from './library-location.mjs';
|
|
import { detectJxlSupport } from './thumbnail-support.mjs';
|
|
|
|
const { createApiClient, resolveUrl } = window.ApiClient;
|
|
|
|
function element(tag, text, className) {
|
|
const node = document.createElement(tag);
|
|
if (text !== undefined) node.textContent = text;
|
|
if (className) node.className = className;
|
|
return node;
|
|
}
|
|
|
|
function legacyLink(route) {
|
|
const link = element('a', route.action, 'workspace-link');
|
|
// A native card (System, Q12) links its workspace hash; a legacy tool's page goes through the mount prefix.
|
|
link.href = route.native ? route.href : resolveUrl(route.href);
|
|
return link;
|
|
}
|
|
|
|
function mountWorkspace() {
|
|
const scope = createScope();
|
|
const api = createApiClient();
|
|
const main = document.getElementById('workspace-main');
|
|
const primaryNav = document.getElementById('primary-nav');
|
|
const savedNav = document.getElementById('saved-nav');
|
|
const systemNav = document.getElementById('system-nav');
|
|
scope.listen(document.querySelector('.skip'), 'click', event => {
|
|
event.preventDefault();
|
|
main.focus();
|
|
});
|
|
// D4: the concept's Theme select; "System" keeps following ComfyUI, then the OS.
|
|
const theme = mountWorkspaceTheme({ root: document.body, scope });
|
|
// #317: learn once whether this browser shows JPEG XL thumbnails (cards fall back to originals otherwise).
|
|
detectJxlSupport();
|
|
const preference = document.getElementById('theme');
|
|
preference.value = theme.preference;
|
|
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
|
|
// D6: ComfyUI ↗ opens the canvas; on Images with a resolved selection it sends that pair (a send is not an Activity job).
|
|
let images = null;
|
|
scope.cleanup(bindCanvasLink(document.querySelector('.topbar .canvas-link'), {
|
|
href: canvasHref(resolveUrl, window.location.pathname),
|
|
getSelectedPair: () => images?.selectedPair?.() ?? null,
|
|
sender: {open: (roles, trigger) => images?.sender.open(roles, trigger)},
|
|
}));
|
|
// The concept's nav(): icon, label text, then a `.nav-count` only for a loaded real count.
|
|
const navLinks = ids => ids.map(([id, icon]) => {
|
|
const route = routes.find(item => item.id === id);
|
|
const link = element('a');
|
|
link.append(createWorkspaceIcon(icon), document.createTextNode(route.label));
|
|
link.href = `#/${route.id}`;
|
|
link.dataset.route = route.id;
|
|
return link;
|
|
});
|
|
primaryNav.replaceChildren(...navLinks([['images', 'images'], ['library', 'library'], ['tags', 'tags'], ['inspect', 'upload']]));
|
|
// The concept's tools-and-system group: Activity, System, then (D28) the production Overview hub.
|
|
systemNav.replaceChildren(...navLinks([['activity', 'activity'], ['system', 'settings'], ['overview', 'overview']]));
|
|
// Activity (#249 Activity-1): one store of this session's jobs feeds the top-bar button and pill, the sidebar
|
|
// row's count and the Activity page, on every route.
|
|
const activity = createActivityStore({ api, scope, connect: url => new EventSource(resolveUrl(url)) });
|
|
mountActivityTopbar({ store: activity, scope });
|
|
activity.load();
|
|
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
|
|
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
|
|
const sidebar = document.querySelector('aside.sidebar');
|
|
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
|
|
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
|
|
let currentKey = null;
|
|
const markCurrent = () => {
|
|
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
|
if (link.dataset.route === currentKey) link.setAttribute('aria-current', 'page');
|
|
else link.removeAttribute('aria-current');
|
|
}
|
|
};
|
|
// SAVED VIEWS (#249 D16): the concept's saved-nav rows (search icon, name, no count), loaded with the shell and
|
|
// re-read after every change. /* D49 */ no "PROPOSED" badge. The first rows are the concept's Favorites (star icon,
|
|
// count, D51) and Needs review (review icon, the exact pending count, ruling R5), kept across every re-read so
|
|
// their counts stay.
|
|
const [favoritesLink, reviewLink] = navLinks([['favorites', 'star'], ['review', 'review']]);
|
|
savedNav.replaceChildren(favoritesLink, reviewLink);
|
|
const savedViews = createSavedViews(api, scope.signal);
|
|
scope.cleanup(savedViews.subscribe(({ views }) => {
|
|
savedNav.replaceChildren(favoritesLink, reviewLink, ...views.map(view => {
|
|
const link = element('a');
|
|
link.append(createWorkspaceIcon('search'), document.createTextNode(view.name));
|
|
link.href = savedViewHash(view.id);
|
|
link.dataset.route = `saved/${view.id}`;
|
|
return link;
|
|
}));
|
|
markCurrent();
|
|
}));
|
|
savedViews.load();
|
|
/** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */
|
|
const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q,
|
|
onSave: name => savedViews.create(page, name, state) });
|
|
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
|
|
const countRequests = new Map();
|
|
function refreshCounts(ids) {
|
|
for (const id of ids) {
|
|
countRequests.get(id)?.abort();
|
|
const request = new AbortController();
|
|
countRequests.set(id, request);
|
|
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
|
|
loadNavCounts(api, request.signal, (_, count) => {
|
|
if (!request.signal.aborted) showCount(id, count);
|
|
}, [id]);
|
|
}
|
|
}
|
|
function showCount(id, count) {
|
|
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
|
|
link?.querySelector('.nav-count')?.remove();
|
|
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
|
}
|
|
/** A count a page has just read itself: it replaces the badge, and an older read in flight no longer lands. */
|
|
function setCount(id, count) {
|
|
countRequests.get(id)?.abort();
|
|
countRequests.delete(id);
|
|
showCount(id, count);
|
|
}
|
|
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
|
|
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
|
|
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
|
|
// Starring or unstarring anywhere (Images, Favorites, Library media) re-reads the Favorites count (D51), once
|
|
// after a run of quick toggles settles rather than once per click (#249 M1).
|
|
let favoriteCountTimer = 0;
|
|
scope.cleanup(onFavoriteChange(() => {
|
|
clearTimeout(favoriteCountTimer);
|
|
favoriteCountTimer = setTimeout(() => refreshCounts(FAVORITE_WRITE_COUNTS), FAVORITE_COUNT_DELAY_MS);
|
|
}));
|
|
scope.cleanup(() => clearTimeout(favoriteCountTimer));
|
|
// A metadata scan that ends may have added items to Needs review: re-read its count once per ended scan.
|
|
const runningScans = new Set();
|
|
scope.cleanup(activity.subscribe(() => {
|
|
let ended = false;
|
|
for (const job of activity.jobs()) {
|
|
if (job.kind !== 'scan') continue;
|
|
if (job.state === 'running') runningScans.add(job.id);
|
|
else if (runningScans.delete(job.id)) ended = true;
|
|
}
|
|
if (ended) refreshCounts(REVIEW_WRITE_COUNTS);
|
|
}));
|
|
refreshCounts(NAV_COUNT_ROUTES);
|
|
// S9: a finished restore replaced the library. The cached visits are dropped, the counts and saved views read
|
|
// again, and the open page is shown again from the restored data; the Activity job view the restore opened stays.
|
|
scope.cleanup(onRestoreFinished(activity, () => {
|
|
forgetImagesVisit();
|
|
forgetLibraryVisit();
|
|
refreshCounts(NAV_COUNT_ROUTES);
|
|
savedViews.load();
|
|
if (currentKey !== 'activity') navigateTo(window.location.hash);
|
|
}));
|
|
// S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back:
|
|
// it opens again over the page shown now, with Restore (again) and Discard; leaving it any other way discards it.
|
|
scope.cleanup(onRestoreHandedBack(activity, review => {
|
|
if (viewScope && !viewScope.signal.aborted) showRestoreReview({ api, store: activity, scope: viewScope, ...review, again: true });
|
|
}));
|
|
let viewScope;
|
|
let firstRender = true;
|
|
// D62: a page whose teardown must wait (Inspect, while a video is still starting) holds the next route until it
|
|
// settles; the latest route wins.
|
|
let leaveGuard = null, renderToken = 0;
|
|
scope.cleanup(() => viewScope?.dispose());
|
|
|
|
// The mounted System page: another of its sections is shown in place, keeping the shell and the drafts.
|
|
let system = null;
|
|
|
|
function render(route) {
|
|
const token = ++renderToken;
|
|
const hold = leaveGuard?.();
|
|
if (hold) {
|
|
hold.then(() => { if (token === renderToken && !scope.signal.aborted) renderNow(route); });
|
|
return;
|
|
}
|
|
renderNow(route);
|
|
}
|
|
|
|
function renderNow(route) {
|
|
leaveGuard = null;
|
|
// A shared page dialog never stays open over the next route.
|
|
closeWorkspaceDialog();
|
|
if (route.id === 'system' && system && currentKey === 'system') {
|
|
system.show(route.section);
|
|
return;
|
|
}
|
|
system = null;
|
|
viewScope?.dispose();
|
|
images = null;
|
|
viewScope = createScope();
|
|
const view = viewScope;
|
|
currentKey = routeKey(route);
|
|
markCurrent();
|
|
if (route.id === 'saved') {
|
|
openSavedView(route, view);
|
|
return;
|
|
}
|
|
renderPage(route, view);
|
|
}
|
|
|
|
/** A saved view opens its page with its state; the heading, breadcrumb and title are the view's name. */
|
|
async function openSavedView(route, view) {
|
|
main.replaceChildren();
|
|
await savedViews.settled();
|
|
if (view.signal.aborted) return;
|
|
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
|
|
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
|
|
await savedViews.load();
|
|
if (view.signal.aborted) return;
|
|
}
|
|
const saved = savedViews.find(route.savedId);
|
|
if (saved) {
|
|
const page = routes.find(item => item.id === saved.page);
|
|
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
|
|
mountEditAction(saved, view);
|
|
return;
|
|
}
|
|
if (savedViews.status === 'ready') {
|
|
// A removed or unknown view: the shell's default page, without a history entry for the dead link.
|
|
window.location.replace('#/images');
|
|
return;
|
|
}
|
|
const {description} = renderHeading(main, {title:route.label});
|
|
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
|
|
description.setAttribute('role', 'alert');
|
|
const retry = createButton('Retry');
|
|
view.listen(retry, 'click', () => {
|
|
savedViews.load();
|
|
openSavedView(route, view);
|
|
});
|
|
main.append(retry);
|
|
}
|
|
|
|
// /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and
|
|
// retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page.
|
|
function mountEditAction(saved, view) {
|
|
// renderPage has just written the heading.
|
|
const edit = createButton('Edit');
|
|
pageHeading(main).actions.prepend(edit);
|
|
view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved,
|
|
onRename: async name => {
|
|
const renamed = await savedViews.rename(saved.id, name);
|
|
if (view.signal.aborted) return;
|
|
saved = renamed;
|
|
retitle(renamed.name);
|
|
},
|
|
onDelete: async () => {
|
|
await savedViews.remove(saved.id);
|
|
window.location.replace(`#/${saved.page}`);
|
|
} }));
|
|
}
|
|
|
|
function retitle(name) {
|
|
// Only the mounted view retitles (its rename returns once aborted), so its heading is present.
|
|
pageHeading(main).h1.textContent = name;
|
|
document.getElementById('workspace-breadcrumb').textContent = name;
|
|
document.title = `${name} · Prompt Manager`;
|
|
}
|
|
|
|
function renderPage(route, view, saved = null) {
|
|
document.title = `${routeTitle(route)} · Prompt Manager`;
|
|
// The concept's heading(); each view fills its description and actions.
|
|
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
|
|
const { description, h1 } = renderHeading(main, { title: route.label });
|
|
if (!firstRender) h1.focus();
|
|
firstRender = false;
|
|
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
|
if (route.id === 'images' || route.id === 'favorites') {
|
|
mountNewPromptAction(main, api, view);
|
|
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
|
|
onSaveView:saveView('images', view), activity});
|
|
return;
|
|
}
|
|
if (route.id === 'library') {
|
|
mountLibrary(main, api, view, {positiveId:route.positiveId, savedState:saved?.state, onSaveView:saveView('library', view)});
|
|
return;
|
|
}
|
|
if (route.id === 'tags') {
|
|
mountVocabulary(main, api, view);
|
|
return;
|
|
}
|
|
if (route.id === 'activity') {
|
|
// D56: `#/activity/<id>` is that job's own view; `#/activity` lists the session's jobs.
|
|
if (route.jobId) mountActivityJobView(main, { store: activity, scope: view, api, jobId: route.jobId });
|
|
else mountActivityPage(main, { store: activity, scope: view });
|
|
return;
|
|
}
|
|
if (route.id === 'review') {
|
|
// Needs review (ruling R5): a recorded decision re-reads the row's count, and the open page's total is the
|
|
// count (F3: a change the page hears of costs no count read).
|
|
mountReview(main, { api, scope: view, onDecision: () => refreshCounts(REVIEW_WRITE_COUNTS),
|
|
onTotal: total => setCount('review', total) });
|
|
return;
|
|
}
|
|
if (route.id === 'system') {
|
|
// The concept's System (app.js renderSystem): its heading description, section nav and Save row.
|
|
description.textContent = route.description;
|
|
system = mountSystem(main, api, view, { section: route.section, activity });
|
|
return;
|
|
}
|
|
if (route.id === 'inspect') {
|
|
// The concept's Inspect a file (app.js renderMetadata): a local file, read only in this browser.
|
|
description.textContent = route.description;
|
|
({ leaveGuard } = mountInspect(main, { parentScope: view, api, sender: createPromptSender({ scope: view }) }));
|
|
return;
|
|
}
|
|
description.textContent = 'A home for your prompts, images and creative history.';
|
|
const stats = element('section', undefined, 'overview-stats');
|
|
stats.setAttribute('aria-label', 'Library statistics');
|
|
main.append(stats);
|
|
const tools = element('section', undefined, 'surface');
|
|
tools.append(element('h2', 'Continue creating'), element('p', 'Your existing tools remain available while the new workspaces take shape.'));
|
|
const cards = element('div', undefined, 'tool-grid');
|
|
for (const target of routes.filter(item => item.href)) {
|
|
const card = element('article', undefined, 'tool-card');
|
|
card.append(element('h3', target.label), element('p', target.description, 'small'), legacyLink(target));
|
|
cards.append(card);
|
|
}
|
|
tools.append(cards);
|
|
main.append(tools);
|
|
async function loadStats() {
|
|
const status = element('p', 'Loading library statistics…');
|
|
status.setAttribute('role', 'status');
|
|
stats.replaceChildren(status);
|
|
try {
|
|
const result = await api.get('/prompt_manager/stats', { signal: view.signal });
|
|
if (view.signal.aborted) return;
|
|
const values = summarizeStats(result.stats);
|
|
const list = element('dl', undefined, 'stat-grid');
|
|
for (const [label, count] of values) {
|
|
const stat = element('div', undefined, 'stat');
|
|
stat.append(element('dt', label), element('dd', count.toLocaleString()));
|
|
list.append(stat);
|
|
}
|
|
stats.replaceChildren(list);
|
|
if (values.every(([, count]) => count === 0)) {
|
|
stats.append(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
|
|
}
|
|
} catch (error) {
|
|
if (view.signal.aborted) return;
|
|
const alert = element('p', 'Library statistics could not be loaded. Your saved data has not changed.');
|
|
alert.setAttribute('role', 'alert');
|
|
const retry = createButton('Retry');
|
|
view.listen(retry, 'click', loadStats);
|
|
stats.replaceChildren(alert, retry);
|
|
}
|
|
}
|
|
loadStats();
|
|
}
|
|
scope.cleanup(mountRouter(window, render));
|
|
scope.listen(window, 'pagehide', () => scope.dispose());
|
|
return () => scope.dispose();
|
|
}
|
|
|
|
mountWorkspace();
|
|
window.addEventListener('pageshow', event => {
|
|
if (event.persisted) mountWorkspace();
|
|
});
|