feat(images): Delete… in the Images selection bar, inspector and viewer (#480)

The concept's Remove links… place, D159: one review per selection that asks
each time (nothing preselected) whether to remove the files from the library
only or also move them to the trash. The browser test server uses a fake
trash.
This commit is contained in:
Vito Sansevero
2026-10-29 10:03:51 -07:00
parent ad392a99a7
commit 4e21e97e12
14 changed files with 734 additions and 18 deletions
+10 -1
View File
@@ -234,7 +234,7 @@ export const DEFERRED_TEXT = 'Metadata loads when you ask for it.';
* collections checklist; `onCollectionsChanged({added, removed})` hears the confirmed changes.
*/
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback,
collections = null, onCollectionsChanged = null}) {
collections = null, onCollectionsChanged = null, onDelete = null}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
@@ -339,6 +339,9 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
onChanged:onCollectionsChanged, focusFallback:fallback}));
footer.append(button);
}
// #480: Delete… for a listed Images file only, last in the actions.
const removable = onDelete ? revealTarget(record.identity) : null;
if (removable) footer.append(deleteButton(removable));
if (!full && footer.childNodes.length) container.append(footer);
}
/**
@@ -456,6 +459,12 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
});
return button;
}
/** #480: "Delete…" opens the Images delete review for this one file. */
function deleteButton(target) {
const button = createButton('Delete…', 'danger');
contentScope.listen(button, 'click', () => onDelete(target, button));
return button;
}
/** "Workflow & raw metadata": each supported record (`pre[tabindex=0] > code`), then the two downloads. */
function rawMetadata(record) {
const {metadata} = record, section = disclosure('Workflow & raw metadata');
+285
View File
@@ -0,0 +1,285 @@
/**
* Images › Delete… (#480, D159): the review dialog for the checked images (or "all N matching", or the one image of
* the inspector or viewer) and the apply.
*
* - The concept's selection bar has "Remove links…" (`danger`) opening its `maintenancePlan` review (`dl.meta-grid`
* Scope / Protected / Backup / Media on disk, Cancel and a primary). Production names it "Delete…" and asks each
* time (maintainer ruling): a radio group "What should happen to the files?" with nothing checked, "Remove from the
* library only" (records go, the file stays on disk, hidden from Images) or "Also move the files to the trash".
* - One preview (`POST /images/delete/preview`) answers both modes; choosing a mode only redraws the rows. The primary
* (`danger`) is enabled once a mode with images to act on is chosen. Apply sends `{token, mode}` to the reviewed-plan
* apply; the job is followed in the Activity store and `onDeleted(job)` runs when it ends.
* - Nothing here deletes a file: the server never unlinks one (it renames into the system trash or leaves it).
*/
import { createButton } from '../shared/toolkit/core.js';
import { closeWorkspaceDialog, openCloseOnlyReview, openContentDialog } from './workspace-dialogs.mjs';
import { followJobEnd } from './images-tagging-bulk.mjs';
import { applyRefusal, APPLY_URL } from './maintenance-review.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { countOf, noun } from './plural.mjs';
export const PREVIEW_URL = '/prompt_manager/images/delete/preview';
export const MAX_DELETE = 500;
export const TOO_MANY = 'Select at most 500 images.';
export const QUESTION = 'What should happen to the files?';
export const MODES = Object.freeze([
Object.freeze({value: 'library', label: 'Remove from the library only', hint: 'Files stay on disk, hidden from Images.'}),
Object.freeze({value: 'trash', label: 'Also move the files to the trash',
hint: 'You can restore them from the trash until it is emptied.'}),
]);
export const NONE_DELETABLE = 'None of the selected images can be deleted.';
export const REVIEW_FAILED = 'The review failed. Nothing was changed.';
export const SOURCE_CHANGED = 'The sources changed. Refresh the images and try again.';
export const STORAGE_CHANGED = 'Prompt storage was restored. Reopen Delete.';
export const USE_LIBRARY_ONLY = 'To remove these files from the library anyway, choose “Remove from the library only”.';
export const NO_TRASH_HERE = 'Moving to the trash is not available on this system yet.';
const SENTENCES = Object.freeze({
libraryBackup: 'Removed images stay hidden unless the file is modified or replaced. Their tags, category, collections '
+ 'and favorite can only be recovered by restoring the safety backup, which also undoes every later change.',
libraryNoBackup: 'Removed images stay hidden unless the file is modified or replaced. Their tags, category, '
+ 'collections and favorite cannot be recovered.',
trash: 'A file restored from the trash shows again without its tags, category, collections or favorite; a scan '
+ 'links it to its prompt again.',
});
export { SENTENCES };
const RECORD_WORDS = [
['image_tags', 'with image tags', 'with image tags'],
['auto_tagged', 'auto-tagged', 'auto-tagged'],
['categories', 'category', 'categories'],
['collections', 'collection membership', 'collection memberships'],
['favorites', 'favorite', 'favorites'],
['review_items', 'Needs review item', 'Needs review items'],
];
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function number(value) {
return Number(value ?? 0).toLocaleString('en-US');
}
/** The images a mode acts on. */
export function modeCount(plan, mode) {
return Number(plan?.[mode]?.count ?? 0);
}
/** "Name, name, name and K more" for up to three refused rows, grouped by their sentence. */
export function refusedValue(summary) {
const count = Number(summary?.count ?? 0);
if (!count) return null;
const rows = Array.isArray(summary.rows) ? summary.rows : [];
const names = rows.slice(0, 3).map(row => String(row.path).split('/').at(-1));
const more = count - names.length;
const reasons = [...new Set(rows.map(row => row.detail).filter(Boolean))];
return `${number(count)}: ${names.join(', ')}${more > 0 ? ` and ${number(more)} more` : ''}`
+ (reasons.length ? `. ${reasons.join(' ')}` : '');
}
/** The `dl.meta-grid` rows for `mode` (design §9.3). */
export function deleteRows(plan, mode) {
const rows = [['Scope', countOf(modeCount(plan, mode), 'image')]];
const refused = {count: 0, rows: []};
for (const part of mode === 'trash' ? [plan.refused, plan.trash_refused] : [plan.refused]) {
refused.count += Number(part?.count ?? 0);
refused.rows.push(...(part?.rows ?? []));
}
const notChanged = refusedValue(refused);
if (notChanged) rows.push(['Not changed', notChanged]);
const records = plan.records ?? {};
const links = Number(records.prompt_links ?? 0);
if (links) {
const orphans = plan.orphaned_prompts ?? {count: 0, titles: []};
let text = `${countOf(links, 'link')}.`;
if (orphans.count > 0) {
const titles = (orphans.titles ?? []).filter(Boolean);
const more = orphans.count - titles.length;
text += ` ${countOf(orphans.count, 'prompt')} ${orphans.count === 1 ? 'loses its' : 'lose their'} last image`
+ (titles.length ? `: ${titles.join(', ')}${more > 0 ? ` and ${number(more)} more` : ''}` : '')
+ `. The ${noun(orphans.count, 'prompt')} ${orphans.count === 1 ? 'stays' : 'stay'} on the Library page.`;
}
rows.push(['Prompt links', text]);
}
const details = RECORD_WORDS.filter(([key]) => Number(records[key] ?? 0) > 0)
.map(([key, one, many]) => `${number(records[key])} ${Number(records[key]) === 1 ? one : many}`);
if (details.length) rows.push(['Image details', details.join(', ')]);
rows.push(['Backup', backupOn(plan) ? 'Create and verify before changes (the database, not the files)'
: 'Off: no safety backup (System › Backup & export › Recovery policy)']);
rows.push(['Media on disk', mode === 'trash'
? 'Files move to the trash. Nothing is deleted permanently. Disk space is freed when you empty the trash.'
: 'Files stay on disk, hidden from Images.']);
return rows;
}
function backupOn(plan) {
return plan?.backup?.policy !== 'off';
}
/** The `p.small` sentences for `mode`. */
export function deleteSentences(plan, mode) {
if (mode === 'library') return [backupOn(plan) ? SENTENCES.libraryBackup : SENTENCES.libraryNoBackup];
const sentences = [SENTENCES.trash];
const trashRefused = Number(plan?.trash_refused?.count ?? 0);
if (trashRefused && trashRefused >= modeCount(plan, 'library')) sentences.push(NO_TRASH_HERE);
if (trashRefused) sentences.push(USE_LIBRARY_ONLY);
return sentences;
}
/** The primary's label: "Remove N images from the library" or "Move N images to the trash". */
export function primaryLabel(mode, count) {
return mode === 'trash' ? `Move ${countOf(count, 'image')} to the trash` : `Remove ${countOf(count, 'image')} from the library`;
}
/** Why the review cannot start at all, or null. */
export function closeOnlyReason(plan) {
if (modeCount(plan, 'library') === 0 && modeCount(plan, 'trash') === 0) return NONE_DELETABLE;
if (plan?.disk && plan.disk.sufficient === false) {
return `There is not enough free space for the safety backup (needs ${number(plan.disk.required_bytes)} bytes, `
+ `has ${number(plan.disk.free_bytes)}).`;
}
return null;
}
/** The toast for a review that could not be read. */
export function reviewFailure(error) {
if (error?.code === 'source-changed') return SOURCE_CHANGED;
if (error?.code === 'storage-changed') return STORAGE_CHANGED;
if (error?.code === 'too-many-images') return TOO_MANY;
if (error?.code === 'busy' && error.message) return error.message;
return REVIEW_FAILED;
}
/** The toast when the job ends (design §9.4). */
export function outcomeToast(job) {
const counters = job?.counters ?? {};
const mode = job?.scope?.mode;
if (job?.scope?.failure === 'backup-failed') return 'The safety backup failed; nothing was changed.';
if (job?.scope?.failure === 'sources-changed') return 'The sources changed; nothing was changed.';
const done = Number(mode === 'trash' ? counters.trashed : counters.removed) || 0;
const total = Number(job?.total ?? 0);
if (job?.scope?.failure === 'records-failed') {
return `Stopped after ${number(done)} of ${countOf(total, 'image')}; the rest were not changed. See Activity.`;
}
const head = mode === 'trash' ? `Moved ${countOf(done, 'image')} to the trash.` : `Removed ${countOf(done, 'image')} from the library.`;
const unchanged = Number(counters.not_changed ?? 0);
return unchanged ? `${head} ${number(unchanged)} ${unchanged === 1 ? 'was' : 'were'} not changed (see Activity).` : head;
}
function modeGroup(onChange) {
const group = el('div', undefined, 'field');
group.setAttribute('role', 'radiogroup');
const caption = el('span', QUESTION);
caption.id = 'image-delete-mode-label';
group.setAttribute('aria-labelledby', caption.id);
group.append(caption);
for (const choice of MODES) {
const label = el('label');
const input = el('input');
Object.assign(input, {type: 'radio', name: 'image-delete-mode', value: choice.value, checked: false});
input.addEventListener('change', () => { if (input.checked) onChange(choice.value); });
label.append(input, ` ${choice.label}`, el('span', ` ${choice.hint}`, 'small muted'));
group.append(label);
}
return group;
}
/** The review for an answered preview `plan`; `onApply(mode)` runs on the primary. */
export function showDeleteReview(plan, {scope, count, trigger = null, onApply}) {
const title = `Delete ${countOf(count, 'image')}`;
const reason = closeOnlyReason(plan);
if (reason) {
openCloseOnlyReview({scope, title, grid: deleteRows(plan, 'library'), sentence: reason, trigger});
return null;
}
let mode = null;
const list = el('dl', undefined, 'meta-grid');
list.hidden = true;
const notes = el('div', undefined, 'image-delete-notes');
const cancel = createButton('Cancel');
const start = createButton(primaryLabel('library', modeCount(plan, 'library')), 'danger');
start.setAttribute('aria-disabled', 'true');
function render() {
list.replaceChildren();
notes.replaceChildren();
if (!mode) return;
for (const [term, value] of deleteRows(plan, mode)) {
const entry = el('div');
entry.append(el('dt', term), el('dd', value));
list.append(entry);
}
list.hidden = false;
for (const sentence of deleteSentences(plan, mode)) notes.append(el('p', sentence, 'small'));
const acting = modeCount(plan, mode);
start.textContent = primaryLabel(mode, acting);
start.setAttribute('aria-disabled', String(acting === 0));
}
const group = modeGroup(value => { mode = value; render(); });
cancel.addEventListener('click', () => closeWorkspaceDialog());
start.addEventListener('click', () => {
if (!mode || start.getAttribute('aria-disabled') === 'true') return;
const chosen = mode;
closeWorkspaceDialog();
onApply(chosen);
});
const actions = el('div', undefined, 'dialog-actions');
actions.append(cancel, start);
openContentDialog({scope, title, nodes: [group, list, notes, actions], trigger});
return {group, list, start};
}
/** Apply `plan` in `mode`; the started job, or null (a toast said why). */
export async function applyDelete({api, store, plan, mode, notify = showWorkspaceToast}) {
let result;
try {
result = await api.post(APPLY_URL, {token: plan.token, mode});
} catch (error) {
const refusal = applyRefusal(error);
notify(refusal.toast);
if (refusal.lost) store?.load?.();
return null;
}
try {
const job = store ? store.track(result?.job) : result?.job;
notify(`${job.title} started.`);
return job;
} catch {
store?.load?.();
return null;
}
}
/**
* The whole flow for `images` (the checked ones) or `filter` ("all N matching", `count` of them): the preview, the
* review and the apply. `trigger` reads "Reviewing…" and is aria-disabled until the review opens; a second press is
* ignored. `onDeleted(job)` runs when the started job ends.
*/
export async function openDeleteImages({api, scope, store, images = [], filter = null, count = images.length, trigger = null,
onDeleted = () => {}, notify = showWorkspaceToast}) {
if (count > MAX_DELETE) { notify(TOO_MANY); return; }
if (!filter && !images.length) return;
if (trigger?.getAttribute('aria-disabled') === 'true') return;
const label = trigger?.textContent;
trigger?.setAttribute('aria-disabled', 'true');
if (trigger) trigger.textContent = 'Reviewing…';
let plan;
try {
const body = filter ? {filter} : {items: images.map(({root_index, root_scope, path}) => ({root_index, root_scope, path}))};
plan = await api.post(PREVIEW_URL, body, {signal: scope.signal});
} catch (error) {
if (!scope.signal.aborted) notify(reviewFailure(error));
return;
} finally {
if (trigger) { trigger.textContent = label; trigger.removeAttribute('aria-disabled'); }
}
if (scope.signal.aborted) return;
showDeleteReview(plan, {scope, count, trigger, onApply: async mode => {
const job = await applyDelete({api, store, plan, mode, notify});
if (!job) return;
if (store) followJobEnd({store, scope, id: job.id, onEnd: ended => { notify(outcomeToast(ended)); onDeleted(ended); }});
else onDeleted(job);
}});
}
+11 -2
View File
@@ -9,6 +9,7 @@ import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-taggin
import { showWorkspaceToast } from './toast.mjs';
import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs';
import { openAddToCollection, removeFromCollection } from './images-collections.mjs';
import { openDeleteImages } from './images-delete.mjs';
const CHECKBOX = '.image-overlays input[type="checkbox"]';
const CHECKBOX_LABEL = '.image-overlays > label';
@@ -50,7 +51,7 @@ function el(tag, text, className) {
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
collection = null, onCollectionsChanged = null, onSourceChanged = null }) {
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null }) {
let checked = initial;
let exporting = false, categorizing = false;
// D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox.
@@ -93,7 +94,15 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
scope.listen(removeButton, 'click', () => removeChecked(removeButton));
bulk.append(removeButton);
}
bulk.append(exportMetadata, clearButton);
bulk.append(exportMetadata);
// #480 (D159): the concept's "Remove links…" (`danger`) place, after Export metadata and before Clear.
if (onDeleted) {
const deleteButton = createButton('Delete…', 'danger');
scope.listen(deleteButton, 'click', () => openDeleteImages({ api, scope, store: activity, images: checkedImages(),
...matchingTarget(), trigger: deleteButton, onDeleted }));
bulk.append(deleteButton);
}
bulk.append(clearButton);
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
+14 -1
View File
@@ -9,6 +9,7 @@ import { createPromptSender } from './prompt-sender.mjs';
import { promptRoles } from './prompt-blocks.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { createMediaViewer } from './media-viewer.mjs';
import { openDeleteImages } from './images-delete.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
import { mountToolbar } from './toolbar.mjs';
@@ -299,6 +300,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
onDeleted:deleted,
// The Set category field suggests the toolbar's categories (#332 MC13).
getCategories:() => categoryNames,
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
@@ -321,6 +323,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
};
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
collections:inspectorCollections,onCollectionsChanged:collectionsChangedBy,
onDelete:embedded ? null : (item,button) => deleteOne(item,button,() => mediaViewer.close()),
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onMediaError:image => recovery.recover(image),onRestoreFocus:image => {
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
@@ -338,7 +341,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
for (const video of details.querySelectorAll('video')) releaseVideo(video);
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection,
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy});
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy,
onDelete:embedded ? null : (target, button) => deleteOne(target, button)});
detailInspector.setState(inspection.state);
inspection.select(image);
for (const [key, card] of rendered) markSelected(card, key === selection);
@@ -647,6 +651,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
clearChecks:() => bar?.clear(), listsNow:() => !recovering,
current:started => !scope.signal.aborted && started === generation,
rediscover:() => loadOptions().then(found => { if (found) renderLoadStatus(); })})});
/** #480: a finished delete job reloads the listing (the deleted files leave it, and the selection clears). */
function deleted() {
loadCategories();
reset();
}
/** #480: Delete… for one file, from the inspector or the viewer (`after` closes the viewer first). */
function deleteOne(target, trigger, after = null) {
openDeleteImages({api, scope, store:activity, images:[target], trigger, onDeleted:() => { after?.(); deleted(); }});
}
function reset() {
generation += 1;
mediaViewer.stopSlideshow();
+11 -1
View File
@@ -25,7 +25,7 @@ function element(tag, className, text) {
* reach `onCollectionsChanged({added, removed})` once the viewer closes, so the viewer keeps its place while open.
*/
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,adapter = rootedFileAdapter,
collections = null,onCollectionsChanged = null}) {
collections = null,onCollectionsChanged = null,onDelete = null}) {
const scope = createScope();
let active = null, retryVersion = 0;
const dispose = () => scope.dispose();
@@ -82,6 +82,16 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
const download = element('a','media-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download','');
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
controls.append(download);
// #480: Delete… (the Images viewer only) pauses the media, then opens the delete review for this file.
if (onDelete) {
const remove = createButton('Delete…','danger');
local.listen(remove,'click',() => {
session.stop();
for (const video of dialog.querySelectorAll('video')) video.pause();
if (entry) onDelete(entry,remove);
});
controls.append(remove);
}
const next = button('Next media','Next →',() => session.navigate(1));
// D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle. D155
// (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing. D156 (#450):