Merge remote-tracking branch 'origin/main' into fix/501-removal-mark-gaps
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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.',
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const rows = [
|
||||
['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`],
|
||||
// The archive reason is the saved pair (a pair's positive or negative), with or without negative variants.
|
||||
['Archive', `${countOf(review.archive.length, 'prompt')} in a saved pair: 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 in a saved pair, as its positive or its negative, are archived instead of deleted, so the ' +
|
||||
'pair and its 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 };
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { NO_TAGS, parseBulkTags } from './images-tagging-bulk.mjs';
|
||||
import { openInputDialog } from './workspace-dialogs.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { COPY as DELETE_COPY, DELETE_LABEL, DELETE_URL, PREVIEW_URL, REVIEWING, RESTORE_LABEL, RESTORE_URL, deleteBody, deleteOutcome,
|
||||
openDeleteReview, previewOutcome, restoreOutcome, reviewOf } from './library-delete.mjs';
|
||||
|
||||
/**
|
||||
* The Prompt library's selection and its bulk actions (D148, D151, beyond the concept): "Add tags" (#442, D151) and "Set
|
||||
@@ -19,6 +21,10 @@ import { showWorkspaceToast } from './toast.mjs';
|
||||
* Apply posts `{prompt_ids, category|tags, expected_epoch}` to `POST /prompt_manager/bulk/category` or `/bulk/tags`:
|
||||
* the epoch the shelf was read under, so a restored storage answers 409 `storage-changed` and nothing is written.
|
||||
* Add tags writes prompt tags only, never image tags or auto-tags.
|
||||
*
|
||||
* Delete… (#443, D152) previews the selection, opens its review (`library-delete.mjs`) and posts the reviewed lists:
|
||||
* unpaired prompts are deleted, paired ones archived, protected ones kept. On the archived shelf (`getShelf()` is
|
||||
* 'archived') the bar offers Restore instead of the other actions.
|
||||
*/
|
||||
export const CATEGORY_URL = '/prompt_manager/bulk/category';
|
||||
export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' });
|
||||
@@ -123,29 +129,34 @@ export function categoryProblem(value) {
|
||||
* to `focusFallback` when it was on the bar the reload hides.
|
||||
*/
|
||||
export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted,
|
||||
onReload }) {
|
||||
onReload, getShelf = () => 'active', nameOf = () => null }) {
|
||||
let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false;
|
||||
const element = el('div');
|
||||
element.id = 'library-bulkbar';
|
||||
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
|
||||
const addTags = createButton('Add tags');
|
||||
const setCategory = createButton('Set category');
|
||||
const deleteButton = createButton(DELETE_LABEL, 'danger');
|
||||
const restoreButton = createButton(RESTORE_LABEL);
|
||||
const clearButton = createButton('Clear');
|
||||
const selectLoaded = createButton('');
|
||||
const actions = [addTags, setCategory, selectLoaded];
|
||||
bulk.append(count, addTags, setCategory, clearButton);
|
||||
// Every bar action is aria-disabled while a review or a write runs (D148, D151, D152), Clear too.
|
||||
const actions = [addTags, setCategory, deleteButton, restoreButton, selectLoaded, clearButton];
|
||||
let shownShelf = null;
|
||||
scope.listen(selectLoaded, 'click', () => {
|
||||
if (writing) return;
|
||||
selectAll();
|
||||
// The pressed offer left the bar: focus moves to its first action, as after D135's offer.
|
||||
addTags.focus({ preventScroll: true });
|
||||
(shownShelf === 'archived' ? restoreButton : addTags).focus({ preventScroll: true });
|
||||
});
|
||||
scope.listen(addTags, 'click', () => openTags());
|
||||
scope.listen(setCategory, 'click', () => openCategory());
|
||||
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
|
||||
scope.listen(deleteButton, 'click', () => openDelete());
|
||||
scope.listen(restoreButton, 'click', () => restoreSelected());
|
||||
scope.listen(clearButton, 'click', () => { if (writing) return; clear(); focusFallback.focus({ preventScroll: true }); });
|
||||
// Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear.
|
||||
scope.listen(element, 'keydown', event => {
|
||||
if (event.key !== 'Escape' || !checked.size) return;
|
||||
if (event.key !== 'Escape' || !checked.size || writing) return;
|
||||
event.preventDefault();
|
||||
clear();
|
||||
focusFallback.focus({ preventScroll: true });
|
||||
@@ -154,6 +165,11 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
const ids = () => getItems().map(item => item.id);
|
||||
function render() {
|
||||
if (!checked.size) { bulk.remove(); return; }
|
||||
const shelf = getShelf() === 'archived' ? 'archived' : 'active';
|
||||
if (shelf !== shownShelf) {
|
||||
shownShelf = shelf;
|
||||
bulk.replaceChildren(count, ...(shelf === 'archived' ? [restoreButton] : [addTags, setCategory, deleteButton]), clearButton);
|
||||
}
|
||||
const label = selectionLabel(checked.size);
|
||||
if (count.textContent !== label) count.textContent = label;
|
||||
const keys = ids();
|
||||
@@ -240,20 +256,62 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
openInputDialog({ scope, trigger: addTags, ...PROMPT_TAGS_DIALOG, suggestions, validate: tagsProblem,
|
||||
onApply: text => write(TAGS_URL, { ...chosen, tags: parseBulkTags(text) }, 'tags') });
|
||||
}
|
||||
/** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */
|
||||
async function write(url, body, kind) {
|
||||
/** Hold every bar action while a review or write runs; `label` is what Delete… reads meanwhile. */
|
||||
function lock(label = null) {
|
||||
writing = true;
|
||||
for (const button of actions) button.setAttribute('aria-disabled', 'true');
|
||||
if (label !== null) deleteButton.textContent = label;
|
||||
}
|
||||
function unlock() {
|
||||
writing = false;
|
||||
for (const button of actions) button.removeAttribute('aria-disabled');
|
||||
deleteButton.textContent = DELETE_LABEL;
|
||||
}
|
||||
/** Delete… (#443): preview the selection, then review it; Start posts what the review showed. */
|
||||
async function openDelete() {
|
||||
const chosen = target(DELETE_COPY.reviewFailed);
|
||||
if (!chosen) return;
|
||||
lock(REVIEWING);
|
||||
let preview = null, failure = null;
|
||||
try {
|
||||
preview = await api.post(PREVIEW_URL, chosen, { signal: scope.signal });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (failure !== null || !preview?.success) {
|
||||
unlock();
|
||||
const outcome = previewOutcome(failure);
|
||||
showWorkspaceToast(outcome.toast);
|
||||
if (outcome.reload) onReload();
|
||||
return;
|
||||
}
|
||||
const review = { ...reviewOf(preview), epoch: chosen.expected_epoch };
|
||||
// The review is open: the bar stays held behind it, under its own name again.
|
||||
deleteButton.textContent = DELETE_LABEL;
|
||||
openDeleteReview({ scope, trigger: deleteButton, review, titleOf: nameOf,
|
||||
onDismiss: unlock,
|
||||
onStart: () => write(DELETE_URL, deleteBody(review), 'delete', (result, error) => deleteOutcome(result, error, review),
|
||||
review.delete.length ? 'Deleting…' : 'Archiving…') });
|
||||
}
|
||||
/** Restore (#443): the selected archived prompts go back on the shelf. */
|
||||
function restoreSelected() {
|
||||
const chosen = target(DELETE_COPY.restoreFailed);
|
||||
if (!chosen) return;
|
||||
write(RESTORE_URL, chosen, 'restore', restoreOutcome);
|
||||
}
|
||||
/** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */
|
||||
async function write(url, body, kind, outcomeOf = (result, failure) => writeOutcome(result, failure, kind), label = null) {
|
||||
lock(label);
|
||||
let result = null, failure = null;
|
||||
try {
|
||||
result = await api.post(url, body);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
writing = false;
|
||||
for (const button of actions) button.removeAttribute('aria-disabled');
|
||||
unlock();
|
||||
}
|
||||
const outcome = writeOutcome(result, failure, kind);
|
||||
const outcome = outcomeOf(result, failure);
|
||||
// A left page still invalidates what it cached, so a return never shows the old categories.
|
||||
if (outcome.committed) onCommitted(kind, result?.success ? result : { phase: 'unconfirmed' });
|
||||
if (scope.signal.aborted) return;
|
||||
@@ -274,7 +332,9 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
|
||||
selectAll,
|
||||
/** A loaded batch: the count and the offer follow it. */
|
||||
listingChanged: () => { if (checked.size) render(); },
|
||||
/** Whether a Set category write is still in flight. */
|
||||
/** Whether a review or a write is still in flight. */
|
||||
writing: () => writing,
|
||||
/** The shelf changed (Show archived prompts): the bar's actions follow it. */
|
||||
shelfChanged: () => { shownShelf = null; render(); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ function parameters(filters,limit) {
|
||||
if(typeof folder!=='string' || folder.length>1024) throw new TypeError('Invalid library folder');
|
||||
if(tagMode!=='all') query.set('tag_mode',tagMode);
|
||||
if(folder) query.set('folder',folder);
|
||||
query.set('sort',sort);return query;
|
||||
query.set('sort',sort);
|
||||
// #443 D152: the archived shelf; the default shelf sends nothing, as before.
|
||||
const shelf=filters.shelf || 'active';
|
||||
if(!['active','archived'].includes(shelf)) throw new TypeError('Invalid library shelf');
|
||||
if(shelf==='archived') query.set('shelf','archived');
|
||||
return query;
|
||||
}
|
||||
/** The Library's tag match values (#444); the first is the default, sent as no parameter. */
|
||||
export const LIBRARY_TAG_MODES=Object.freeze(['all','any','untagged']);
|
||||
@@ -31,7 +36,7 @@ function validatePage(page,cursor,query) {
|
||||
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
|
||||
}
|
||||
export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
|
||||
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,archivedTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
|
||||
let query=parameters({},40),request,disposed=false,seen=new Set();
|
||||
if(restore) {
|
||||
query=parameters(restore.filters,40);const saved=restore.state;
|
||||
@@ -57,11 +62,13 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
const incoming=[];const added=new Set();
|
||||
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
|
||||
for(const value of added) seen.add(value);
|
||||
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
|
||||
// #443: the archived parents matching the filters (Show archived prompts); null from an older server.
|
||||
const archivedTotal=Number.isSafeInteger(page.archived_total) && page.archived_total>=0?page.archived_total:null;
|
||||
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,archivedTotal,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
|
||||
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
|
||||
}
|
||||
return {get state(){return state;},loadMore,async reset(filters={}) {
|
||||
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
|
||||
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,archivedTotal:state.archivedTotal ?? null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
|
||||
},dispose(){disposed=true;request?.abort();request=null;}};
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import {libraryFilters,libraryViewState} from './saved-views.mjs';
|
||||
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
import {mountLibrarySelection} from './library-selection.mjs';
|
||||
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
|
||||
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome} from './library-delete.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
@@ -109,6 +110,8 @@ export function rememberLibraryResult(result) {
|
||||
function outcomeMessage(result,outcome) {
|
||||
const {kind,receipt}=result.commit;
|
||||
if(kind==='metadata') return 'Saved.';
|
||||
// #443: a save that reused an archived prompt leaves it archived; say how to bring it back.
|
||||
if((kind==='create' || kind==='add-negative') && result.detail?.positive?.archived===true) return DELETE_COPY.archivedText;
|
||||
if(kind==='archive-pair') return ARCHIVED_MESSAGE;
|
||||
if(kind!=='create' && kind!=='add-negative') return null;
|
||||
if(receipt.positive_created) return 'Saved.';
|
||||
@@ -160,7 +163,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration;
|
||||
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
|
||||
const restorable=visit?.state?visit:null;
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',tagMode:'all',folder:'',...visit?.filters,...saved};
|
||||
// #443 D152: `shelf` ('active' or 'archived', Show archived prompts) lives in the visit's filters, never in a saved view.
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',tagMode:'all',folder:'',shelf:'active',...visit?.filters,...saved};
|
||||
// D153: a sort the shelf no longer offers (an older "Highest rated" visit) reads as Recently added.
|
||||
if(!LIBRARY_SORTS.some(([value])=>value===filters.sort)) filters.sort='newest';
|
||||
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
|
||||
@@ -182,14 +186,22 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
// #437 D148: select prompts and set their category (the legacy admin's Set Category); a write reloads the shelf.
|
||||
let categoryNames=[];
|
||||
const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
|
||||
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},
|
||||
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},getShelf:()=>filters.shelf,
|
||||
nameOf:id=>{const item=state?.items.find(entry=>entry.id===id);return item?displayTitle(item):null;},
|
||||
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();}});
|
||||
const message=el('p','','library-status');message.setAttribute('role','status');
|
||||
const continuation=el('div',undefined,'library-continuation');
|
||||
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
|
||||
const actions=headingActions(main,section);
|
||||
const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON');
|
||||
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(exportButton,newPrompt);
|
||||
// #443 D152: the shelf's counterpart of Negative variants' Show archived (#235); N follows the filters.
|
||||
const showArchived=createButton('Show archived prompts');showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived'));
|
||||
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(showArchived,exportButton,newPrompt);
|
||||
scope.listen(showArchived,'click',()=>{
|
||||
if(selection.writing()) return;
|
||||
filters.shelf=filters.shelf==='archived'?'active':'archived';showArchived.setAttribute('aria-pressed',String(filters.shelf==='archived'));
|
||||
reset();selection.shelfChanged();
|
||||
});
|
||||
const exportStatus=el('p','','library-options-status');exportStatus.hidden=true;exportStatus.setAttribute('role','alert');
|
||||
const editor=createNewPromptEditor(api,scope,main);
|
||||
scope.listen(newPrompt,'click',()=>editor.open({mode:'new',trigger:newPrompt}));
|
||||
@@ -244,6 +256,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const content=el('div',undefined,'card-content');
|
||||
const heading=el('div',undefined,'card-title');const open=createButton(name);open.dataset.history='';
|
||||
heading.append(open,el('span',item.category || 'Uncategorized','chip'));
|
||||
if(item.archived===true) heading.append(el('span','Archived','chip'));
|
||||
const prompts=el('div');
|
||||
const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive'],title:name});
|
||||
const meta=el('div',undefined,'card-meta');
|
||||
@@ -391,11 +404,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
// A loaded batch: the selection bar's count and Select all N loaded follow it (D151).
|
||||
if(batch) selection.listingChanged();
|
||||
if(next.variantTotal!==null) {
|
||||
const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
|
||||
const count=filters.shelf==='archived'?`${countOf(next.total,'archived prompt')} · ${countOf(next.variantTotal,'negative variant')} hidden with them`
|
||||
:`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
|
||||
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
|
||||
}
|
||||
const archivedLabel=next.archivedTotal===null?'Show archived prompts':`Show archived prompts (${next.archivedTotal.toLocaleString('en-US')})`;
|
||||
if(showArchived.textContent!==archivedLabel) showArchived.textContent=archivedLabel;
|
||||
const filtered=Boolean(filters.text || filters.category || filters.tags.length || filters.folder || filters.tagMode!=='all');
|
||||
const empty=filters.shelf==='archived'?(filtered?'No archived prompts match.':'No archived prompts.'):'No matching prompts. Try changing the filters.';
|
||||
const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':'';
|
||||
message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden;
|
||||
message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?empty:!next.hasMore?'You’re all caught up.':'')+hidden;
|
||||
message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading));
|
||||
// #464: a loaded page is laid out now, not on the next frame: a starved renderer (a background tab, headless
|
||||
// WebKit under load) can hold frames for seconds, which left a loaded shelf empty. Measurements still flush per frame.
|
||||
@@ -490,6 +508,26 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
if(current) current.apply(result);else loader.load(positiveId);
|
||||
focusHeading(main);
|
||||
}
|
||||
/** The Archived notice: Restore takes the prompt off the archive, then the page reads it again. */
|
||||
function archivedNotice(content,shown) {
|
||||
const notice=el('div',undefined,'notice library-archived-notice'),copy=el('div'),row=el('div',undefined,'row-actions');
|
||||
const status=el('p','','small');status.setAttribute('role','status');status.hidden=true;
|
||||
const restore=createButton('Restore');restore.setAttribute('aria-label','Restore this prompt to the Prompt library');
|
||||
copy.append(el('h2','Archived'),el('p',archivedNoticeText(shown.positive.variant_count)),status);row.append(restore);notice.append(copy,row);
|
||||
content.listen(restore,'click',async()=>{
|
||||
if(restore.getAttribute('aria-disabled')==='true') return;
|
||||
restore.setAttribute('aria-disabled','true');status.hidden=true;
|
||||
let result=null,failure=null;
|
||||
try {result=await api.post(RESTORE_URL,{prompt_ids:[shown.positive.id],expected_epoch:shown.library_epoch});} catch(error) {failure=error;}
|
||||
const outcome=restoreOutcome(result,failure);
|
||||
if(outcome.committed) invalidateLibraryAfterWrite({kind:'bulk-restore',receipt:result?.success?result:{phase:'unconfirmed'}});
|
||||
if(content.signal.aborted) return;
|
||||
restore.removeAttribute('aria-disabled');
|
||||
if(outcome.reload) {showWorkspaceToast(outcome.toast);detail=null;loader.load(positiveId);focusHeading(main);return;}
|
||||
status.textContent=outcome.toast;status.hidden=false;
|
||||
});
|
||||
return notice;
|
||||
}
|
||||
/** Explicit exit after restored storage: dispose the recorded view, then remount with a fresh visit. */
|
||||
function reloadView() {
|
||||
writeGeneration++;shelfVisit=shelfVisit && {filters:shelfVisit.filters,anchor:null};
|
||||
@@ -667,6 +705,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const gallery=el('section',undefined,'history-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(galleryHeading);
|
||||
const images=el('div',undefined,'library-linked-images');gallery.append(images);
|
||||
const recorded=el('div',undefined,'library-recorded-container');
|
||||
// #443 D152: an archived prompt's page says so first, with Restore (the concept's `.notice`).
|
||||
if(detail.positive.archived===true) body.append(archivedNotice(content,detail));
|
||||
body.append(summary,variants,gallery,recorded);
|
||||
const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null);
|
||||
const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true);
|
||||
|
||||
@@ -56,8 +56,8 @@ export const HOST_UNAVAILABLE = 'Server settings could not be read.';
|
||||
export const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
|
||||
export const WARNING_NOTE = 'Restores are staged, migrated and validated before replacing live data.';
|
||||
// D104 (M2): what the safety-backup policy does (the concept's "Preview a verified backup…" line misstates it).
|
||||
export const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA ' +
|
||||
'imports change records. A restore always keeps one.';
|
||||
export const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA ' +
|
||||
'imports and prompt deletes change records. A restore always keeps one.';
|
||||
// I1: a third review while two are held (another window's): what the user can do about it.
|
||||
export const REVIEWS_FULL = 'Two restores are already being reviewed in another window. Discard one there, or try again ' +
|
||||
'when it expires (15 minutes after it was reviewed).';
|
||||
|
||||
Reference in New Issue
Block a user