The scan page keeps only the counts, the scan button, the last scan in one card and Advanced. Not found, not scanned yet, other formats and the last scan's models each open a page with Back, why those models are there, one button for the whole list and the rows; a model opened from a list returns to it where it was scrolled. "Show the result" after a scan opens the last scan's page. Also fixes "formerly Model Check" in the changelog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
9.8 KiB
JavaScript
214 lines
9.8 KiB
JavaScript
/**
|
|
* The scan page's lists, each a page of its own behind a count on the scan page: the models
|
|
* not scanned yet, the unmatched ones, the model files a scan never reads (other formats), and
|
|
* each model the last scan changed (GET /anomalous/scan_summary, /anomalous/last_scan).
|
|
* On the scan page itself the last scan is one summary card with a way to its list.
|
|
* Every row opens its model; rows offer "scan" / "look up again" and a Civitai search.
|
|
* The page passes what the buttons do (`actions`); this module only renders.
|
|
*/
|
|
|
|
import { translate as t } from './locales.js';
|
|
import { dayLabel, timeLabel } from './activity_log.js';
|
|
import { countsLine, fileOutcome, isPending, reasonText, scanScope, STATUS_MARKS } from './scan_results.js';
|
|
import { typeLabel } from './ui_model_types.js';
|
|
|
|
const SHOWN_ROWS = 100; // more behind "show all"
|
|
|
|
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;
|
|
}
|
|
|
|
/** Opens a listed model's detail; its back button returns to `onBack` (the list it came from). */
|
|
export async function openListedModel(owner, item, onBack) {
|
|
const params = new URLSearchParams({ type: item.type, path_idx: item.path_idx, rel: item.rel });
|
|
const data = await fetch(`/anomalous/scan_model?${params}`).then(res => res.json()).catch(() => ({}));
|
|
if (!data.model) {
|
|
alert(t('scanModelGone', { name: item.filename }));
|
|
return;
|
|
}
|
|
owner.hideAllPanels();
|
|
owner.historyStack = [];
|
|
owner.currentType = data.model.type;
|
|
owner.currentPathIdx = data.model.path_idx;
|
|
owner.currentSubfolder = data.model.subfolder || '/';
|
|
owner.currentDetailModel = data.model;
|
|
owner.recipeModelReturn = onBack; // set after hideAllPanels, which clears it
|
|
owner.showDetail(data.model);
|
|
}
|
|
|
|
/** Civitai's model search for a file name, to find a model by hand. */
|
|
function searchCivitai(item) {
|
|
const query = item.filename.replace(/\.safetensors$/i, '').replace(/[_-]+/g, ' ');
|
|
window.open(`https://civitai.com/search/models?query=${encodeURIComponent(query)}`, '_blank', 'noopener');
|
|
}
|
|
|
|
function where(item) {
|
|
const folder = item.rel.includes('/') ? item.rel.slice(0, item.rel.lastIndexOf('/')) : '';
|
|
return [item.type ? typeLabel({ type: item.type }) : '', folder].filter(Boolean).join(' · ');
|
|
}
|
|
|
|
/** One model: mark, file name, where it is, what about it, and its buttons. */
|
|
function row(item, detail, buttons, mark = '') {
|
|
const line = el('div', `anomalous-scan-row${item.status ? ` is-${item.status}` : ''}`);
|
|
if (mark) line.append(el('span', 'anomalous-scan-row-mark', mark));
|
|
const copy = el('div', 'anomalous-scan-row-copy');
|
|
const name = el('strong', 'anomalous-scan-row-name', item.filename);
|
|
name.title = item.rel;
|
|
copy.append(name, el('small', 'anomalous-scan-row-where', where(item)));
|
|
if (detail) copy.append(el('small', 'anomalous-scan-row-detail', detail));
|
|
const actions = el('div', 'anomalous-scan-row-actions');
|
|
for (const [label, onClick, disabled] of buttons) {
|
|
const node = button('anomalous-scan-row-btn', label, onClick);
|
|
node.disabled = Boolean(disabled);
|
|
actions.append(node);
|
|
}
|
|
line.append(copy, actions);
|
|
return line;
|
|
}
|
|
|
|
/** Buttons for an unmatched model. */
|
|
function unmatchedButtons(item, actions) {
|
|
return [
|
|
[t('scanRowOpen'), () => actions.open(item)],
|
|
...(actions.offline ? [] : [[t('scanRowRetry'), () => actions.scan([item], { retry: true }), actions.busy]]),
|
|
[t('scanRowSearch'), () => searchCivitai(item)],
|
|
];
|
|
}
|
|
|
|
/** Rows into `list`, the first `limit`, then a button for the rest. */
|
|
function fillRows(list, items, render, limit) {
|
|
const shown = items.slice(0, limit);
|
|
list.replaceChildren(...shown.map(render));
|
|
if (items.length > shown.length) {
|
|
list.append(button('anomalous-scan-more', t('scanShowAll', { count: items.length }), () => fillRows(list, items, render, Infinity)));
|
|
}
|
|
}
|
|
|
|
/** "Last scan · when · what · how long". */
|
|
function resultTitle(result) {
|
|
const seconds = Math.max(0, Math.round((result.finished || 0) - (result.started || 0)));
|
|
const target = result.kind === 'one' ? result.files?.[0]?.filename : '';
|
|
return t('scanResultTitle', {
|
|
when: `${dayLabel(result.finished)} ${timeLabel(result.finished)}`,
|
|
scope: scanScope(result, target),
|
|
duration: seconds < 60 ? t('scanSeconds', { count: seconds }) : t('scanMinutes', { count: Math.round(seconds / 60) }),
|
|
});
|
|
}
|
|
|
|
/** What the reader must know about a scan: offline, Civitai gone mid-way, errors. */
|
|
function resultNotes(result) {
|
|
const notes = [];
|
|
if (result.options?.offline_only) notes.push(el('p', 'anomalous-scan-muted', t('scanResultOffline')));
|
|
if (result.civitai_down) notes.push(el('p', 'anomalous-scan-note is-warn', t('scanResultCivitaiDown')));
|
|
for (const error of result.errors || []) notes.push(el('p', 'anomalous-scan-note is-error', error));
|
|
return notes;
|
|
}
|
|
|
|
/** The last scan on the scan page: when, what, the counts, and a way to each model. Null before the first scan. */
|
|
export function renderLastScan(result, onDetails) {
|
|
if (!result?.counts) return null;
|
|
const box = el('section', 'anomalous-scan-card anomalous-scan-result');
|
|
box.append(el('div', 'anomalous-scan-card-title', resultTitle(result)),
|
|
el('div', 'anomalous-scan-result-counts', countsLine(result.counts)), ...resultNotes(result));
|
|
const files = result.files || [];
|
|
if (files.length) box.append(button('anomalous-scan-more', t('scanResultDetails', { count: files.length }), onDetails));
|
|
else box.append(el('p', 'anomalous-scan-muted', t('scanResultNothing', { count: result.counts.unchanged || 0 })));
|
|
return box;
|
|
}
|
|
|
|
/** A list's models, title, why they are here and the row for one of them. */
|
|
function listOf(view, summary, last, actions) {
|
|
if (view === 'new') {
|
|
const items = summary?.new_models || [];
|
|
return {
|
|
items,
|
|
title: t('scanListNew', { count: items.length }),
|
|
hint: t('scanListNewHint'),
|
|
all: items.length ? [t('scanListScanAll', { count: items.length }), () => actions.scan(items)] : null,
|
|
render: item => row(item, '', [
|
|
[t('scanRowOpen'), () => actions.open(item)],
|
|
[t('scanRowScan'), () => actions.scan([item]), actions.busy],
|
|
]),
|
|
};
|
|
}
|
|
if (view === 'skipped') {
|
|
const items = summary?.skipped_models || [];
|
|
return {
|
|
items,
|
|
title: t('scanListSkipped', { count: items.length }),
|
|
hint: t('scanListSkippedHint'),
|
|
render: item => row(item, '', [[t('scanRowOpen'), () => actions.open(item)]]),
|
|
};
|
|
}
|
|
if (view === 'result') {
|
|
const items = last?.files || [];
|
|
return {
|
|
items,
|
|
title: t('scanResultList'),
|
|
render: item => row(item, fileOutcome(item),
|
|
item.status === 'inferred' ? unmatchedButtons(item, actions) : [[t('scanRowOpen'), () => actions.open(item)]],
|
|
STATUS_MARKS[item.status] || ''),
|
|
};
|
|
}
|
|
// Unmatched: those the next online scan looks up by itself first.
|
|
const items = [...(summary?.unmatched_models || [])]
|
|
.sort((a, b) => Number(isPending(b.reason)) - Number(isPending(a.reason)));
|
|
// Civitai does not know it and the file is no known image model: say what it may be.
|
|
const guess = item => (item.base ? `≈ ${item.base}` : item.reason === 'not_found' ? t('scanReasonNotImage') : '');
|
|
return {
|
|
items,
|
|
title: t('scanListUnmatched', { count: items.length }),
|
|
hint: t(actions.offline ? 'scanListUnmatchedHintOffline' : 'scanListUnmatchedHint'),
|
|
all: items.length && !actions.offline ? [t('scanPageRetry', { count: items.length }), actions.retryAll] : null,
|
|
render: item => row(item, [reasonText(item.reason), guess(item)].filter(Boolean).join(' · '),
|
|
unmatchedButtons(item, actions), '≈'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One list as a page ('new' | 'unmatched' | 'skipped' | 'result'): back to the scan page, what
|
|
* these models are and why they are here, a button for all of them, then the rows. A scan
|
|
* started here shows its progress in `progressHost`, above the rows.
|
|
*/
|
|
export function renderListPage(view, summary, last, actions, progressHost) {
|
|
const list = listOf(view, summary, last, actions);
|
|
const page = el('div', 'anomalous-scan-page');
|
|
page.append(button('anomalous-scan-back', t('scanBack'), actions.back), el('h1', 'anomalous-scan-title', list.title));
|
|
if (view === 'result' && last?.counts) {
|
|
page.append(el('div', 'anomalous-scan-card-title', resultTitle(last)),
|
|
el('div', 'anomalous-scan-result-counts', countsLine(last.counts)), ...resultNotes(last));
|
|
}
|
|
if (list.hint) page.append(el('p', 'anomalous-scan-lead', list.hint));
|
|
if (list.all) {
|
|
const all = button('anomalous-scan-secondary', actions.busy ? t('scanPageScanning') : list.all[0], list.all[1]);
|
|
all.disabled = actions.busy;
|
|
const bar = el('div', 'anomalous-scan-actions');
|
|
bar.append(all);
|
|
page.append(bar);
|
|
}
|
|
page.append(progressHost);
|
|
if (!list.items.length) {
|
|
page.append(el('p', 'anomalous-scan-muted', t('scanListEmpty')));
|
|
return page;
|
|
}
|
|
const card = el('section', 'anomalous-scan-card');
|
|
const rows = el('div', 'anomalous-scan-rows');
|
|
fillRows(rows, list.items, list.render, SHOWN_ROWS);
|
|
card.append(rows);
|
|
page.append(card);
|
|
if (view === 'result' && last?.counts?.unchanged) {
|
|
page.append(el('p', 'anomalous-scan-muted', t('scanResultUnchanged', { count: last.counts.unchanged })));
|
|
}
|
|
return page;
|
|
}
|