344 lines
16 KiB
JavaScript
344 lines
16 KiB
JavaScript
/** Save and edit dialogs for Workflow Recipes. */
|
|
|
|
import { translate } from "./locales.js";
|
|
import { anomalousAlert, anomalousConfirm } from "./ui_dialog.js";
|
|
import { appendText } from "./ui_recipe_detail_dom.js";
|
|
import { outputImageUrl, safeThumbnail } from "./ui_recipe_media.js";
|
|
|
|
const t = (key, params) => translate(key, params);
|
|
const RECIPE_PRESENTATION_DEFAULTS = Object.freeze({ saveModelPreviewSnapshots: true });
|
|
|
|
export function formatRecipeText(key, values = {}) {
|
|
return Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), t(key));
|
|
}
|
|
|
|
export function showRecipeSaveDialog(owner, canvasThumbnail, workflowScope, modelAudit, initial = null) {
|
|
return new Promise((resolve) => {
|
|
const selection = {
|
|
thumbnail: safeThumbnail(initial?.thumbnail) || safeThumbnail(canvasThumbnail),
|
|
sourceImage: initial?.source_image || null,
|
|
// A saved snapshot is what makes the cover portable in an export.
|
|
// Preserve an explicit opt-out, but enable the sharing-oriented
|
|
// behavior for new and legacy recipes without a stored preference.
|
|
saveModelPreviewSnapshots: initial?.presentation?.save_model_preview_snapshots
|
|
?? RECIPE_PRESENTATION_DEFAULTS.saveModelPreviewSnapshots,
|
|
};
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'anomalous-recipe-dialog-overlay';
|
|
overlay.setAttribute('role', 'dialog');
|
|
overlay.setAttribute('aria-modal', 'true');
|
|
|
|
const dialog = document.createElement('div');
|
|
dialog.className = 'anomalous-recipe-dialog';
|
|
appendText(dialog, 'h3', t('recipeSaveTitle'));
|
|
appendText(
|
|
dialog,
|
|
'p',
|
|
t(workflowScope === 'partial' ? 'recipeScopePartialSaveHint' : 'recipeScopeCompleteSaveHint'),
|
|
'anomalous-recipe-detail-muted',
|
|
);
|
|
|
|
const nameLabel = appendText(dialog, 'label', t('recipeName'));
|
|
const nameInput = document.createElement('input');
|
|
nameInput.className = 'anomalous-nb-select';
|
|
nameInput.type = 'text';
|
|
nameInput.maxLength = 120;
|
|
nameInput.value = initial?.name || t('recipeDefaultName');
|
|
nameLabel.appendChild(nameInput);
|
|
|
|
const tagsLabel = appendText(dialog, 'label', t('recipeTags'));
|
|
const tagsInput = document.createElement('input');
|
|
tagsInput.className = 'anomalous-nb-select';
|
|
tagsInput.type = 'text';
|
|
tagsInput.maxLength = 300;
|
|
tagsInput.placeholder = t('recipeTagsHint');
|
|
tagsInput.value = Array.isArray(initial?.tags) ? initial.tags.join(', ') : '';
|
|
tagsLabel.appendChild(tagsInput);
|
|
|
|
const notesLabel = appendText(dialog, 'label', t('recipeNotes'));
|
|
const notesInput = document.createElement('textarea');
|
|
notesInput.className = 'anomalous-nb-textarea';
|
|
notesInput.maxLength = 3000;
|
|
notesInput.placeholder = t('recipeNotesHint');
|
|
notesInput.value = initial?.notes || '';
|
|
notesLabel.appendChild(notesInput);
|
|
|
|
const verifiableMissing = modelAudit?.verifiableMissing || [];
|
|
let verifyModelIdentitiesInput = null;
|
|
if (verifiableMissing.length) {
|
|
const verificationSection = document.createElement('section');
|
|
verificationSection.className = 'anomalous-recipe-save-section';
|
|
appendText(
|
|
verificationSection,
|
|
'strong',
|
|
formatRecipeText('recipeVerificationMissing', { count: verifiableMissing.length }),
|
|
);
|
|
appendText(
|
|
verificationSection,
|
|
'small',
|
|
verifiableMissing.map((reference) => reference.saved_value).join('、'),
|
|
'anomalous-recipe-node-hint',
|
|
);
|
|
const verificationChoice = document.createElement('label');
|
|
verificationChoice.className = 'anomalous-recipe-checkbox';
|
|
verifyModelIdentitiesInput = document.createElement('input');
|
|
verifyModelIdentitiesInput.type = 'checkbox';
|
|
verifyModelIdentitiesInput.checked = false;
|
|
verificationChoice.append(
|
|
verifyModelIdentitiesInput,
|
|
document.createTextNode(t('recipeVerifyOnSave')),
|
|
);
|
|
verificationSection.appendChild(verificationChoice);
|
|
appendText(
|
|
verificationSection,
|
|
'small',
|
|
t('recipeVerifyOnSaveHint'),
|
|
'anomalous-recipe-node-hint',
|
|
);
|
|
dialog.appendChild(verificationSection);
|
|
}
|
|
|
|
const coverSection = document.createElement('section');
|
|
coverSection.className = 'anomalous-recipe-save-section';
|
|
appendText(coverSection, 'strong', t('recipeBindImage'));
|
|
const coverChoices = document.createElement('div');
|
|
coverChoices.className = 'anomalous-recipe-cover-choices';
|
|
const coverPreview = document.createElement('img');
|
|
coverPreview.className = 'anomalous-recipe-dialog-preview';
|
|
coverPreview.alt = t('recipeThumbnail');
|
|
const initialPreview = selection.thumbnail || outputImageUrl(selection.sourceImage);
|
|
if (initialPreview) coverPreview.src = initialPreview;
|
|
else coverPreview.style.display = 'none';
|
|
|
|
const choiceButtons = [];
|
|
const selectCover = (button, sourceImage, previewUrl, thumbnailValue) => {
|
|
for (const choice of choiceButtons) choice.classList.toggle('selected', choice === button);
|
|
selection.sourceImage = sourceImage;
|
|
selection.thumbnail = thumbnailValue;
|
|
if (previewUrl) {
|
|
coverPreview.src = previewUrl;
|
|
coverPreview.style.display = 'block';
|
|
} else {
|
|
coverPreview.removeAttribute('src');
|
|
coverPreview.style.display = 'none';
|
|
}
|
|
};
|
|
|
|
const noneChoice = appendText(coverChoices, 'button', t('recipeNoImage'), 'anomalous-recipe-cover-choice');
|
|
noneChoice.type = 'button';
|
|
choiceButtons.push(noneChoice);
|
|
noneChoice.onclick = () => selectCover(noneChoice, null, null, null);
|
|
if (safeThumbnail(initial?.thumbnail) || initial?.source_image) {
|
|
const existingChoice = appendText(coverChoices, 'button', t('recipeKeepImage'), 'anomalous-recipe-cover-choice selected');
|
|
existingChoice.type = 'button';
|
|
choiceButtons.push(existingChoice);
|
|
existingChoice.onclick = () => selectCover(
|
|
existingChoice,
|
|
initial?.source_image || null,
|
|
safeThumbnail(initial?.thumbnail) || outputImageUrl(initial?.source_image),
|
|
safeThumbnail(initial?.thumbnail),
|
|
);
|
|
}
|
|
if (safeThumbnail(canvasThumbnail)) {
|
|
const canvasChoice = document.createElement('button');
|
|
canvasChoice.type = 'button';
|
|
canvasChoice.className = `anomalous-recipe-cover-choice${initial ? '' : ' selected'}`;
|
|
const canvasImage = document.createElement('img');
|
|
canvasImage.src = canvasThumbnail;
|
|
canvasImage.alt = t('recipeCanvasPreview');
|
|
appendText(canvasChoice, 'span', t('recipeCanvasPreview'));
|
|
canvasChoice.prepend(canvasImage);
|
|
choiceButtons.push(canvasChoice);
|
|
canvasChoice.onclick = () => selectCover(canvasChoice, null, canvasThumbnail, safeThumbnail(canvasThumbnail));
|
|
} else if (!initial) {
|
|
noneChoice.classList.add('selected');
|
|
}
|
|
|
|
const recentStatus = appendText(coverSection, 'small', t('recipeLoadingRecentImages'), 'anomalous-recipe-node-hint');
|
|
coverSection.append(coverChoices, coverPreview);
|
|
dialog.appendChild(coverSection);
|
|
|
|
fetch('/anomalous/gallery_images?page=1&limit=12')
|
|
.then((response) => response.ok ? response.json() : Promise.reject(new Error('image list failed')))
|
|
.then((payload) => {
|
|
recentStatus.textContent = t('recipeRecentImages');
|
|
for (const imageData of payload.images || []) {
|
|
const url = outputImageUrl(imageData);
|
|
if (!url) continue;
|
|
const choice = document.createElement('button');
|
|
choice.type = 'button';
|
|
choice.className = 'anomalous-recipe-cover-choice anomalous-recipe-output-choice';
|
|
const image = document.createElement('img');
|
|
image.src = url;
|
|
image.loading = 'lazy';
|
|
image.alt = imageData.filename;
|
|
choice.appendChild(image);
|
|
choice.title = imageData.filename;
|
|
choiceButtons.push(choice);
|
|
choice.onclick = () => selectCover(choice, {
|
|
filename: imageData.filename,
|
|
subfolder: imageData.subfolder || '',
|
|
type: 'output',
|
|
}, url, null);
|
|
coverChoices.appendChild(choice);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.warn('Could not load recent recipe images:', error);
|
|
recentStatus.textContent = t('recipeRecentImagesUnavailable');
|
|
});
|
|
|
|
const error = appendText(dialog, 'div', '', 'anomalous-recipe-dialog-error');
|
|
const actions = document.createElement('div');
|
|
actions.className = 'anomalous-recipe-actions';
|
|
const cancel = appendText(actions, 'button', t('recipeCancel'), 'anomalous-btn-danger');
|
|
const save = appendText(actions, 'button', t('recipeSave'), 'anomalous-btn-primary');
|
|
cancel.type = 'button';
|
|
save.type = 'button';
|
|
|
|
const close = (value) => {
|
|
overlay.remove();
|
|
resolve(value);
|
|
};
|
|
cancel.onclick = () => close(null);
|
|
overlay.onclick = (event) => {
|
|
if (event.target === overlay) close(null);
|
|
};
|
|
save.onclick = () => {
|
|
const name = nameInput.value.trim();
|
|
if (!name) {
|
|
error.textContent = t('recipeNameRequired');
|
|
nameInput.focus();
|
|
return;
|
|
}
|
|
const tags = [...new Set(tagsInput.value.split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20);
|
|
close({
|
|
name,
|
|
tags,
|
|
notes: notesInput.value.trim(),
|
|
thumbnail: selection.thumbnail,
|
|
sourceImage: selection.sourceImage,
|
|
saveModelPreviewSnapshots: selection.saveModelPreviewSnapshots,
|
|
verifyModelIdentities: Boolean(verifyModelIdentitiesInput?.checked),
|
|
});
|
|
};
|
|
dialog.appendChild(actions);
|
|
overlay.appendChild(dialog);
|
|
(owner.nbPanel || document.body).appendChild(overlay);
|
|
nameInput.focus();
|
|
nameInput.select();
|
|
});
|
|
}
|
|
|
|
export function showRecipeEditDialog(owner, recipeData, filename, history) {
|
|
return new Promise((resolve) => {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'anomalous-recipe-dialog-overlay';
|
|
overlay.setAttribute('role', 'dialog');
|
|
overlay.setAttribute('aria-modal', 'true');
|
|
const dialog = document.createElement('div');
|
|
dialog.className = 'anomalous-recipe-dialog anomalous-recipe-edit-dialog';
|
|
appendText(dialog, 'h3', t('recipeEditTitle'));
|
|
|
|
const nameLabel = appendText(dialog, 'label', t('recipeName'));
|
|
const nameInput = document.createElement('input');
|
|
nameInput.className = 'anomalous-nb-select';
|
|
nameInput.type = 'text';
|
|
nameInput.maxLength = 120;
|
|
nameInput.value = recipeData.name || '';
|
|
nameLabel.appendChild(nameInput);
|
|
|
|
const tagsLabel = appendText(dialog, 'label', t('recipeTags'));
|
|
const tagsInput = document.createElement('input');
|
|
tagsInput.className = 'anomalous-nb-select';
|
|
tagsInput.type = 'text';
|
|
tagsInput.maxLength = 300;
|
|
tagsInput.value = Array.isArray(recipeData.tags) ? recipeData.tags.join(', ') : '';
|
|
tagsInput.placeholder = t('recipeTagsHint');
|
|
tagsLabel.appendChild(tagsInput);
|
|
|
|
const notesLabel = appendText(dialog, 'label', t('recipeNotes'));
|
|
const notesInput = document.createElement('textarea');
|
|
notesInput.className = 'anomalous-nb-textarea';
|
|
notesInput.maxLength = 3000;
|
|
notesInput.value = recipeData.notes || '';
|
|
notesInput.placeholder = t('recipeNotesHint');
|
|
notesLabel.appendChild(notesInput);
|
|
|
|
const historyDetails = document.createElement('details');
|
|
historyDetails.className = 'anomalous-recipe-node-details';
|
|
const historySummary = document.createElement('summary');
|
|
historySummary.textContent = `${t('recipeHistory')} (${history.length})`;
|
|
historyDetails.appendChild(historySummary);
|
|
const historyList = document.createElement('div');
|
|
historyList.className = 'anomalous-recipe-history-list';
|
|
if (!history.length) {
|
|
appendText(historyList, 'small', t('recipeHistoryEmpty'), 'anomalous-recipe-node-hint');
|
|
} else {
|
|
for (const version of history) {
|
|
const row = document.createElement('div');
|
|
row.className = 'anomalous-recipe-history-row';
|
|
const date = Number.isFinite(Number(version.timestamp))
|
|
? new Date(Number(version.timestamp)).toLocaleString()
|
|
: t('recipeUnknownVersion');
|
|
appendText(row, 'span', `${date} · ${version.name || t('recipeUntitled')}`);
|
|
const restore = appendText(row, 'button', t('recipeRestoreVersion'), 'anomalous-btn-danger');
|
|
restore.type = 'button';
|
|
restore.onclick = async () => {
|
|
if (!await anomalousConfirm(t('recipeRestoreVersionConfirm'))) return;
|
|
try {
|
|
const response = await fetch('/anomalous/restore_recipe_version', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename, version: version.version }),
|
|
});
|
|
if (!response.ok) throw new Error('recipe history restore failed');
|
|
overlay.remove();
|
|
await owner.refreshRecipes();
|
|
resolve({ mode: 'restored' });
|
|
} catch (error) {
|
|
console.error('Could not restore Workflow Recipe version:', error);
|
|
await anomalousAlert(t('recipeUpdateError'));
|
|
}
|
|
};
|
|
row.appendChild(restore);
|
|
historyList.appendChild(row);
|
|
}
|
|
}
|
|
historyDetails.appendChild(historyList);
|
|
dialog.appendChild(historyDetails);
|
|
|
|
const error = appendText(dialog, 'div', '', 'anomalous-recipe-dialog-error');
|
|
const actions = document.createElement('div');
|
|
actions.className = 'anomalous-recipe-actions';
|
|
const cancel = appendText(actions, 'button', t('recipeCancel'), 'anomalous-btn-danger');
|
|
const canvas = appendText(actions, 'button', t('recipeEditCanvas'), 'anomalous-btn-primary');
|
|
const save = appendText(actions, 'button', t('recipeUpdate'), 'anomalous-btn-success');
|
|
for (const button of [cancel, canvas, save]) button.type = 'button';
|
|
const close = (value) => { overlay.remove(); resolve(value); };
|
|
cancel.onclick = () => close(null);
|
|
overlay.onclick = (event) => { if (event.target === overlay) close(null); };
|
|
canvas.onclick = () => close({ mode: 'canvas' });
|
|
save.onclick = () => {
|
|
const name = nameInput.value.trim();
|
|
if (!name) {
|
|
error.textContent = t('recipeNameRequired');
|
|
nameInput.focus();
|
|
return;
|
|
}
|
|
close({
|
|
mode: 'save',
|
|
name,
|
|
tags: [...new Set(tagsInput.value.split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20),
|
|
notes: notesInput.value.trim(),
|
|
});
|
|
};
|
|
dialog.appendChild(actions);
|
|
overlay.appendChild(dialog);
|
|
(owner.nbPanel || document.body).appendChild(overlay);
|
|
nameInput.focus();
|
|
nameInput.select();
|
|
});
|
|
}
|
|
|