Every page in both languages, all themes and at 480: - Give the window its own text colour: text with none took ComfyUI's, white on the light theme (Tidy headings and file names, the site chip). - Keep a task's link chip in its row, its x in reach, and colour the next-task chip. - Let choices in Settings wrap, the Models search shrink, and a workflow's preset row go to two lines. - Add --amb-on-link for text on the accent: pale in the dark themes, so white was hard to read. - Lists and colons follow the language (listSeparator, wordColon); tests/words_in_language.mjs keeps it so. - Right plurals in English, "AI" on the rail, one line left in Chinese translated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
12 KiB
JavaScript
249 lines
12 KiB
JavaScript
/**
|
|
* Settings → Backup (api/backup.py): exporting the user's data as one .zip, and putting a
|
|
* backup back. Export asks what to include and downloads the file. Import uploads the chosen
|
|
* .zip, shows what is in it next to what this computer has (per store, the models found by
|
|
* hash), lets the user pick the parts and whether differing files are replaced, then puts it
|
|
* back and says what happened. Nothing is erased: replaced files go to the Recycle Bin.
|
|
* The models the backup's computer had and this one lacks can be downloaded again
|
|
* (ui_backup_downloads.js).
|
|
*/
|
|
|
|
import { translate as t } from './locales.js';
|
|
import { formatSize } from './file_size.js';
|
|
import { openRestoreDownloads } from './ui_backup_downloads.js';
|
|
|
|
const PARTS = ['recipes', 'combos', 'materials', 'parameters', 'comfy_workflows'];
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function button(className, label, onClick) {
|
|
const node = el('button', className, label);
|
|
node.type = 'button';
|
|
node.onclick = onClick;
|
|
return node;
|
|
}
|
|
|
|
function checkbox(label, checked, hint = '') {
|
|
const line = el('label', 'anomalous-backup-choice');
|
|
const box = el('input');
|
|
box.type = 'checkbox';
|
|
box.checked = checked;
|
|
const copy = el('span', 'anomalous-backup-choice-copy');
|
|
copy.append(el('span', '', label));
|
|
if (hint) copy.append(el('small', 'anomalous-backup-note', hint));
|
|
line.append(box, copy);
|
|
return { line, box };
|
|
}
|
|
|
|
/** A dialog over the page; Esc or a click beside it closes it (then `onClose`). Returns { dialog, footer, close }. */
|
|
export function openDialog(title, onClose = null) {
|
|
const overlay = el('div', 'anomalous-dialog-overlay anomalous-backup-overlay');
|
|
const dialog = el('div', 'anomalous-backup-dialog');
|
|
dialog.setAttribute('role', 'dialog');
|
|
const body = el('div', 'anomalous-backup-body');
|
|
const footer = el('div', 'anomalous-backup-footer');
|
|
dialog.append(el('h3', 'anomalous-backup-title', title), body, footer);
|
|
overlay.append(dialog);
|
|
const onKey = (event) => {
|
|
if (event.key !== 'Escape') return;
|
|
event.stopPropagation();
|
|
close();
|
|
};
|
|
const close = () => {
|
|
overlay.remove();
|
|
document.removeEventListener('keydown', onKey, true);
|
|
onClose?.();
|
|
};
|
|
overlay.onclick = (event) => { if (event.target === overlay) close(); };
|
|
document.addEventListener('keydown', onKey, true);
|
|
document.body.append(overlay);
|
|
return { dialog: body, footer, close };
|
|
}
|
|
|
|
async function postJson(url, body) {
|
|
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok || data.status === 'error') throw new Error(data.error || `HTTP ${response.status}`);
|
|
return data;
|
|
}
|
|
|
|
function download(name) {
|
|
const link = el('a');
|
|
link.href = `/anomalous/backup/file?name=${encodeURIComponent(name)}`;
|
|
link.download = name;
|
|
document.body.append(link);
|
|
link.click();
|
|
link.remove();
|
|
}
|
|
|
|
/** Export: what to include, then the .zip is packed and downloaded. */
|
|
export function exportBackup() {
|
|
const { dialog, footer, close } = openDialog(t('backupExportTitle'));
|
|
const models = checkbox(t('backupExportModels'), true, t('backupExportModelsHint'));
|
|
const scan = checkbox(t('backupExportScan'), true, t('backupExportScanHint'));
|
|
const covers = checkbox(t('backupExportCivitaiCovers'), false, t('backupExportCivitaiCoversHint'));
|
|
const comfy = checkbox(t('backupPart_comfy_workflows'), true, t('backupExportComfyHint'));
|
|
const library = checkbox(t('backupExportLibrary'), true, t('backupExportLibraryHint'));
|
|
const status = el('p', 'anomalous-backup-note');
|
|
dialog.append(el('p', 'anomalous-backup-text', t('backupExportIntro')), models.line, scan.line, covers.line, library.line, comfy.line,
|
|
el('p', 'anomalous-backup-note', t('backupExportKey')), status);
|
|
const go = button('anomalous-scan-primary', t('backupExportStart'), async () => {
|
|
go.disabled = true;
|
|
status.classList.remove('is-bad');
|
|
status.textContent = t('backupExporting');
|
|
try {
|
|
const data = await postJson('/anomalous/backup/export', {
|
|
models: models.box.checked, scan: scan.box.checked, civitai_covers: covers.box.checked,
|
|
library: library.box.checked, comfy_workflows: comfy.box.checked,
|
|
});
|
|
download(data.name);
|
|
status.textContent = t('backupExported', { name: data.name, size: formatSize(data.size) });
|
|
go.remove();
|
|
cancel.textContent = t('dialogOk');
|
|
} catch (error) {
|
|
status.textContent = t('backupFailed', { error: error.message });
|
|
status.classList.add('is-bad');
|
|
go.disabled = false;
|
|
}
|
|
});
|
|
const cancel = button('anomalous-scan-secondary', t('dialogCancel'), close);
|
|
footer.append(cancel, go);
|
|
go.focus();
|
|
}
|
|
|
|
/** Import: pick the .zip, see what is in it, choose, put it back. */
|
|
export function importBackup(owner) {
|
|
const input = el('input');
|
|
input.type = 'file';
|
|
input.accept = '.zip,application/zip';
|
|
input.onchange = () => {
|
|
const file = input.files?.[0];
|
|
if (file) void importBackupFile(owner, file);
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
/** Reads a backup .zip and offers to put it back (also from the share dialog, ui_share.js). */
|
|
export async function importBackupFile(owner, file) {
|
|
const { dialog, footer, close } = openDialog(t('backupImportTitle'));
|
|
const status = el('p', 'anomalous-backup-note', t('backupReading', { name: file.name }));
|
|
dialog.append(status);
|
|
footer.append(button('anomalous-scan-secondary', t('dialogCancel'), close));
|
|
let summary;
|
|
try {
|
|
const form = new FormData();
|
|
form.append('file', file, file.name);
|
|
const response = await fetch('/anomalous/backup/inspect', { method: 'POST', body: form });
|
|
summary = await response.json().catch(() => ({}));
|
|
if (!response.ok || summary.status === 'error') throw new Error(summary.error || `HTTP ${response.status}`);
|
|
} catch (error) {
|
|
status.textContent = t('backupFailed', { error: error.message });
|
|
status.classList.add('is-bad');
|
|
return;
|
|
}
|
|
showChoices(owner, file.name, summary, { dialog, footer, close });
|
|
}
|
|
|
|
/** The models the backup's computer had and this one lacks, with the way to download them again. */
|
|
function missingModels(owner, summary) {
|
|
const library = summary.library;
|
|
if (!library) return null;
|
|
const line = el('div', 'anomalous-backup-missing');
|
|
if (!library.missing.length) {
|
|
line.append(el('span', 'anomalous-backup-note', t('backupLibraryAllHere', { total: library.total })));
|
|
return line;
|
|
}
|
|
const size = library.missing.reduce((sum, entry) => sum + (Number(entry.size) || 0), 0);
|
|
line.append(el('span', 'anomalous-backup-text', t('backupLibraryMissing', { count: library.missing.length, size: formatSize(size) })),
|
|
button('anomalous-scan-secondary anomalous-scan-small-btn', t('backupLibraryDownload'), () => openRestoreDownloads(owner, library.missing)));
|
|
return line;
|
|
}
|
|
|
|
function showChoices(owner, name, summary, { dialog, footer, close }) {
|
|
const created = summary.created ? new Date(summary.created * 1000).toLocaleString() : '?';
|
|
dialog.replaceChildren(el('p', 'anomalous-backup-text', t('backupFrom', { name, date: created, version: summary.version || '?' })));
|
|
const parts = PARTS.filter(part => summary.parts?.[part]).map((part) => {
|
|
const counts = summary.parts[part];
|
|
const choice = checkbox(t(`backupPart_${part}`), counts.new + counts.differs > 0, t('backupCounts', counts));
|
|
dialog.append(choice.line);
|
|
return { part, box: choice.box };
|
|
});
|
|
let models = null;
|
|
if (summary.models?.total) {
|
|
const { matched, total, missing } = summary.models;
|
|
const hint = missing.length
|
|
? t('backupModelsMissing', { names: missing.slice(0, 5).map(rel => rel.split('/').pop()).join(t('listSeparator')), count: missing.length })
|
|
: '';
|
|
models = checkbox(t('backupModels', { matched, total }), matched > 0, hint);
|
|
dialog.append(models.line);
|
|
}
|
|
const settings = summary.settings ? checkbox(t('backupSettings'), false, t('backupSettingsHint')) : null;
|
|
if (settings) dialog.append(settings.line);
|
|
const missing = missingModels(owner, summary);
|
|
if (missing) dialog.append(missing);
|
|
if (!parts.length && !models && !settings) dialog.append(el('p', 'anomalous-backup-note', t('backupNothing')));
|
|
|
|
const mode = el('div', 'anomalous-backup-mode');
|
|
const radio = (value, label, checked) => {
|
|
const line = el('label', 'anomalous-backup-choice');
|
|
const input = el('input');
|
|
input.type = 'radio';
|
|
input.name = 'anomalous-backup-mode';
|
|
input.value = value;
|
|
input.checked = checked;
|
|
line.append(input, el('span', '', label));
|
|
mode.append(line);
|
|
return input;
|
|
};
|
|
radio('keep', t('backupModeKeep'), true);
|
|
const replace = radio('replace', t('backupModeReplace'), false);
|
|
const status = el('p', 'anomalous-backup-note');
|
|
dialog.append(el('h4', 'anomalous-backup-subtitle', t('backupModeTitle')), mode, status);
|
|
|
|
footer.replaceChildren();
|
|
const go = button('anomalous-scan-primary', t('backupImportStart'), async () => {
|
|
go.disabled = true;
|
|
status.classList.remove('is-bad');
|
|
status.textContent = t('backupImporting');
|
|
try {
|
|
const result = await postJson('/anomalous/backup/apply', {
|
|
token: summary.token, name,
|
|
parts: parts.filter(item => item.box.checked).map(item => item.part),
|
|
models: Boolean(models?.box.checked), settings: Boolean(settings?.box.checked), replace: replace.checked,
|
|
});
|
|
showResult(dialog, result);
|
|
const again = missingModels(owner, summary);
|
|
if (again && summary.library.missing.length) dialog.append(again);
|
|
go.remove();
|
|
cancel.textContent = t('dialogOk');
|
|
if (result.model_notes || result.model_covers || result.model_infos || result.model_civitai_covers || result.settings) owner?.loadModels?.();
|
|
} catch (error) {
|
|
status.textContent = t('backupFailed', { error: error.message });
|
|
status.classList.add('is-bad');
|
|
go.disabled = false;
|
|
}
|
|
});
|
|
const cancel = button('anomalous-scan-secondary', t('dialogCancel'), close);
|
|
footer.append(cancel, go);
|
|
}
|
|
|
|
function showResult(dialog, result) {
|
|
const lines = [t('backupDone', result)];
|
|
if (result.model_notes || result.model_covers || result.covers_kept || result.models_missing) lines.push(t('backupDoneModels', result));
|
|
if (result.model_infos || result.model_civitai_covers) lines.push(t('backupDoneScan', result));
|
|
if (result.settings) lines.push(t('backupDoneSettings'));
|
|
dialog.replaceChildren(...lines.map(line => el('p', 'anomalous-backup-text', line)));
|
|
if (result.failed?.length) {
|
|
dialog.append(el('p', 'anomalous-backup-note is-bad', t('backupFailedItems')));
|
|
const list = el('ul', 'anomalous-backup-failed');
|
|
list.append(...result.failed.slice(0, 20).map(item => el('li', '', item)));
|
|
dialog.append(list);
|
|
}
|
|
dialog.append(el('p', 'anomalous-backup-note', t('backupDoneHint')));
|
|
}
|