feat(library): Delete… on the selection bar, Show archived prompts and Restore (#443)

The Library bar's Delete… previews the selection and opens the shared
review: what is deleted (in no saved pair), archived (paired: hidden,
restorable) or kept (tagged __protected__), the safety backup and what
cannot be undone. Show archived prompts (N) lists archived prompts with
Restore on their bar; an archived prompt's page shows an Archived notice
with Restore. TESTING.md records D152; the route snapshot gains the three
workspace routes.
This commit is contained in:
Vito Sansevero
2026-10-28 18:51:06 -07:00
parent f30db7713d
commit 323f2617d8
14 changed files with 880 additions and 33 deletions
+93 -3
View File
@@ -2210,7 +2210,8 @@ Category". It is built only from the concept's Images parts:
input[type=checkbox]`); Home, End and the arrow keys still move between titles.
- `div#library-bulkbar` sits between the toolbar and the caption, empty until something
is checked, then the concept's `.bulkbar`: "N selected", Set category, Clear (D151
adds Add tags before Set category, in the Images bar's order). The
adds Add tags before Set category, in the Images bar's order; D152 adds Delete… before
Clear, and Restore alone on the archived shelf). The
selection is a set of prompt ids over the loaded shelf: windowed-out cards keep their
state, Load more keeps it, and any shelf reload (search, category, sort, tags, a
write) and leaving the page clear it. Clicking a checkbox toggles it and sets the
@@ -2291,6 +2292,95 @@ during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison no
Add tags); units `tests/js/workspace-library-selection.test.js`; Python
`tests/test_bulk_library_tags.py`. A Python change: ComfyUI needs a restart.
### Deleting and archiving selected prompts (#443)
**D152** (beyond the concept, on D148's bar; the concept archives a prompt from its recipe
editor's "Archive…", `app.js` 976, reviewed in `maintenancePlan`, 953–958, and maps the legacy
delete to a "reference-safe archive", 783): the Prompt library deletes or archives the
selected prompts in one reviewed step, replacing the legacy admin page's bulk and single
Delete, which refuses a whole batch when any prompt belongs to a saved pair (maintainer
rulings of 2026-10-28):
- The bar reads "N selected", [Select all N loaded], Add tags, Set category, **Delete…**
(the concept's `danger` class, as its Images bar's "Remove links…"), Clear.
- Delete… posts `{prompt_ids, expected_epoch}` to `POST
/prompt_manager/library/delete/preview`, which classifies each id in this order: not
found (missing or not a Library parent), protected (tagged `__protected__`), already
archived, archive (the positive or negative of any saved pair, archived pair or not),
delete (everything else). From the press until the review opens, every bar action, Clear
too, is `aria-disabled` and Delete… reads "Reviewing…" (a double press sends one preview).
- The review is the shared record-changing review (`openReviewDialog`), titled "Delete
selected prompts", "Archive selected prompts" or "Delete or archive selected prompts", its
`dl.meta-grid` rows Delete permanently and Archive (always), Protected (kept) with up to
three titles, Already archived, No longer exist, and, when something is deleted, Image
links removed and Backup ("Create and verify before deleting", or "Off: no safety backup
(System › Backup & export › Recovery policy)"); then `p.small` sentences: why paired
prompts are archived and how to restore them, what a delete removes and that it cannot be
undone except by restoring the safety backup (which returns the whole library to that
moment; kept among the last 10), that a later scan, new image or node run with the same
text adds a deleted prompt again, and how to unprotect. The primary is `danger`: "Delete D
prompts", "Archive A prompts" or "Delete D and archive A prompts". When nothing can change,
or the disk has no room for the promised backup, the review is close-only with the reason.
The concept's "Review maintenance plan" title, its demo note, Scope/Protected/Media on
disk rows and `ca-primary` start are replaced by these.
- Start posts the reviewed lists `{archive_ids, delete_ids, expected_epoch, backup}` to `POST
/prompt_manager/library/delete` while Delete… reads "Deleting…" (or "Archiving…"). The
server takes a `records` hold ("Delete prompts (Library)", refused 409 `busy` beside a scan,
restore, maintenance, move or Optimize), checks the disk (507 `insufficient-space`) and,
when a delete remains and the reviewed `backup` was "on" or the policy is on now, takes a
verified safety backup (500 `backup-failed`, nothing changed). One `BEGIN IMMEDIATE`
transaction then deletes only reviewed deletes still unpaired whose prompt row, tags,
title and image links are unchanged since the backup (exactly the legacy rows: image
link rows, cascaded tags, Library state and pair links of images, released workflow
documents; files never), archives the paired ones and any reviewed delete now paired, and
counts the rest (`already_archived`, `not_found`, `changed`). Archiving writes a
`prompt_archive` row, the prompt's `updated_at` and, when its stored hash is stale or
missing and no other row holds the canonical one, the canonical hash, so scans, the
monitor and node saves find the archived row instead of adding a copy. A changed epoch
answers 409 `storage-changed`; nothing is written.
- Outcomes: "Deleted D prompts." "Archived A prompts." with already archived, no longer
exist, changed and kept-protected sentences and "Safety backup saved: NAME (System ›
Backup & export)."; the shelf, categories, cached Library views and sidebar counts reload.
Storage restored: "The library was restored or moved since this page loaded. Nothing was
deleted or archived."; busy: "NAME is running. Nothing was changed; try again when it
finishes."; a failed backup or a short disk: the server's sentence; no answer, 502, 503 or
504: "The server did not confirm the change. The Library was reloaded: check the shelf and
Show archived prompts."; other refusals: "The selected prompts could not be changed. Try
again.", the selection kept.
- **Show archived prompts (N)** (heading actions, before Export, `aria-pressed`; N the
archived prompts matching the filters, `archived_total`; the shelf counterpart of #235's
Show archived) lists only archived prompts (`GET /prompt_manager/library?shelf=archived`,
a cursor never crosses shelves), each card with an "Archived" `.chip`, the caption "N
archived prompts · M negative variants hidden with them", the bar "N selected", Restore,
Clear, and "No archived prompts." / "No archived prompts match." when empty. The pressed
state stays with the shelf visit, never in a saved view; Browse and a saved view open the
shelf. Restore posts `{prompt_ids, expected_epoch}` to `POST
/prompt_manager/library/restore` (every requested archived id, protected or not): "Restored
N prompts to the Prompt library."
- An archived prompt's page shows the concept's `.notice` ("Archived" / "This prompt and its
negative variants are hidden from the Prompt library; nothing was removed.") with Restore;
everything else on the page works. New prompt or Add negative on an archived prompt's
text keeps it archived and toasts "This text is an archived prompt. Restore it to show it
in the Prompt library again."
- Archived prompts: hidden from the shelf, its search, `total`, the sidebar count, Tags &
categories' prompt counts (a value only archived prompts hold stays listed with 0) and
System › Overview and the restore review's positive counts; still counted by rename and
delete impacts and usage statistics; still shown as another prompt's negative variant, on
recorded runs and Images, by the legacy pages and the ComfyUI node's search; still reached
by run counting, scans and the monitor; never pruned by maintenance (a new protection,
"archived in the Library"); exported with `archived: true`. Older builds ignore the table
and show them again; the rows stay.
- Deviation from legacy: a selected `__protected__` prompt is never deleted or archived (the
legacy delete removed it); it is listed in the review and the toast.
Tests: `tests/browser/workspace-library-delete.spec.mjs` (mixed review and result on the
server, Cancel, Show archived prompts and Restore from the bar and the notice, double press,
failed backup, busy, unconfirmed, restore fence); `tests/browser/library-delete-concept.spec.mjs`
(the review against the concept's `maintenancePlan`, the bar's Delete… against the concept's
"Remove links…"); units `tests/js/workspace-library-delete.test.js`; Python
`tests/test_prompt_archive_schema.py`, `tests/test_bulk_library_delete.py`,
`tests/test_library_delete_routes.py`. A Python change: ComfyUI needs a restart.
### Prompt history page and recorded deviations (#249 PR F-a)
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
@@ -3401,8 +3491,8 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
- **D104** (Backup & export, S9, Q4): Recovery policy keeps the concept's "Proposed" chip.
"Create a safety backup before maintenance" is the server's `safety_backup`
(`maintenance.safety_backup`) policy. Its line says what it does ("Keep a verified copy
of the database on disk before maintenance, cleanups and LoRA imports change records. A
restore always keeps one."); the concept's "Preview a verified backup before applying
of the database on disk before maintenance, cleanups, LoRA imports and prompt deletes
change records. A restore always keeps one."); the concept's "Preview a verified backup before applying
operations that change records." describes nothing the server does. It is drafted behind the Save row and saved with
`expected_revision` (a configuration changed elsewhere is the conflict status); it is
disabled with "Server settings could not be read." when the read failed. "Scheduled
+6 -1
View File
@@ -44,7 +44,12 @@ export function mapLibraryConcept(reference) {
// and "Used today". The two-line clamp is
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
return {...reference,library:{...library,toolbar,
// D152 (#443): Show archived prompts (N) comes first in the heading actions, a plain button like Export (the concept
// fixture has nothing archived, so N is 0).
const actions=reference.heading.actions;
const plain=actions.buttons.find(button=>!button.primary);
const heading={...reference.heading,actions:{...actions,buttons:[{...plain,text:'Show archived prompts (0)'},...actions.buttons]}};
return {...reference,heading,library:{...library,toolbar,
// D35: the caption uses the singular for one.
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})),
@@ -0,0 +1,147 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
// #443 D152: the Library's Delete… review against the concept's own reviewed archive: the recipe editor's "Archive…"
// (app.js 976), which opens `maintenancePlan("archive <title>")` (953–958, 1309–1310). The concept's shell, grid tokens,
// `p.small` and actions are kept; its title, demo note, rows, note text and `ca-primary` start are replaced by D152's,
// and the start takes the concept's `danger` class, measured on the editor's own "Archive…". The bar's Delete… is
// compared with the concept Images bar's danger action, "Remove links…" (app.js 432).
test.use({ locale: 'en-US', timezoneId: 'UTC' });
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
page.conceptErrors = [];
page.on('pageerror', error => page.conceptErrors.push(error.message));
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.conceptErrors).toEqual([]);
});
/** Runs in the page: the open review dialog's title, body tree, grid, notes and actions with the concept's tokens. */
function measureReview() {
const dialog = document.querySelector('dialog#modal[open]');
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const body = dialog.querySelector('#modal-body');
const grid = body.querySelector(':scope > dl.meta-grid');
const notes = [...body.querySelectorAll(':scope > p.small')];
const actions = body.querySelector(':scope > .dialog-actions');
return {
title: text(dialog.querySelector('#modal-title')),
tree: [...body.children].map(name).filter(item => item !== 'p.demo-note' && item !== 'h3'),
grid: { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
notes: notes.map(note => ({ text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') })),
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
};
}
/** Runs in the page: a button's class and tokens (the concept's danger button, or production's). */
function measureButton(selector) {
const node = document.querySelector(selector);
const style = getComputedStyle(node);
return { className: node.className, ...Object.fromEntries(['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop',
'paddingLeft', 'borderTopColor', 'borderTopLeftRadius', 'height'].map(key => [key, key === 'height'
? `${Math.round(node.getBoundingClientRect().height * 100) / 100}px` : style[key]])) };
}
async function conceptReview(page, theme) {
await openConcept(page, 'prompt/alpine', theme);
await page.locator('#main .heading-actions').getByRole('button', { name: 'Edit metadata', exact: true }).click();
const archive = page.locator('dialog#modal[open] .dialog-actions button', { hasText: 'Archive…' });
await expect(archive).toBeVisible();await page.mouse.move(0, 0);
const danger = await page.evaluate(measureButton, 'dialog#modal[open] .dialog-actions button.danger');
await archive.click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
await page.mouse.move(0, 0);
return { danger, review: await page.evaluate(measureReview) };
}
async function seedMixed(request) {
// One unpaired prompt beside the concept's paired ones, so the review deletes one and archives one.
await expect(await request.post('/prompt_manager/save', { data: { text: 'Unpaired study', category: 'Studies' } })).toBeOK();
const items = (await (await request.get('/prompt_manager/library?limit=100')).json()).items;
return { unpaired: items.find(item => item.text === 'Unpaired study'), paired: items.find(item => item.variant_count > 0) };
}
async function productionReview(page, request, theme) {
const { unpaired, paired } = await seedMixed(request);
await openProduction(page, 'library', theme);
for (const item of [unpaired, paired]) await page.locator(`article[data-prompt-id="${item.id}"] .image-overlays input`).check();
await page.locator('#library-bulkbar .bulkbar').getByRole('button', { name: 'Delete…', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Delete or archive selected prompts');
await page.mouse.move(0, 0);
return page.evaluate(measureReview);
}
/** D152's changes to the concept's review; everything else must equal the concept. */
function mapDeleteConcept({ danger, review }) {
const [cancel] = review.actions.buttons;
const note = review.notes.at(-1);
return {
...review,
title: 'Delete or archive selected prompts',
tree: ['dl.meta-grid', 'p.small', 'p.small', 'div.dialog-actions'],
grid: { ...review.grid, rows: [['Delete permanently', '1 prompt not in a saved pair'],
['Archive', '1 prompt with negative variants: hidden, restorable'],
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting']] },
notes: [
{ ...note, text: 'Prompts with negative variants, or used as one, are archived instead of deleted, so their variants '
+ 'and runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, '
+ 'select them and press Restore.' },
// A second paragraph takes the concept's own `p + p` margin (style.css 93-95).
{ ...note, marginTop: '10px', text: "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and "
+ 'image links. Deleted prompts cannot be brought back one by one: the only way back is to restore the safety '
+ 'backup taken first (System › Backup & export), which returns the whole library to that moment. It is kept '
+ 'among the last 10 safety backups. Image files, favorites, collections and recorded runs stay. A later scan, a '
+ 'new image or a node run with the same text adds it again as a new prompt.' },
],
actions: { ...review.actions, tree: [review.actions.tree[0], 'button.danger'], buttons: [cancel, { text: 'Delete 1 and archive 1 prompt', className: danger.className,
backgroundColor: danger.backgroundColor, color: danger.color, fontSize: danger.fontSize, paddingTop: danger.paddingTop,
paddingLeft: danger.paddingLeft, borderTopColor: danger.borderTopColor }] },
};
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`the Delete… review matches the concept's reviewed archive ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const concept = await conceptReview(page, theme);
expect(concept.review.tree).toEqual(['dl.meta-grid', 'p.small', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-archive-review-${theme}-${width}.png`) });
const production = await productionReview(page, request, theme);
await page.screenshot({ path: testInfo.outputPath(`production-delete-review-${theme}-${width}.png`) });
expect(diffInventory(mapDeleteConcept(concept), production)).toEqual([]);
});
}
test('the Delete… review comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const concept = await conceptReview(page, 'dark');
await productionReview(page, request, 'dark');
const expected = mapDeleteConcept(concept);
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
expect(await paths()).toEqual([]);
await mutate(page, { css: '#modal-body .dialog-actions .danger { padding-left: 2px }' });
expect(await paths()).toContain('actions.buttons.1.paddingLeft');
});
test('the bar\'s Delete… matches the concept Images bar\'s danger action', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await openConcept(page, 'images', 'dark');
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0);
const reference = await page.evaluate(measureButton, '#bulkbar .bulkbar button.danger');
const { paired } = await seedMixed(request);
await openProduction(page, 'library', 'dark');
await page.locator(`article[data-prompt-id="${paired.id}"] .image-overlays input`).check();
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0);
expect(await page.evaluate(measureButton, '#library-bulkbar .bulkbar button.danger')).toEqual(reference);
});
@@ -87,8 +87,8 @@ function measureBackups(mainSelector) {
}
const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
'records. A restore always keeps one.';
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA imports and ' +
'prompt deletes change records. A restore always keeps one.';
/**
* D103: "Back up" (the concept's "Simulate backup"), the status line from /backup/status, "Export JSON" (its "Export
@@ -0,0 +1,169 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
// #443 D152: Delete… on the Library bar deletes unpaired prompts, archives paired ones (restorable), keeps protected ones.
const route='/prompt_manager/workspace#/library';
const PREVIEW='/prompt_manager/library/delete/preview';
const DELETE='/prompt_manager/library/delete';
const cards=page=>page.locator('article[data-prompt-id]');
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
const bar=page=>page.locator('#library-bulkbar > .bulkbar');
const toast=page=>page.locator('.toast');
const review=page=>page.getByRole('dialog');
const showArchived=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:/^Show archived prompts/});
async function items(request,shelf='active') {
const query=shelf==='archived'?'&shelf=archived':'';
return (await(await request.get(`/prompt_manager/library?limit=100${query}`)).json()).items;
}
const byText=(list,text)=>list.find(item=>item.text===text);
function recordPosts(page,path) {
const posts=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname===path)posts.push(item.postDataJSON());});
return posts;
}
async function openReview(page) {
await bar(page).getByRole('button',{name:'Delete…',exact:true}).click();
await expect(review(page)).toBeVisible();
}
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:2}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('Delete… reviews the selection, deletes the unpaired, archives the paired and keeps the protected',async({page,request})=>{
const before=await items(request);
const dog=byText(before,'Dog landscape'),cat=byText(before,'Cat portrait'),guarded=byText(before,'Library extra 0000');
await expect(await request.post('/prompt_manager/bulk/tags',{data:{prompt_ids:[guarded.id],tags:['__protected__']}})).toBeOK();
const previews=recordPosts(page,PREVIEW),writes=recordPosts(page,DELETE);
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
for(const item of [dog,cat,guarded]) await box(page,item.id).check();
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${before.length} loaded`,'Add tags','Set category','Delete…','Clear']);
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).toHaveClass(/danger/);
await openReview(page);
await expect(review(page)).toHaveAccessibleName('Delete or archive selected prompts');
const grid=review(page).locator('dl.meta-grid > div');
await expect(grid.locator('dt')).toHaveText(['Delete permanently','Archive','Protected (kept)','Image links removed','Backup']);
await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair','1 prompt with negative variants: hidden, restorable',
'1: Library extra 0000','0 images will no longer show these prompts (files stay on disk)','Create and verify before deleting']);
await expect(review(page).locator('p.small')).toHaveCount(3);
await expect(review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true})).toHaveClass(/danger/);
// Cancel writes nothing and releases the bar.
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await expect(review(page)).toHaveCount(0);expect(writes).toEqual([]);
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
await openReview(page);
await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Archived 1 prompt\. 1 protected prompt was kept\. Safety backup saved: \S+\.backup_\S+ \(System › Backup & export\)\.$/);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
await expect(card(page,dog.id)).toHaveCount(0);await expect(card(page,cat.id)).toHaveCount(0);await expect(card(page,guarded.id)).toHaveCount(1);
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
// One preview per review opened (Cancel, then Delete…).
const reviewed={prompt_ids:[dog.id,cat.id,guarded.id],expected_epoch:epoch};
expect(previews).toEqual([reviewed,reviewed]);
expect(writes).toEqual([{archive_ids:[cat.id],delete_ids:[dog.id],expected_epoch:epoch,backup:'on'}]);
const after=await items(request);
expect(byText(after,'Dog landscape')).toBeUndefined();expect(byText(after,'Cat portrait')).toBeUndefined();
expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]);
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
});
test('Show archived prompts lists archived prompts, and Restore on its bar returns them',async({page,request})=>{
const before=await items(request);const cat=byText(before,'Cat portrait'),dual=byText(before,'Dual role portrait');
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
await box(page,cat.id).check();await box(page,dual.id).check();
await openReview(page);
await expect(review(page)).toHaveAccessibleName('Archive selected prompts');
await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click();
await expect(toast(page)).toHaveText('Archived 2 prompts.');
await expect(cards(page)).toHaveCount(before.length-2);
await expect(showArchived(page)).toHaveAttribute('aria-pressed','false');
await showArchived(page).click();
await expect(showArchived(page)).toHaveAttribute('aria-pressed','true');
await expect(cards(page)).toHaveCount(2);
await expect(page.locator('.section-caption > span').first()).toHaveText(/^2 archived prompts · \d+ negative variants? hidden with them$/);
await expect(card(page,cat.id).locator('.card-title .chip',{hasText:'Archived'})).toHaveCount(1);
await box(page,cat.id).check();
await expect(bar(page).getByRole('button')).toHaveText(['Select all 2 loaded','Restore','Clear']);
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
await expect(cards(page)).toHaveCount(1);
await showArchived(page).click();
await expect(cards(page)).toHaveCount(before.length-1);
await expect(card(page,cat.id)).toHaveCount(1);
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
// An empty archived view says so.
await showArchived(page).click();await box(page,dual.id).check();
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
await expect(page.locator('.library-status')).toHaveText('No archived prompts.');
});
test('an archived prompt\'s page says so and restores it from its notice',async({page,request})=>{
const cat=byText(await items(request),'Cat portrait');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
await expect(await request.post(DELETE,{data:{archive_ids:[cat.id],delete_ids:[],expected_epoch:epoch,backup:'off'}})).toBeOK();
await page.goto(`/prompt_manager/workspace#/library/${cat.id}`);
const notice=page.locator('.library-archived-notice');
await expect(notice.locator('h2')).toHaveText('Archived');
await expect(notice.locator('p').first()).toHaveText('This prompt and its negative variants are hidden from the Prompt library; nothing was removed.');
await notice.getByRole('button',{name:'Restore this prompt to the Prompt library'}).click();
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
await expect(notice).toHaveCount(0);
expect(byText(await items(request),'Cat portrait')?.id).toBe(cat.id);
});
test('a double press sends one preview, and the bar holds while it is reviewed',async({page,request})=>{
const dog=byText(await items(request),'Dog landscape');
const previews=recordPosts(page,PREVIEW);
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route(`**${PREVIEW}`,async item=>{await held;await item.fallback();});
await page.goto(route);await box(page,dog.id).check();
const remove=bar(page).getByRole('button',{name:/^(Delete…|Reviewing…)$/});
await remove.click();
await expect(remove).toHaveText('Reviewing…');await expect(remove).toHaveAttribute('aria-disabled','true');
await expect(bar(page).getByRole('button',{name:'Clear',exact:true})).toHaveAttribute('aria-disabled','true');
await remove.click({force:true});
release();
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
expect(previews).toHaveLength(1);
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
});
test('a restore since the page loaded, a failed backup, a busy job and an unconfirmed answer each say what happened',async({page,request})=>{
const before=await items(request);const dog=byText(before,'Dog landscape');
await armUnseenRestores(page);
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
// Failed backup: nothing changed, the selection stays.
await page.route(`**${DELETE}`,item=>item.fulfill({status:500,contentType:'application/json',
body:JSON.stringify({success:false,code:'backup-failed',error:'The safety backup could not be taken and verified; nothing was changed.'})}));
await box(page,dog.id).check();await openReview(page);
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('The safety backup failed. The safety backup could not be taken and verified; nothing was changed.');
await expect(box(page,dog.id)).toBeChecked();
// Busy: names the job, nothing changed.
await page.unroute(`**${DELETE}`);
await page.route(`**${DELETE}`,item=>item.fulfill({status:409,contentType:'application/json',
body:JSON.stringify({success:false,code:'busy',error:'busy',running:{id:'abc',title:'Scan'}})}));
await openReview(page);await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('Scan is running. Nothing was changed; try again when it finishes.');
// Unconfirmed: the shelf reloads.
await page.unroute(`**${DELETE}`);
await page.route(`**${DELETE}`,item=>item.abort());
await openReview(page);await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('The server did not confirm the change. The Library was reloaded: check the shelf and Show archived prompts.');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
await page.unroute(`**${DELETE}`);
// A restore the page has not seen: the review opened before it is refused on the server.
await box(page,dog.id).check();await openReview(page);
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
await expect(toast(page)).toHaveText('The library was restored or moved since this page loaded. Nothing was deleted or archived.');
expect(byText(await items(request),'Dog landscape')).toBeDefined();
});
@@ -62,7 +62,8 @@ test('Set category writes the selected prompts on the server, reloads the shelf
await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected');
await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected');
// Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling).
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Clear']);
// D152 (#443) adds Delete… before Clear.
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Delete…','Clear']);
await setCategory(page,' Studio set ');
await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.');
await expect(dialog(page)).toHaveCount(0);
@@ -287,7 +288,7 @@ test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed on
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1});
await addTags(page,'everyone');
for(const name of ['Add tags','Set category',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
for(const name of ['Add tags','Set category','Delete…','Clear',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
release();
await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`);
expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1);
@@ -11,8 +11,8 @@ const STATUS_URL = '/prompt_manager/backup/status';
const PREVIEW_URL = '/prompt_manager/restore/preview';
const APPLY_URL = '/prompt_manager/restore/apply';
const WHEN = { dateStyle: 'medium', timeStyle: 'short' };
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
'records. A restore always keeps one.';
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA imports and ' +
'prompt deletes change records. A restore always keeps one.';
test.use({ locale: 'en-US', timezoneId: 'UTC' });
+3
View File
@@ -131,6 +131,9 @@
"POST /prompt_manager/jobs/thumbnails",
"POST /prompt_manager/jobs/{job_id}/cancel",
"POST /prompt_manager/library",
"POST /prompt_manager/library/delete",
"POST /prompt_manager/library/delete/preview",
"POST /prompt_manager/library/restore",
"POST /prompt_manager/library/{positive_id}/variants",
"POST /prompt_manager/logs/config",
"POST /prompt_manager/logs/truncate",
+126
View File
@@ -0,0 +1,126 @@
const test = require('node:test');
const assert = require('node:assert/strict');
// #443 D152: the Prompt library's Delete… review (delete unpaired, archive paired, keep protected) and Restore.
const source = import('../../web/js/workspace/library-delete.mjs');
const preview = (patch = {}) => ({success: true, library_epoch: 'e', delete: [], archive: [], protected: [],
already_archived: 0, not_found: 0, image_links: 0, backup: 'on', disk: null, ...patch});
test('the routes are the workspace ones, never the legacy bulk delete', async () => {
const {PREVIEW_URL, DELETE_URL, RESTORE_URL} = await source;
assert.equal(PREVIEW_URL, '/prompt_manager/library/delete/preview');
assert.equal(DELETE_URL, '/prompt_manager/library/delete');
assert.equal(RESTORE_URL, '/prompt_manager/library/restore');
});
test('the title, primary and body follow what the review will do', async () => {
const {reviewOf, reviewTitle, primaryLabel, deleteBody} = await source;
const both = reviewOf(preview({delete: [1, 2], archive: [3]}));
assert.equal(reviewTitle(both), 'Delete or archive selected prompts');
assert.equal(primaryLabel(both), 'Delete 2 and archive 1 prompt');
assert.deepEqual(deleteBody(both), {archive_ids: [3], delete_ids: [1, 2], expected_epoch: 'e', backup: 'on'});
const onlyDelete = reviewOf(preview({delete: [1]}));
assert.equal(reviewTitle(onlyDelete), 'Delete selected prompts');
assert.equal(primaryLabel(onlyDelete), 'Delete 1 prompt');
const onlyArchive = reviewOf(preview({archive: [4, 5]}));
assert.equal(reviewTitle(onlyArchive), 'Archive selected prompts');
assert.equal(primaryLabel(onlyArchive), 'Archive 2 prompts');
});
test('the grid shows each row only when it applies, with protected titles in selection order', async () => {
const {reviewOf, reviewGrid} = await source;
const names = {7: 'Seven', 8: 'Eight', 9: 'Nine', 10: 'Ten'};
const review = reviewOf(preview({delete: [1], archive: [2], protected: [7, 8, 9, 10], already_archived: 1,
not_found: 2, image_links: 3}));
assert.deepEqual(reviewGrid(review, id => names[id]), [
['Delete permanently', '1 prompt not in a saved pair'],
['Archive', '1 prompt with negative variants: hidden, restorable'],
['Protected (kept)', '4: Seven, Eight, Nine and 1 more'],
['Already archived', '1'],
['No longer exist', '2'],
['Image links removed', '3 images will no longer show these prompts (files stay on disk)'],
['Backup', 'Create and verify before deleting'],
]);
const archiveOnly = reviewOf(preview({archive: [2]}));
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Delete permanently', 'Archive']);
const off = reviewOf(preview({delete: [1], backup: 'off'}));
assert.equal(reviewGrid(off).at(-1)[1], 'Off: no safety backup (System › Backup & export › Recovery policy)');
});
test('the sentences explain archive, irreversibility per backup policy, re-creation and protection', async () => {
const {reviewOf, reviewSentences} = await source;
const [archive, remove, guarded] = reviewSentences(reviewOf(preview({delete: [1], archive: [2], protected: [3]})));
assert.match(archive, /^Prompts with negative variants, or used as one, are archived instead of deleted/);
assert.match(archive, /Archived prompts cannot be deleted\. To bring them back, press Show archived prompts/);
assert.match(remove, /the only way back is to restore the safety backup taken first \(System › Backup & export\), which returns the whole library to that moment\. It is kept among the last 10 safety backups\./);
assert.match(remove, /A later scan, a new image or a node run with the same text adds it again as a new prompt\.$/);
assert.equal(guarded, 'Protected prompts are tagged __protected__; remove the tag to delete them.');
const [off] = reviewSentences(reviewOf(preview({delete: [1], backup: 'off'})));
assert.match(off, /Deleted prompts cannot be brought back\. Image files/);
assert.doesNotMatch(off, /safety backup/);
});
test('a review that cannot start says why: nothing to change, or no room for the backup', async () => {
const {reviewOf, blockedReason, COPY} = await source;
assert.equal(blockedReason(reviewOf(preview({protected: [1]}))), COPY.none);
assert.equal(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: false, message: 'Not enough. Nothing was changed.'}}))),
'Not enough. Nothing was changed.');
assert.match(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: false}}))), /not enough free space/);
assert.equal(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: true}}))), null);
assert.equal(blockedReason(reviewOf(preview({archive: [1]}))), null);
});
test('a delete outcome counts each part, singular and plural, and names the backup', async () => {
const {reviewOf, deleteOutcome} = await source;
const review = reviewOf(preview({delete: [1, 2], archive: [3], protected: [9]}));
assert.deepEqual(deleteOutcome({success: true, deleted: 2, archived: 3, already_archived: 0, not_found: 0, changed: 0,
backup: 'prompts.db.backup_x'}, null, review), {committed: true, reload: true,
toast: 'Deleted 2 prompts. Archived 3 prompts. 1 protected prompt was kept. Safety backup saved: prompts.db.backup_x (System › Backup & export).'});
assert.equal(deleteOutcome({success: true, deleted: 1, archived: 0, already_archived: 1, not_found: 1, changed: 1,
backup: null}, null, reviewOf(preview())).toast,
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was kept.');
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0, already_archived: 2, not_found: 3, changed: 2,
backup: null}, null, reviewOf(preview({protected: [1, 2]}))).toast,
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were kept. 2 protected prompts were kept. The Library was reloaded.');
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0}, null, reviewOf(preview())).committed, false);
});
test('a failed delete says nothing changed, or that it may have, and keeps or reloads the selection', async () => {
const {deleteOutcome, COPY} = await source;
assert.deepEqual(deleteOutcome(null, {status: 409, code: 'storage-changed'}), {toast: COPY.storage, committed: false, reload: true});
assert.equal(COPY.storage, 'The library was restored or moved since this page loaded. Nothing was deleted or archived.');
assert.deepEqual(deleteOutcome(null, {status: 409, code: 'busy', running: {title: 'Scan'}}),
{toast: 'Scan is running. Nothing was changed; try again when it finishes.', committed: false, reload: false});
assert.deepEqual(deleteOutcome(null, {status: 500, code: 'backup-failed', message: 'The safety backup could not be taken and verified; nothing was changed.'}),
{toast: 'The safety backup failed. The safety backup could not be taken and verified; nothing was changed.', committed: false, reload: false});
assert.deepEqual(deleteOutcome(null, {status: 507, code: 'insufficient-space', message: 'Not enough free disk space. Nothing was changed.'}),
{toast: 'Not enough free disk space. Nothing was changed.', committed: false, reload: false});
for (const failure of [new TypeError('Failed to fetch'), {status: 502}, {status: 503}, {status: 504}]) {
assert.deepEqual(deleteOutcome(null, failure), {toast: COPY.unconfirmed, committed: true, reload: true});
}
assert.deepEqual(deleteOutcome(null, {status: 400}), {toast: COPY.failed, committed: false, reload: false});
});
test('a failed preview reloads only after a restore; Restore outcomes have their own words', async () => {
const {previewOutcome, restoreOutcome, COPY} = await source;
assert.deepEqual(previewOutcome({status: 409, code: 'storage-changed'}), {toast: COPY.storage, committed: false, reload: true});
assert.deepEqual(previewOutcome({status: 500}), {toast: COPY.reviewFailed, committed: false, reload: false});
assert.equal(previewOutcome({status: 409, code: 'busy', running: {title: 'Optimize'}}).toast,
'Optimize is running. Nothing was changed; try again when it finishes.');
assert.deepEqual(restoreOutcome({success: true, restored: 2, unchanged: 1, not_found: 1}, null),
{toast: 'Restored 2 prompts to the Prompt library. 1 was not archived. 1 no longer exists.', committed: true, reload: true});
assert.deepEqual(restoreOutcome({success: true, restored: 0, unchanged: 2, not_found: 0}, null),
{toast: 'No prompt was restored. 2 were not archived.', committed: false, reload: true});
assert.deepEqual(restoreOutcome(null, {status: 409, code: 'storage-changed'}), {toast: COPY.restoreStorage, committed: false, reload: true});
assert.deepEqual(restoreOutcome(null, {status: 503}), {toast: COPY.restoreUnconfirmed, committed: true, reload: true});
assert.deepEqual(restoreOutcome(null, {status: 400}), {toast: COPY.restoreFailed, committed: false, reload: false});
});
test('reviewOf keeps only well-formed ids and counts from an answer', async () => {
const {reviewOf} = await source;
const review = reviewOf({library_epoch: 3, delete: [1, 'x', -2, 2], archive: null, protected: [1.5], already_archived: -1,
not_found: 'x', image_links: 2, backup: 'maybe'});
assert.deepEqual(review, {epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
imageLinks: 2, backup: 'on', disk: null});
});
+199
View File
@@ -0,0 +1,199 @@
import { countOf } from './plural.mjs';
import { openCloseOnlyReview, openReviewDialog } from './workspace-dialogs.mjs';
/**
* The Prompt library's Delete… review and outcomes (#443, D152, beyond the concept). The concept maps the legacy prompt
* delete to a "reference-safe archive" reviewed in its `maintenancePlan` dialog; the workspace offers Delete… on the
* D148 bar, whose review (the shared `openReviewDialog`) says what happens to each selected prompt: deleted (in no saved
* pair), archived (paired: hidden, restorable) or kept (tagged `__protected__`). The server executes the reviewed
* lists (`POST /prompt_manager/library/delete`), taking a verified safety backup before any delete when the policy is
* on. Restore (`/library/restore`) puts archived prompts back on the shelf.
*/
export const PREVIEW_URL = '/prompt_manager/library/delete/preview';
export const DELETE_URL = '/prompt_manager/library/delete';
export const RESTORE_URL = '/prompt_manager/library/restore';
export const REVIEWING = 'Reviewing…';
export const DELETE_LABEL = 'Delete…';
export const RESTORE_LABEL = 'Restore';
// Answers that may follow a committed write: no status, or a gateway's.
const UNCONFIRMED_STATUSES = new Set([502, 503, 504]);
const NOTHING = 'Nothing was deleted or archived.';
export const COPY = Object.freeze({
reviewFailed: 'The selected prompts could not be reviewed. Try again.',
storage: `The library was restored or moved since this page loaded. ${NOTHING}`,
unconfirmed: 'The server did not confirm the change. The Library was reloaded: check the shelf and Show archived prompts.',
failed: 'The selected prompts could not be changed. Try again.',
nothing: `${NOTHING} The Library was reloaded.`,
none: 'None of the selected prompts can be deleted or archived.',
restoreStorage: 'The library was restored or moved since this page loaded. No prompt was returned to the Library.',
restoreUnconfirmed: 'Returning the prompts could not be confirmed. The Library was reloaded.',
restoreFailed: 'The prompts could not be returned to the Library. Try again.',
archivedNotice: 'This prompt and its negative variants are hidden from the Prompt library; nothing was removed.',
archivedText: 'This text is an archived prompt. Restore it to show it in the Prompt library again.',
});
const count = value => (Number.isSafeInteger(value) && value >= 0 ? value : 0);
const ids = value => (Array.isArray(value) ? value.filter(id => Number.isSafeInteger(id) && id > 0) : []);
const were = (value, singular, plural) => `${value.toLocaleString('en-US')} ${value === 1 ? singular : plural}`;
/** The preview's classes as counts and id lists, whatever else the answer holds. */
export function reviewOf(preview) {
return {
epoch: typeof preview?.library_epoch === 'string' ? preview.library_epoch : null,
delete: ids(preview?.delete), archive: ids(preview?.archive), protected: ids(preview?.protected),
alreadyArchived: count(preview?.already_archived), notFound: count(preview?.not_found),
imageLinks: count(preview?.image_links), backup: preview?.backup === 'off' ? 'off' : 'on',
disk: preview?.disk && typeof preview.disk === 'object' ? preview.disk : null,
};
}
export function reviewTitle(review) {
if (!review.archive.length) return 'Delete selected prompts';
if (!review.delete.length) return 'Archive selected prompts';
return 'Delete or archive selected prompts';
}
/** "Title A, Title B, Title C and 2 more": the protected prompts the page has loaded, in selection order. */
export function protectedNames(review, titleOf) {
const names = review.protected.map(titleOf).filter(name => typeof name === 'string' && name);
const shown = names.slice(0, 3).join(', ');
const more = review.protected.length - Math.min(3, names.length);
return more > 0 ? `${shown}${shown ? ' and ' : ''}${more.toLocaleString('en-US')} more` : shown;
}
/** The review's `[term, value]` rows (design §7.2); rows that do not apply are left out. */
export function reviewGrid(review, titleOf = () => null) {
const rows = [
['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`],
['Archive', `${countOf(review.archive.length, 'prompt')} with negative variants: hidden, restorable`],
];
if (review.protected.length) {
const names = protectedNames(review, titleOf);
rows.push(['Protected (kept)', `${review.protected.length.toLocaleString('en-US')}${names ? `: ${names}` : ''}`]);
}
if (review.alreadyArchived) rows.push(['Already archived', review.alreadyArchived.toLocaleString('en-US')]);
if (review.notFound) rows.push(['No longer exist', review.notFound.toLocaleString('en-US')]);
if (review.delete.length) {
rows.push(['Image links removed', `${countOf(review.imageLinks, 'image')} will no longer show these prompts (files stay on disk)`]);
rows.push(['Backup', review.backup === 'on' ? 'Create and verify before deleting'
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
}
return rows;
}
/** The review's sentences, in order, each when it applies. */
export function reviewSentences(review) {
const sentences = [];
if (review.archive.length) {
sentences.push('Prompts with negative variants, or used as one, are archived instead of deleted, so their variants ' +
'and runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, ' +
'select them and press Restore.');
}
if (review.delete.length) {
const undo = review.backup === 'on'
? ' Deleted prompts cannot be brought back one by one: the only way back is to restore the safety backup taken ' +
'first (System › Backup & export), which returns the whole library to that moment. It is kept among the last ' +
'10 safety backups.'
: ' Deleted prompts cannot be brought back.';
sentences.push("Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and " +
`image links.${undo} Image files, favorites, collections and recorded runs stay. A later scan, a new image or a ` +
'node run with the same text adds it again as a new prompt.');
}
if (review.protected.length) sentences.push('Protected prompts are tagged __protected__; remove the tag to delete them.');
return sentences;
}
export function primaryLabel(review) {
const deletes = review.delete.length, archives = review.archive.length;
if (deletes && archives) return `Delete ${deletes.toLocaleString('en-US')} and archive ${countOf(archives, 'prompt')}`;
return deletes ? `Delete ${countOf(deletes, 'prompt')}` : `Archive ${countOf(archives, 'prompt')}`;
}
/** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */
export function blockedReason(review) {
if (!review.delete.length && !review.archive.length) return COPY.none;
if (review.delete.length && review.disk && review.disk.sufficient === false) {
return typeof review.disk.message === 'string' && review.disk.message
? review.disk.message : 'There is not enough free space for the safety backup. Nothing was changed.';
}
return null;
}
/**
* Open the review for `review` (`reviewOf`). `titleOf(id)` names a loaded prompt. A review that cannot start is the
* close-only review (`onDismiss` runs at once, as nothing is pending); otherwise Start calls `onStart()` and any other
* close `onDismiss()`.
*/
export function openDeleteReview({ scope, trigger, review, titleOf, onStart, onDismiss }) {
const title = reviewTitle(review), grid = reviewGrid(review, titleOf);
const options = reviewSentences(review).map(text => Object.assign(document.createElement('p'), { className: 'small', textContent: text }));
const blocked = blockedReason(review);
if (blocked) {
onDismiss();
openCloseOnlyReview({ scope, trigger, title, grid, options, sentence: blocked });
return;
}
openReviewDialog({ scope, trigger, title, grid, options, cancelLabel: 'Cancel',
primary: { label: primaryLabel(review), danger: true }, onStart, onDismiss });
}
/** The write's body: the reviewed lists, the epoch and the reviewed backup promise. */
export function deleteBody(review) {
return { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup };
}
function failureOutcome(failure, copy) {
if (failure?.code === 'storage-changed') return { toast: copy.storage, committed: false, reload: true };
if (failure?.code === 'busy') {
const name = failure.running?.title || 'Another job';
return { toast: `${name} is running. Nothing was changed; try again when it finishes.`, committed: false, reload: false };
}
const status = failure?.status;
if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: copy.unconfirmed, committed: true, reload: true };
// The server's own refusals that name their reason (backup-failed, insufficient-space) say "Nothing was changed".
if (['backup-failed', 'insufficient-space'].includes(failure?.code) && typeof failure.message === 'string' && failure.message) {
const lead = failure.code === 'backup-failed' ? 'The safety backup failed. ' : '';
return { toast: `${lead}${failure.message}`, committed: false, reload: false };
}
return { toast: copy.failed, committed: false, reload: false };
}
/** What a settled delete means: the toast, whether it may have `committed` and whether the shelf must `reload`. */
export function deleteOutcome(result, failure, review) {
if (failure !== null || !result?.success) {
return failureOutcome(failure, { storage: COPY.storage, unconfirmed: COPY.unconfirmed, failed: COPY.failed });
}
const deleted = count(result.deleted), archived = count(result.archived);
const parts = [];
if (deleted) parts.push(`Deleted ${countOf(deleted, 'prompt')}.`);
if (archived) parts.push(`Archived ${countOf(archived, 'prompt')}.`);
if (!parts.length) parts.push(NOTHING);
const already = count(result.already_archived), missing = count(result.not_found), changed = count(result.changed);
if (already) parts.push(`${were(already, 'was', 'were')} already archived.`);
if (missing) parts.push(`${missing.toLocaleString('en-US')} no longer ${missing === 1 ? 'exists' : 'exist'}.`);
if (changed) parts.push(`${changed.toLocaleString('en-US')} changed since the review and ${changed === 1 ? 'was' : 'were'} kept.`);
const kept = review?.protected?.length || 0;
if (kept) parts.push(`${countOf(kept, 'protected prompt')} ${kept === 1 ? 'was' : 'were'} kept.`);
if (typeof result.backup === 'string' && result.backup) parts.push(`Safety backup saved: ${result.backup} (System › Backup & export).`);
if (!deleted && !archived) parts.push('The Library was reloaded.');
return { toast: parts.join(' '), committed: deleted + archived > 0, reload: true };
}
/** What a settled restore means (the same shape as `deleteOutcome`). */
export function restoreOutcome(result, failure) {
if (failure !== null || !result?.success) {
return failureOutcome(failure, { storage: COPY.restoreStorage, unconfirmed: COPY.restoreUnconfirmed, failed: COPY.restoreFailed });
}
const restored = count(result.restored), unchanged = count(result.unchanged), missing = count(result.not_found);
const parts = [restored ? `Restored ${countOf(restored, 'prompt')} to the Prompt library.` : 'No prompt was restored.'];
if (unchanged) parts.push(`${unchanged.toLocaleString('en-US')} ${unchanged === 1 ? 'was' : 'were'} not archived.`);
if (missing) parts.push(`${missing.toLocaleString('en-US')} no longer ${missing === 1 ? 'exists' : 'exist'}.`);
return { toast: parts.join(' '), committed: restored > 0, reload: true };
}
/** A failed preview: a restored storage reloads the shelf; anything else keeps the selection. */
export function previewOutcome(failure) {
if (failure?.code === 'storage-changed') return { toast: COPY.storage, committed: false, reload: true };
if (failure?.code === 'busy') return failureOutcome(failure, {});
return { toast: COPY.reviewFailed, committed: false, reload: false };
}
+72 -12
View File
@@ -6,6 +6,8 @@ import { NO_TAGS, parseBulkTags } from './images-tagging-bulk.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
import { countOf } from './plural.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { COPY as DELETE_COPY, DELETE_LABEL, DELETE_URL, PREVIEW_URL, REVIEWING, RESTORE_LABEL, RESTORE_URL, deleteBody, deleteOutcome,
openDeleteReview, previewOutcome, restoreOutcome, reviewOf } from './library-delete.mjs';
/**
* The Prompt library's selection and its bulk actions (D148, D151, beyond the concept): "Add tags" (#442, D151) and "Set
@@ -19,6 +21,10 @@ import { showWorkspaceToast } from './toast.mjs';
* Apply posts `{prompt_ids, category|tags, expected_epoch}` to `POST /prompt_manager/bulk/category` or `/bulk/tags`:
* the epoch the shelf was read under, so a restored storage answers 409 `storage-changed` and nothing is written.
* Add tags writes prompt tags only, never image tags or auto-tags.
*
* Delete… (#443, D152) previews the selection, opens its review (`library-delete.mjs`) and posts the reviewed lists:
* unpaired prompts are deleted, paired ones archived, protected ones kept. On the archived shelf (`getShelf()` is
* 'archived') the bar offers Restore instead of the other actions.
*/
export const CATEGORY_URL = '/prompt_manager/bulk/category';
export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' });
@@ -123,29 +129,34 @@ export function categoryProblem(value) {
* to `focusFallback` when it was on the bar the reload hides.
*/
export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted,
onReload }) {
onReload, getShelf = () => 'active', nameOf = () => null }) {
let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false;
const element = el('div');
element.id = 'library-bulkbar';
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const addTags = createButton('Add tags');
const setCategory = createButton('Set category');
const deleteButton = createButton(DELETE_LABEL, 'danger');
const restoreButton = createButton(RESTORE_LABEL);
const clearButton = createButton('Clear');
const selectLoaded = createButton('');
const actions = [addTags, setCategory, selectLoaded];
bulk.append(count, addTags, setCategory, clearButton);
// Every bar action is aria-disabled while a review or a write runs (D148, D151, D152), Clear too.
const actions = [addTags, setCategory, deleteButton, restoreButton, selectLoaded, clearButton];
let shownShelf = null;
scope.listen(selectLoaded, 'click', () => {
if (writing) return;
selectAll();
// The pressed offer left the bar: focus moves to its first action, as after D135's offer.
addTags.focus({ preventScroll: true });
(shownShelf === 'archived' ? restoreButton : addTags).focus({ preventScroll: true });
});
scope.listen(addTags, 'click', () => openTags());
scope.listen(setCategory, 'click', () => openCategory());
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
scope.listen(deleteButton, 'click', () => openDelete());
scope.listen(restoreButton, 'click', () => restoreSelected());
scope.listen(clearButton, 'click', () => { if (writing) return; clear(); focusFallback.focus({ preventScroll: true }); });
// Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear.
scope.listen(element, 'keydown', event => {
if (event.key !== 'Escape' || !checked.size) return;
if (event.key !== 'Escape' || !checked.size || writing) return;
event.preventDefault();
clear();
focusFallback.focus({ preventScroll: true });
@@ -154,6 +165,11 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
const ids = () => getItems().map(item => item.id);
function render() {
if (!checked.size) { bulk.remove(); return; }
const shelf = getShelf() === 'archived' ? 'archived' : 'active';
if (shelf !== shownShelf) {
shownShelf = shelf;
bulk.replaceChildren(count, ...(shelf === 'archived' ? [restoreButton] : [addTags, setCategory, deleteButton]), clearButton);
}
const label = selectionLabel(checked.size);
if (count.textContent !== label) count.textContent = label;
const keys = ids();
@@ -240,20 +256,62 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
openInputDialog({ scope, trigger: addTags, ...PROMPT_TAGS_DIALOG, suggestions, validate: tagsProblem,
onApply: text => write(TAGS_URL, { ...chosen, tags: parseBulkTags(text) }, 'tags') });
}
/** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */
async function write(url, body, kind) {
/** Hold every bar action while a review or write runs; `label` is what Delete… reads meanwhile. */
function lock(label = null) {
writing = true;
for (const button of actions) button.setAttribute('aria-disabled', 'true');
if (label !== null) deleteButton.textContent = label;
}
function unlock() {
writing = false;
for (const button of actions) button.removeAttribute('aria-disabled');
deleteButton.textContent = DELETE_LABEL;
}
/** Delete… (#443): preview the selection, then review it; Start posts what the review showed. */
async function openDelete() {
const chosen = target(DELETE_COPY.reviewFailed);
if (!chosen) return;
lock(REVIEWING);
let preview = null, failure = null;
try {
preview = await api.post(PREVIEW_URL, chosen, { signal: scope.signal });
} catch (error) {
failure = error;
}
if (scope.signal.aborted) return;
if (failure !== null || !preview?.success) {
unlock();
const outcome = previewOutcome(failure);
showWorkspaceToast(outcome.toast);
if (outcome.reload) onReload();
return;
}
const review = { ...reviewOf(preview), epoch: chosen.expected_epoch };
// The review is open: the bar stays held behind it, under its own name again.
deleteButton.textContent = DELETE_LABEL;
openDeleteReview({ scope, trigger: deleteButton, review, titleOf: nameOf,
onDismiss: unlock,
onStart: () => write(DELETE_URL, deleteBody(review), 'delete', (result, error) => deleteOutcome(result, error, review),
review.delete.length ? 'Deleting…' : 'Archiving…') });
}
/** Restore (#443): the selected archived prompts go back on the shelf. */
function restoreSelected() {
const chosen = target(DELETE_COPY.restoreFailed);
if (!chosen) return;
write(RESTORE_URL, chosen, 'restore', restoreOutcome);
}
/** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */
async function write(url, body, kind, outcomeOf = (result, failure) => writeOutcome(result, failure, kind), label = null) {
lock(label);
let result = null, failure = null;
try {
result = await api.post(url, body);
} catch (error) {
failure = error;
} finally {
writing = false;
for (const button of actions) button.removeAttribute('aria-disabled');
unlock();
}
const outcome = writeOutcome(result, failure, kind);
const outcome = outcomeOf(result, failure);
// A left page still invalidates what it cached, so a return never shows the old categories.
if (outcome.committed) onCommitted(kind, result?.success ? result : { phase: 'unconfirmed' });
if (scope.signal.aborted) return;
@@ -274,7 +332,9 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
selectAll,
/** A loaded batch: the count and the offer follow it. */
listingChanged: () => { if (checked.size) render(); },
/** Whether a Set category write is still in flight. */
/** Whether a review or a write is still in flight. */
writing: () => writing,
/** The shelf changed (Show archived prompts): the bar's actions follow it. */
shelfChanged: () => { shownShelf = null; render(); },
};
}
+11 -4
View File
@@ -12,14 +12,19 @@ function parameters(filters,limit) {
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
const sort=filters.sort || 'newest';
if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort');
query.set('sort',sort);return query;
query.set('sort',sort);
// #443 D152: the archived shelf; the default shelf sends nothing, as before.
const shelf=filters.shelf || 'active';
if(!['active','archived'].includes(shelf)) throw new TypeError('Invalid library shelf');
if(shelf==='archived') query.set('shelf','archived');
return query;
}
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
function validatePage(page,cursor) {
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
}
export function createLibraryStream({api,onChange=()=>{},restore}) {
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,archivedTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
let query=parameters({},40),request,disposed=false,seen=new Set();
if(restore) {
query=parameters(restore.filters,40);const saved=restore.state;
@@ -45,11 +50,13 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
const incoming=[];const added=new Set();
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
for(const value of added) seen.add(value);
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
// #443: the archived parents matching the filters (Show archived prompts); null from an older server.
const archivedTotal=Number.isSafeInteger(page.archived_total) && page.archived_total>=0?page.archived_total:null;
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,archivedTotal,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
}
return {get state(){return state;},loadMore,async reset(filters={}) {
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,archivedTotal:state.archivedTotal ?? null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
},dispose(){disposed=true;request?.abort();request=null;}};
}
/**
+45 -5
View File
@@ -17,6 +17,7 @@ import {libraryFilters,libraryViewState} from './saved-views.mjs';
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
import {mountLibrarySelection} from './library-selection.mjs';
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
import {COPY as DELETE_COPY,RESTORE_URL,restoreOutcome} from './library-delete.mjs';
let shelfVisit=null;
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
@@ -107,6 +108,8 @@ export function rememberLibraryResult(result) {
function outcomeMessage(result,outcome) {
const {kind,receipt}=result.commit;
if(kind==='metadata') return 'Saved.';
// #443: a save that reused an archived prompt leaves it archived; say how to bring it back.
if((kind==='create' || kind==='add-negative') && result.detail?.positive?.archived===true) return DELETE_COPY.archivedText;
if(kind==='archive-pair') return ARCHIVED_MESSAGE;
if(kind!=='create' && kind!=='add-negative') return null;
if(receipt.positive_created) return 'Saved.';
@@ -158,7 +161,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration;
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
const restorable=visit?.state?visit:null;
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved};
// #443 D152: `shelf` ('active' or 'archived', Show archived prompts) lives in the visit's filters, never in a saved view.
const filters={text:'',category:'',tags:[],sort:'newest',shelf:'active',...visit?.filters,...saved};
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
@@ -177,14 +181,22 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
// #437 D148: select prompts and set their category (the legacy admin's Set Category); a write reloads the shelf.
let categoryNames=[];
const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},getShelf:()=>filters.shelf,
nameOf:id=>{const item=state?.items.find(entry=>entry.id===id);return item?displayTitle(item):null;},
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();}});
const message=el('p','','library-status');message.setAttribute('role','status');
const continuation=el('div',undefined,'library-continuation');
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
const actions=headingActions(main,section);
const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON');
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(exportButton,newPrompt);
// #443 D152: the shelf's counterpart of Negative variants' Show archived (#235); N follows the filters.
const showArchived=createButton('Show archived prompts');showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived'));
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(showArchived,exportButton,newPrompt);
scope.listen(showArchived,'click',()=>{
if(selection.writing()) return;
filters.shelf=filters.shelf==='archived'?'active':'archived';showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived'));
reset();selection.shelfChanged();
});
const exportStatus=el('p','','library-options-status');exportStatus.hidden=true;exportStatus.setAttribute('role','alert');
const editor=createNewPromptEditor(api,scope,main);
scope.listen(newPrompt,'click',()=>editor.open({mode:'new',trigger:newPrompt}));
@@ -239,6 +251,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const content=el('div',undefined,'card-content');
const heading=el('div',undefined,'card-title');const open=createButton(name);open.dataset.history='';
heading.append(open,el('span',item.category || 'Uncategorized','chip'));
if(item.archived===true) heading.append(el('span','Archived','chip'));
const prompts=el('div');
const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive'],title:name});
const meta=el('div',undefined,'card-meta');
@@ -388,11 +401,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
// A loaded batch: the selection bar's count and Select all N loaded follow it (D151).
if(batch) selection.listingChanged();
if(next.variantTotal!==null) {
const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
const count=filters.shelf==='archived'?`${countOf(next.total,'archived prompt')} · ${countOf(next.variantTotal,'negative variant')} hidden with them`
:`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
}
const archivedLabel=next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`;
if(showArchived.textContent!==archivedLabel) showArchived.textContent=archivedLabel;
const filtered=Boolean(filters.text || filters.category || filters.tags.length);
const empty=filters.shelf==='archived'?(filtered?'No archived prompts match.':'No archived prompts.'):'No matching prompts. Try changing the filters.';
const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':'';
message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden;
message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?empty:!next.hasMore?'You’re all caught up.':'')+hidden;
message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading));
schedule();scheduleLoad();
}
@@ -473,6 +491,26 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
if(current) current.apply(result);else loader.load(positiveId);
focusHeading(main);
}
/** The Archived notice: Restore takes the prompt off the archive, then the page reads it again. */
function archivedNotice(content,shown) {
const notice=el('div',undefined,'notice library-archived-notice'),copy=el('div'),row=el('div',undefined,'row-actions');
const status=el('p','','small');status.setAttribute('role','status');status.hidden=true;
const restore=createButton('Restore');restore.setAttribute('aria-label','Restore this prompt to the Prompt library');
copy.append(el('h2','Archived'),el('p',DELETE_COPY.archivedNotice),status);row.append(restore);notice.append(copy,row);
content.listen(restore,'click',async()=>{
if(restore.getAttribute('aria-disabled')==='true') return;
restore.setAttribute('aria-disabled','true');status.hidden=true;
let result=null,failure=null;
try {result=await api.post(RESTORE_URL,{prompt_ids:[shown.positive.id],expected_epoch:shown.library_epoch});} catch(error) {failure=error;}
const outcome=restoreOutcome(result,failure);
if(outcome.committed) invalidateLibraryAfterWrite({kind:'bulk-restore',receipt:result?.success?result:{phase:'unconfirmed'}});
if(content.signal.aborted) return;
restore.removeAttribute('aria-disabled');
if(outcome.reload) {showWorkspaceToast(outcome.toast);detail=null;loader.load(positiveId);focusHeading(main);return;}
status.textContent=outcome.toast;status.hidden=false;
});
return notice;
}
/** Explicit exit after restored storage: dispose the recorded view, then remount with a fresh visit. */
function reloadView() {
writeGeneration++;shelfVisit=shelfVisit && {filters:shelfVisit.filters,anchor:null};
@@ -650,6 +688,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const gallery=el('section',undefined,'history-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(galleryHeading);
const images=el('div',undefined,'library-linked-images');gallery.append(images);
const recorded=el('div',undefined,'library-recorded-container');
// #443 D152: an archived prompt's page says so first, with Restore (the concept's `.notice`).
if(detail.positive.archived===true) body.append(archivedNotice(content,detail));
body.append(summary,variants,gallery,recorded);
const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null);
const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true);
+2 -2
View File
@@ -56,8 +56,8 @@ export const HOST_UNAVAILABLE = 'Server settings could not be read.';
export const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
export const WARNING_NOTE = 'Restores are staged, migrated and validated before replacing live data.';
// D104 (M2): what the safety-backup policy does (the concept's "Preview a verified backup…" line misstates it).
export const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA ' +
'imports change records. A restore always keeps one.';
export const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA ' +
'imports and prompt deletes change records. A restore always keeps one.';
// I1: a third review while two are held (another window's): what the user can do about it.
export const REVIEWS_FULL = 'Two restores are already being reviewed in another window. Discard one there, or try again ' +
'when it expires (15 minutes after it was reviewed).';