Files
DemonGatanjieuandClaude Opus 5.5 d778aec946 Fix what a walk through every page found before 1.63
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>
2026-11-06 03:18:07 +08:00

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')));
}