Maintainer ruling (a): Windows never uses the Recycle Bin, because it deletes files larger than the bin's capacity permanently and IFileOperation's PreDeleteItem guard cannot detect it (native probe runs 40951037575, 40953347904). Each file in the Delete review now reads "Moving files to the Recycle Bin isn't available on Windows: files larger than the bin's capacity would be deleted permanently." (code windows-recycle) instead of "This drive has no trash.", and the summary line drops its "yet": "Moving to the trash is not available for these files; each one lists why."
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 for these files; each one lists why.';
|
||
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);
|
||
}});
|
||
}
|