Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/library-delete.mjs
T
Vito Sansevero 323f2617d8 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.
2026-10-28 18:51:06 -07:00

200 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}