Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_scan_page.js
T
DemonGatanjieuandClaude Opus 5.5 698a256a16 feat(model-check): download missing models, to the folder you choose
Model Check offers Download (and Download all) for a missing model whose
source names one exact file: Civitai by the workflow's fingerprint, or a
Civitai version / Hugging Face / GitHub file link the workflow carries.
A dialog shows each file's size, source and full destination, with the
models folder (when there are several) and subfolder changeable; Settings
> Model downloads sets the default: where the workflow says (the node
needs no change) or a set folder, {base} sorting by base model and
matching an existing folder in any spelling.

Downloads run one at a time into <file>.part, continue after an
interruption, are checked against the SHA-256 before they get their name,
never replace a file, and send the Civitai key to Civitai only. Finished
files are put into their nodes (one Ctrl+Z step each), scanned for info
and covers, and recorded in the activity log.

A change of fingerprints alone (after any scan) no longer takes a Ctrl+Z
step that seemed to do nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:28:51 +08:00

332 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The scan page: how many models are matched on Civitai, unmatched or not scanned
* (GET /anomalous/scan_summary), one button to scan, how the next scan goes, progress in
* the page and the last scan in one card. Each count and the last scan open their list
* as a page of its own (ui_scan_lists.js), the settings card the settings page; each
* has a way back.
* Scans run through scan_runner.js; the progress box is scan_progress.js's panel,
* hosted here while the page is shown.
*/
import { translate as t } from './locales.js';
import { isScanRunning, startScan, targetsForItems } from './scan_runner.js';
import { setScanProgressHost } from './scan_progress.js';
import { openListedModel, renderLastScan, renderListPage } from './ui_scan_lists.js';
// Choices kept while the browser is open. The two risky ones go back off after each scan.
const options = {
scope: 'all', // 'all' | 'picked'
selection: new Map(),
offline: false,
virtualRename: true,
physicalRename: false,
forceOverwrite: false,
autoFix: true,
};
// The view shown: '' the page itself, 'settings', or a list: 'new' | 'unmatched' | 'skipped' | 'result'.
// `scroll`: where to put a list shown again after a model's Back.
const pageState = { view: '', scroll: 0 };
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 pickedCount() {
let count = 0;
for (const files of options.selection.values()) count += files.size;
return count;
}
async function fetchJson(url) {
try {
const res = await fetch(url);
return res.ok ? await res.json() : null;
} catch {
return null;
}
}
/** A count; with `onClick` it opens the list of those models. */
function stat(value, label, tone, onClick) {
const box = el(onClick ? 'button' : 'div', `anomalous-scan-stat${tone ? ` is-${tone}` : ''}`);
if (onClick) {
box.type = 'button';
box.onclick = onClick;
box.title = t('scanPageShowList');
}
box.append(el('strong', 'anomalous-scan-stat-value', value ?? '–'), el('span', 'anomalous-scan-stat-label', label));
return box;
}
/** One setting: what it is and does on the left, its switch on the right; `tag` warns beside the title. */
function toggle(key, titleKey, helpKey, { tag = '', onChange = null } = {}) {
const row = el('label', 'anomalous-scan-setting');
const copy = el('span', 'anomalous-scan-setting-copy');
const title = el('span', 'anomalous-scan-setting-title', t(titleKey));
if (tag) title.append(el('span', 'anomalous-scan-tag', t(tag)));
copy.append(title, el('small', 'anomalous-scan-setting-help', t(helpKey)));
const input = el('input', 'anomalous-scan-switch');
input.type = 'checkbox';
input.checked = Boolean(options[key]);
input.onchange = () => {
options[key] = input.checked;
onChange?.();
};
row.append(copy, input);
return row;
}
/** A titled card of the settings page. */
function group(title, ...children) {
const box = el('section', 'anomalous-scan-card anomalous-scan-group');
box.append(el('h3', 'anomalous-scan-group-title', title), ...children);
return box;
}
/** How the next scan will go, in short words; `true` marks the ones that change files or take long. */
function currentChoices(hasKey) {
const online = !options.offline;
return [
[options.scope === 'picked' ? t('scanChipPicked', { count: pickedCount() }) : t('scanPageScopeAll')],
[t(online ? 'scanChipOnline' : 'scanPageOffline')],
online && options.virtualRename && [t('scanChipDisplayName')],
online && options.physicalRename && [t('scanChipFileRename'), true],
online && options.forceOverwrite && [t('scanChipForce'), true],
options.autoFix && [t('scanChipAutoFix')],
[t(hasKey ? 'scanChipKeyOn' : 'scanChipKeyOff')],
].filter(Boolean);
}
/** The scan page's card for the settings: how the next scan goes, and a way to change it. */
function settingsCard(owner, panel, hasKey) {
const card = button('anomalous-scan-card anomalous-scan-settings-card', undefined, () => go(owner, panel, 'settings'));
const head = el('span', 'anomalous-scan-settings-head');
head.append(el('span', 'anomalous-scan-card-title', t('scanSettingsTitle')), el('span', 'anomalous-scan-settings-change', t('scanSettingsChange')));
const chips = el('span', 'anomalous-scan-chips');
for (const [text, warn] of currentChoices(hasKey)) chips.append(el('span', `anomalous-scan-chip${warn ? ' is-warn' : ''}`, text));
card.append(head, chips);
return card;
}
/** The settings as a page of their own, like the lists: back, then one card per question. */
function settingsPage(owner, panel, hasKey, progressHost) {
const refresh = () => go(owner, panel, 'settings');
const page = el('div', 'anomalous-scan-page');
page.append(button('anomalous-scan-back', t('scanBack'), () => go(owner, panel, '')),
el('h1', 'anomalous-scan-title', t('scanSettingsTitle')), el('p', 'anomalous-scan-lead', t('scanSettingsLead')), progressHost);
// Which models: two choices side by side; "picked" shows the picker.
const segment = el('div', 'anomalous-scan-segment');
segment.setAttribute('role', 'radiogroup');
for (const [value, label] of [['all', t('scanPageScopeAll')], ['picked', t('scanPageScopePicked')]]) {
const choice = button('anomalous-scan-segment-btn', label, () => {
options.scope = value;
refresh();
});
choice.setAttribute('role', 'radio');
choice.setAttribute('aria-checked', String(options.scope === value));
segment.append(choice);
}
let scopeNote = el('small', 'anomalous-scan-setting-help', t('scanPageScopeAllHelp'));
if (options.scope === 'picked') {
scopeNote = el('div', 'anomalous-scan-pick');
scopeNote.append(
button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPagePickModels'), () => owner._openAdvancedModelSelector(options.selection, (next) => {
options.selection = next;
refresh();
})),
el('span', 'anomalous-scan-muted', t('scanPagePickedCount', { count: pickedCount() })),
);
}
// What only an online scan does: under its own heading, faded while scanning offline.
const online = el('div', `anomalous-scan-online${options.offline ? ' is-disabled' : ''}`);
online.append(
el('div', 'anomalous-scan-subtitle', t(options.offline ? 'scanPageOnlineOff' : 'scanPageOnline')),
toggle('virtualRename', 'scanPageDisplayName', 'scanPageDisplayNameHelp'),
toggle('physicalRename', 'scanPageFileRename', 'scanPageFileRenameHelp', { tag: 'scanPageTagFiles' }),
toggle('forceOverwrite', 'scanPageForce', 'scanPageForceHelp', { tag: 'scanPageTagSlow' }),
);
const key = el('div', 'anomalous-scan-setting');
const keyCopy = el('span', 'anomalous-scan-setting-copy');
const keyTitle = el('span', 'anomalous-scan-setting-title', t('scanPageApiKey'));
keyTitle.append(el('span', `anomalous-scan-tag ${hasKey ? 'is-on' : 'is-off'}`, t(hasKey ? 'scanPageApiKeyOn' : 'scanPageApiKeyOff')));
keyCopy.append(keyTitle, el('small', 'anomalous-scan-setting-help', t('scanPageApiKeyHelp')));
key.append(keyCopy, button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPageApiKeySet'), async () => {
if (await saveApiKey()) renderScanPage(owner, panel);
}));
page.append(
group(t('scanPageScope'), segment, scopeNote),
group(t('scanPageHow'), toggle('offline', 'scanPageOffline', 'scanPageOfflineHelp', { onChange: refresh }), online),
group(t('scanPageAfter'), toggle('autoFix', 'scanPageAutoFix', 'scanPageAutoFixHelp')),
group(t('scanPageAccount'), key),
);
return page;
}
/** Asks for the Civitai key and saves it; true when saved (also Model Check's downloads). */
export async function saveApiKey() {
const value = prompt(t('scanPageApiKeyPrompt'), '');
if (value === null) return false;
try {
const res = await fetch('/anomalous/save_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: value.trim() }),
});
const data = await res.json();
if (data.status !== 'ok') throw new Error(data.message || `HTTP ${res.status}`);
alert(t(value.trim() ? 'scanPageApiKeySaved' : 'scanPageApiKeyCleared'));
return true;
} catch (error) {
alert(t('scanPageApiKeyFailed', { error: String(error.message || error) }));
return false;
}
}
/** A scan from this page. `extra`: { retryUnmatched } for every unmatched model, or
* { targets, retryUnmatched } for the models of a list row or a whole list. */
async function run(owner, panel, extra = {}) {
const whole = !extra.retryUnmatched && !extra.targets;
if (whole && options.scope === 'picked' && pickedCount() === 0) {
alert(t('scanPageNothingPicked'));
return;
}
if (whole && options.forceOverwrite && !confirm(t('scanPageForceConfirm'))) return;
const selection = whole && options.scope === 'picked' ? options.selection : null;
const started = startScan(owner, { ...options, ...extra, selection });
renderScanPage(owner, panel); // buttons show "scanning"
await started;
if (whole) {
options.physicalRename = false;
options.forceOverwrite = false;
}
if (panel.isConnected && panel.style.display !== 'none') renderScanPage(owner, panel);
}
/** To another view of the page ('' is the scan page itself), with what was read last. */
function go(owner, panel, view) {
pageState.view = view;
if (panel._scanData) paint(owner, panel, panel._scanData);
else renderScanPage(owner, panel);
}
/** What the list pages' buttons do. */
function listActions(owner, panel, busy) {
return {
busy,
offline: options.offline,
back: () => go(owner, panel, ''),
// The model's Back returns to this list where it was.
open: (item) => {
const view = pageState.view;
const scroll = panel.scrollTop;
openListedModel(owner, item, () => openScanPage(owner, view, scroll));
},
scan: (items, { retry = false } = {}) => run(owner, panel, { targets: targetsForItems(items), retryUnmatched: retry }),
retryAll: () => run(owner, panel, { retryUnmatched: true }),
};
}
/** The scan page itself: what a scan is, the counts (each opening its list), one button, the last scan. */
function mainPage(owner, panel, { summary, config, last }, busy, progressHost) {
const page = el('div', 'anomalous-scan-page');
page.append(el('h1', 'anomalous-scan-title', t('scanPageTitle')), el('p', 'anomalous-scan-lead', t('scanPageLead')));
const open = view => () => go(owner, panel, view);
const stats = el('div', 'anomalous-scan-stats');
stats.append(
stat(summary?.total, t('scanPageTotal')),
stat(summary?.matched, t('scanPageMatched'), 'ok'),
stat(summary?.unmatched, t('scanPageUnmatched'), summary?.unmatched ? 'warn' : '', summary?.unmatched ? open('unmatched') : null),
stat(summary?.new, t('scanPageNew'), summary?.new ? 'new' : '', summary?.new ? open('new') : null),
);
page.append(stats);
if (summary?.skipped) page.append(button('anomalous-scan-skipped-note', t('scanPageSkippedNote', { count: summary.skipped }), open('skipped')));
if (!summary) page.append(el('p', 'anomalous-scan-muted', t('scanPageSummaryFailed')));
const actions = el('div', 'anomalous-scan-actions');
const picked = options.scope === 'picked';
// An online scan also looks up the models earlier scans could not ask Civitai about.
const waiting = (summary?.new || 0) + (options.offline ? 0 : summary?.pending || 0);
const label = busy ? t('scanPageScanning')
: picked ? t('scanPageScanPicked', { count: pickedCount() })
: waiting ? t(options.offline ? 'scanPageScanNewOffline' : 'scanPageScanNew', { count: waiting })
: t('scanPageScanAgain');
const primary = button('anomalous-scan-primary', label, () => run(owner, panel));
primary.disabled = busy;
actions.append(primary);
page.append(actions);
const hint = picked ? t('scanPageHintPicked') : t(options.offline ? 'scanPageHintOffline' : 'scanPageHint');
page.append(el('p', 'anomalous-scan-muted', hint), settingsCard(owner, panel, Boolean(config?.has_api_key)), progressHost);
const result = renderLastScan(last, open('result'));
if (result) page.append(result);
return page;
}
/** Puts the current view into `panel`; a view shown again keeps where it was scrolled. */
function paint(owner, panel, data) {
const view = pageState.view;
const top = pageState.scroll || (panel._scanView === view ? panel.scrollTop : 0);
pageState.scroll = 0;
const busy = isScanRunning();
const progressHost = el('div', 'anomalous-scan-progress-host');
const page = view === 'settings' ? settingsPage(owner, panel, Boolean(data.config?.has_api_key), progressHost)
: view ? renderListPage(view, data.summary, data.last, listActions(owner, panel, busy), progressHost)
: mainPage(owner, panel, data, busy, progressHost);
panel.replaceChildren(page);
panel._scanView = view;
panel.scrollTop = top;
setScanProgressHost(progressHost);
}
/** Renders the page into `panel` (again after each scan, so the counts are fresh). */
export async function renderScanPage(owner, panel) {
const token = (panel._scanRender = (panel._scanRender || 0) + 1);
const [summary, config, last] = await Promise.all([
fetchJson('/anomalous/scan_summary'), fetchJson('/anomalous/config'), fetchJson('/anomalous/last_scan'),
]);
if (token !== panel._scanRender) return;
// Closed or left meanwhile: the progress box keeps floating instead of hiding in the page.
if (panel.style.display === 'none' || !owner.modal?.classList.contains('visible')) return;
panel._scanData = { summary, config, last };
paint(owner, panel, panel._scanData);
// A scan that ends elsewhere (a card's radar, another tab) refreshes the counts too.
owner.onScanFinished = () => {
if (panel.isConnected && panel.style.display !== 'none') renderScanPage(owner, panel);
};
}
/**
* The rail's scan button, Home's "Scan model folders" and Model Check: the scan page itself.
* `view` opens one of its lists instead ('result' after a scan, a list on a model's Back),
* scrolled to `scroll`.
*/
export function openScanPage(owner, view = '', scroll = 0) {
pageState.view = view;
pageState.scroll = scroll;
owner.scanPanel._scanView = null; // opened afresh: from the top unless `scroll`
owner.enterToolPage?.('scan');
owner.hideAllPanels();
owner.scanPanel.style.display = 'flex';
renderScanPage(owner, owner.scanPanel);
}
/** Leaving the page: the progress box floats again. */
export function leaveScanPage() {
setScanProgressHost(null);
}