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.', noneArchived: 'None of the selected prompts can be deleted.', noneSingle: 'This prompt cannot 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.', 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}`; const DIGEST = /^[0-9a-f]{64}$/; /** * The preview's classes as counts and id lists, whatever else the answer holds. On the archived `shelf` (#492) the * review is the permanent delete only: the choice is fixed to `delete`. */ export function reviewOf(preview, shelf = 'active') { const archivedShelf = shelf === 'archived'; return { shelf: archivedShelf ? 'archived' : 'active', notArchived: count(preview?.not_archived), 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, ...pairedOf(preview), ...(archivedShelf ? { choice: 'delete' } : {}), }; } const onArchivedShelf = review => review.shelf === 'archived'; /** #482: the archive ids that may be deleted permanently (each with its review digest), and the pair counts. */ function pairedOf(preview) { const archive = new Set(ids(preview?.archive)), given = preview?.review_digests ?? {}; const deletable = ids(preview?.deletable).filter(id => archive.has(id) && DIGEST.test(String(given?.[id] ?? ''))); const counts = preview?.paired_counts ?? {}; return { deletable, digests: Object.fromEntries(deletable.map(id => [id, given[id]])), pairedCounts: { pairs: count(counts.pairs), archivedPairs: count(counts.archived_pairs), variants: count(counts.variants), keptNegatives: count(counts.kept_negatives) }, pairedImageLinks: count(preview?.paired_image_links), choice: 'archive', // #512: archive ids the server says cannot be deleted (another prompt's negative); none from an older answer. // The server's own list, not the digest-checked one: a deletable prompt with a bad digest is still paired. archiveOnly: Array.isArray(preview?.deletable) ? [...archive].filter(id => !ids(preview.deletable).includes(id)) : [], }; } /** #482: whether the review asks what to do with prompts in a saved pair. */ export const hasPairedChoice = review => !onArchivedShelf(review) && review.deletable.length > 0; /** #482: the review with its saved-pair choice (`archive`, the default, or `delete`); the original is unchanged. */ export function withChoice(review, choice) { if (onArchivedShelf(review)) return { ...review, choice: 'delete' }; return { ...review, choice: choice === 'delete' && hasPairedChoice(review) ? 'delete' : 'archive' }; } const permanent = review => review.choice === 'delete'; /** What is deleted and archived: Delete permanently moves the deletable ids from archive to delete. */ function plan(review) { if (!permanent(review)) return { plain: review.delete, paired: [], archive: review.archive }; const chosen = new Set(review.deletable); return { plain: review.delete, paired: review.deletable, archive: review.archive.filter(id => !chosen.has(id)) }; } export function reviewTitle(review) { const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length; // #512: a review opened from a prompt's own page names that prompt. if (review.single) return deletes || !archive.length ? 'Delete this prompt' : 'Archive this prompt'; if (onArchivedShelf(review)) return 'Delete archived prompts'; if (!archive.length) return 'Delete selected prompts'; if (!deletes) 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; } /** An archived prompt's page notice: why it is archived, and its negative variants only when it has any. */ export function archivedNoticeText(variantCount) { const hidden = Number.isSafeInteger(variantCount) && variantCount > 0 ? `It and its ${countOf(variantCount, 'negative variant')} are hidden` : 'It is hidden'; return `This prompt is archived because it is in a saved pair. ${hidden} from the Prompt library; nothing was removed.`; } /** The review's `[term, value]` rows (design §7.2); rows that do not apply are left out. */ export function reviewGrid(review, titleOf = () => null) { if (permanent(review)) return permanentGrid(review, titleOf); // #512: rows with nothing in them are left out. const rows = []; if (review.delete.length) rows.push(['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`]); if (review.archive.length) rows.push(['Archive', archiveValue(review)]); keptRows(rows, review, titleOf); if (review.delete.length) deleteRows(rows, review, review.imageLinks); return rows; } /** The archive reason is the saved pair (a pair's positive or negative); #512 names another prompt's negative. */ function archiveValue(review) { const total = review.archive.length, negatives = review.archiveOnly.length; if (negatives === total) return `${countOf(total, 'prompt')} used as another prompt's negative: hidden, restorable`; const detail = negatives ? ` (${negatives.toLocaleString('en-US')} used as another prompt's negative)` : ''; return `${countOf(total, 'prompt')} in a saved pair${detail}: hidden, restorable`; } function keptRows(rows, review, titleOf) { 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.notArchived) rows.push(['No longer archived (kept)', review.notArchived.toLocaleString('en-US')]); if (review.notFound) rows.push(['No longer exist', review.notFound.toLocaleString('en-US')]); } function deleteRows(rows, review, images) { rows.push(['Image links removed', `${countOf(images, '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)']); } /** #482: the grid when Delete permanently is chosen. */ function permanentGrid(review, titleOf) { const { plain, paired, archive } = plan(review), counts = review.pairedCounts; const parts = [plain.length && `${plain.length.toLocaleString('en-US')} not in a saved pair`, `${paired.length.toLocaleString('en-US')} in a saved pair`].filter(Boolean); const total = plain.length + paired.length; const deleted = plain.length ? `${countOf(total, 'prompt')}: ${parts.join(', ')}` : `${countOf(total, 'prompt')} in a saved pair`; const details = [counts.variants && `${counts.variants.toLocaleString('en-US')} with a negative prompt`, counts.archivedPairs && `${counts.archivedPairs.toLocaleString('en-US')} archived`].filter(Boolean); const rows = [['Delete permanently', paired.length ? deleted : `${countOf(total, 'prompt')} not in a saved pair`]]; // #492: on the archived shelf a selection may hold no prompt in a saved pair. if (paired.length) { rows.push(['Saved pairs removed', `${countOf(counts.pairs, 'saved pair')}${details.length ? ` (${details.join(', ')})` : ''}`]); if (counts.keptNegatives) rows.push(['Negatives kept', `${countOf(counts.keptNegatives, 'negative prompt')} ${counts.keptNegatives === 1 ? 'is' : 'are'} kept`]); } if (archive.length && onArchivedShelf(review)) rows.push(['Stays archived', `${countOf(archive.length, 'prompt')} used as another prompt's negative`]); else if (archive.length) rows.push(['Archive', `${countOf(archive.length, 'prompt')} used as another prompt's negative: hidden, restorable`]); keptRows(rows, review, titleOf); deleteRows(rows, review, review.imageLinks + review.pairedImageLinks); return rows; } /** The review's sentences, in order, each when it applies. */ export function reviewSentences(review) { if (permanent(review)) return permanentSentences(review); const sentences = [], negatives = review.archiveOnly.length; if (review.archive.length > negatives) { sentences.push('Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the ' + 'pair and its runs stay intact. To delete them later or bring them back, press Show archived prompts, select them ' + 'and press Delete… or Restore.'); } // #512: a negative selected without its positive stays the other prompt's variant. if (negatives) { sentences.push("Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant " + `stays.${negatives === review.archive.length ? ' To bring them back, press Show archived prompts, select them and press Restore.' : ''}`); } if (review.delete.length) sentences.push(removeSentence(review)); if (review.protected.length) sentences.push(PROTECTED_SENTENCE); return sentences; } const PROTECTED_SENTENCE = 'Protected prompts are tagged __protected__; remove the tag to delete them.'; function removeSentence(review, pairs = false) { 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.'; return "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.${pairs ? ' A pair node run with the same text saves a new pair.' : ''}`; } /** #482: the sentences when Delete permanently is chosen. */ function permanentSentences(review) { const { paired, archive } = plan(review); const sentences = paired.length ? ['Deleting a prompt in a saved pair also removes its saved pairs, with their titles, ' + 'categories, ratings, notes, tags, run counts and image links. Their negative prompts are kept. Recorded runs stay ' + 'in History and Activity, listed by their text.', removeSentence(review, true)] : [removeSentence(review)]; if (archive.length && onArchivedShelf(review)) sentences.push("Prompts used as another prompt's negative stay archived, so that prompt's variant stays."); else if (archive.length) sentences.push("Prompts used as another prompt's negative are archived instead of deleted, " + "so that prompt's variant stays."); if (review.protected.length) sentences.push(PROTECTED_SENTENCE); return sentences; } export function primaryLabel(review) { const { plain, paired, archive } = plan(review); const deletes = plain.length + paired.length, archives = archive.length; // #492: on the archived shelf what cannot be deleted stays archived; the primary names only the deletes. if (onArchivedShelf(review)) return `Delete ${countOf(deletes, 'prompt')}`; 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) { const none = review.single ? COPY.noneSingle : null; if (onArchivedShelf(review) && !review.delete.length && !review.deletable.length) return none ?? COPY.noneArchived; if (!review.delete.length && !review.archive.length) return none ?? COPY.none; const deletes = review.delete.length + plan(review).paired.length; if (deletes && 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 }) { let current = withChoice(review, 'archive'); const paragraphs = target => reviewSentences(target).map(text => paragraph(text)); const title = reviewTitle(current), grid = reviewGrid(current, titleOf); let sentences = paragraphs(current); const blocked = blockedReason(current); if (blocked) { onDismiss(); openCloseOnlyReview({ scope, trigger, title, grid, options: sentences, sentence: blocked }); return; } // #482: prompts in a saved pair ask Archive (the default, as #443) or Delete permanently, with their pairs. const choice = hasPairedChoice(review) ? pairedChoice(value => update(value)) : null; const dialog = openReviewDialog({ scope, trigger, title, grid, options: choice ? [choice, ...sentences] : sentences, cancelLabel: 'Cancel', primary: { label: primaryLabel(current), danger: true }, // #492: the archived shelf's review is only a permanent delete, so it opens on Cancel. focusCancel: onArchivedShelf(current), onStart: () => onStart(current), onDismiss }); function update(value) { current = withChoice(review, value); dialog.setRows(reviewGrid(current, titleOf)); const reason = blockedReason(current); const next = paragraphs(current); if (reason) next.push(paragraph(reason)); for (const node of sentences) node.remove(); choice.after(...next); sentences = next; const heading = choice.closest('dialog')?.querySelector('#modal-title'); if (heading) heading.textContent = reviewTitle(current); dialog.setPrimary({ label: primaryLabel(current), disabled: Boolean(reason) }); } } const paragraph = text => Object.assign(document.createElement('p'), { className: 'small', textContent: text }); export const PAIRED_CHOICES = Object.freeze([ Object.freeze({ value: 'archive', label: 'Archive (hidden, restorable)' }), Object.freeze({ value: 'delete', label: 'Delete permanently, with their saved pairs' }), ]); /** #482: the D90 radio row ("Prompts in a saved pair"), Archive checked; it is built fresh for every review. */ function pairedChoice(onChange) { const group = document.createElement('div'); group.className = 'field'; group.setAttribute('role', 'radiogroup'); const caption = document.createElement('span'); caption.textContent = 'Prompts in a saved pair'; caption.id = 'library-delete-choice-label'; group.setAttribute('aria-labelledby', caption.id); group.append(caption); for (const choice of PAIRED_CHOICES) { const label = document.createElement('label'); const input = document.createElement('input'); Object.assign(input, { type: 'radio', name: 'library-delete-choice', value: choice.value, checked: choice.value === 'archive' }); input.addEventListener('change', () => { if (input.checked) onChange(choice.value); }); label.append(input, ` ${choice.label}`); group.append(label); } return group; } /** The write's body: the reviewed lists, the epoch and the reviewed backup promise. */ export function deleteBody(review) { const body = { archive_ids: review.archive, delete_ids: review.delete, expected_epoch: review.epoch, backup: review.backup }; // #492: the archived shelf sends only what it deletes (its prompts are archived already). if (onArchivedShelf(review)) { return { ...body, archive_ids: review.deletable, delete_paired_ids: review.deletable, paired_digests: review.digests, shelf: 'archived' }; } // #482: the archive ids to delete permanently, each with the digest of what the review showed. if (permanent(review)) Object.assign(body, { delete_paired_ids: review.deletable, paired_digests: review.digests }); return body; } 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 = [], pairs = count(result.pairs_removed); if (deleted) parts.push(`Deleted ${countOf(deleted, 'prompt')}.`); if (pairs) parts.push(`Removed ${countOf(pairs, 'saved pair')}.`); if (archived) parts.push(`Archived ${countOf(archived, 'prompt')}.`); // #492: the archived shelf archives nothing, so it says only that nothing was deleted. if (!parts.length) parts.push(review?.shelf === 'archived' ? 'Nothing was deleted.' : 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.`); const restored = count(result.not_archived); if (restored) parts.push(`${were(restored, 'was', 'were')} restored since the review and kept.`); 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'} not deleted.`); 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 }; } /** * #481: review and run a delete of `ids` outside the selection bar (a prompt's own page): the same preview, review * (#482's choice included), backup and write. `onSettled(outcome, result, failure)` runs once the write has answered * (or the preview failed: `result` null); a dismissed review calls `onDismiss()`. The toast is the caller's. */ export async function reviewPromptDelete({ api, scope, trigger, ids: promptIds, epoch, titleOf, onSettled, onDismiss = () => {} }) { const body = { prompt_ids: promptIds, expected_epoch: epoch }; let preview = null, failure = null; try { preview = await api.post(PREVIEW_URL, body, { signal: scope.signal }); } catch (error) { failure = error; } if (scope.signal.aborted) return; if (failure !== null || !preview?.success) { onSettled(previewOutcome(failure), null, failure); return; } // #512: the review names this one prompt. const review = { ...reviewOf(preview), epoch, single: true }; openDeleteReview({ scope, trigger, review, titleOf, onDismiss, onStart: async chosen => { let result = null, error = null; try { result = await api.post(DELETE_URL, deleteBody(chosen)); } catch (caught) { error = caught; } onSettled(deleteOutcome(result, error, chosen), result?.success ? result : null, error); } }); }