A model Civitai has no record of now has its safetensors header checked (model_identity. safetensors_damage: too short, header longer than the file, not JSON, or a tensor ending past the file's end; only the header is read). Such files get the unmatched reason "damaged": a red badge on the card, a line in the details, a warning on the scan page (the summary counts them) and first place in the unmatched list, with advice to download the model again. Tests: header cases, a scan telling a damaged file from a whole one; the scan report fixtures are whole safetensors files now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
15 KiB
JavaScript
333 lines
15 KiB
JavaScript
/**
|
||
* 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?.damaged) page.append(button('anomalous-scan-damaged-note', t('scanPageDamagedNote', { count: summary.damaged }), open('unmatched')));
|
||
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);
|
||
}
|