* feat(trash): a freedesktop trash backend that renames without replacing or refuses (#480) * feat(images): remove a file's records and hide it with a removal mark (#480) Deletes every record of one file under every recorded spelling (image links, tags, auto-tag marks, pending review items, category, collection memberships, favorite) in one transaction. A Needs review item that is kept keeps its annotation rows (ON DELETE RESTRICT). The image_removals table hides an exact file (locator, size, mtime_ns) until it changes; linking skips a marked file, and an explicit tag, category, favorite or collection write shows it again. * feat(images): review and apply Delete images as a reviewed plan job (#480) POST /prompt_manager/images/delete/preview reviews both modes; the maintenance apply takes {token, mode}. Library mode removes the records and writes the removal mark; trash mode moves the file first and removes its records only after a successful move. The workspace listing, the output scan, the duplicate find and Tag output images skip marked files. * 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. * test(images): portable delete tests, the native no-trash proof, D159 in TESTING.md (#480) * perf(images): cache the removal marks so a listing page reads no extra table (#480) The marks reload only when this process writes or clears one, or a restore replaces the database. The route snapshot gains the preview route. * test(images): the full inspector specs expect D159's Delete… in the panel and the viewer (#480) * fix(images): the delete review starts nothing without a chosen mode, and dims its primary until then (#480) An aria-disabled primary ignores every activation, a chosen one starts once, and the apply helper refuses a missing mode. Tests cover a forced click, a double click, Enter and Space on the primary, Enter in the dialog and a submit event, and the server refusing an apply without an exact mode.
291 lines
14 KiB
JavaScript
291 lines
14 KiB
JavaScript
/**
|
||
* 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, started = false;
|
||
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('Choose what happens to the files', 'danger image-delete-start');
|
||
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());
|
||
// Nothing starts without an explicit mode: an aria-disabled primary ignores every activation (click, Enter, Space),
|
||
// and a chosen one starts once however often it is pressed. The dialog has no form, so Enter elsewhere submits nothing.
|
||
start.addEventListener('click', () => {
|
||
if (started || !MODES.some(choice => choice.value === mode) || start.getAttribute('aria-disabled') === 'true') return;
|
||
started = true;
|
||
start.setAttribute('aria-disabled', 'true');
|
||
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}) {
|
||
if (!MODES.some(choice => choice.value === mode)) throw new TypeError('A delete needs an explicit mode');
|
||
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);
|
||
}});
|
||
}
|