Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_scan_picker.js
T
DemonGatanjieuandClaude Opus 5.5 899a1d9ac1 Model picker: folders in order, aligned columns, offline wording
The picker's models folders are sorted by type, each named with where it
is when the folder is not named after its type (Diffusion · unet) or the
type has two; models follow their folder. A row's state, base model and
size sit in fixed columns. Offline, the settings page no longer counts
models last scanned without a connection as "not on Civitai": they wait
for the next online scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:00:44 +08:00

361 lines
16 KiB
JavaScript

/**
* The scan settings' model picker (GET /anomalous/scan_pickable): every model a scan reads, as
* a list or as covers, each with how the last scan left it (the scan page's words: not scanned
* yet, not found, damaged, found, set to "don't scan"), its folder, base model and size. The
* folders on the left, the state chips, the search and "picked only" narrow what is shown;
* "pick these" picks all of it, a click one model, a shift-click the run since the last click.
* OK hands the picks to the settings page as items { type, path_idx, rel, filename, name, size };
* Cancel, ✕, Esc and the backdrop leave them as they were. Long lists are drawn a screenful at
* a time.
*/
import { translate as t } from './locales.js';
import { createViewScope } from './ui_lifecycle.js';
import { typeLabel } from './ui_model_types.js';
import { formatSize } from './ui_model_download.js';
const STATES = ['new', 'unmatched', 'damaged', 'matched', 'skip'];
const STATE_KEYS = {
new: 'scanPageNew', unmatched: 'scanPageUnmatched', damaged: 'modelSourceDamagedShort',
matched: 'scanPickFound', skip: 'modelSourceSkipped',
};
const STEP = 60; // rows drawn at a time
const VIEW_KEY = 'anomalous_scan_picker_view';
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 stored() {
try {
return localStorage.getItem(VIEW_KEY) === 'covers' ? 'covers' : 'list';
} catch {
return 'list';
}
}
function store(view) {
try {
localStorage.setItem(VIEW_KEY, view);
} catch { /* the view is only a convenience */ }
}
/** How the last scan left a model, from the metadata its card badge reads too. */
export function scanState(meta) {
if (meta.scan_choice === 'skip') return 'skip';
if (meta.info_source === 'civitai') return 'matched';
if (meta.info_source === 'local') return meta.unmatched_reason === 'damaged' ? 'damaged' : 'unmatched';
return 'new';
}
export const pickKey = item => `${item.type}|${item.path_idx}|${item.rel}`;
/** The picks' count and size, as the settings page and the picker's footer say them. */
export function picksLine(items) {
if (!items.length) return t('scanPickNone');
return t('scanPickSummary', { count: items.length, size: formatSize(items.reduce((sum, item) => sum + (item.size || 0), 0)) });
}
/**
* The listed models as rows to show (their key, state, folder and what a search matches), in
* folder order, and the models folders with their labels: the type, and where the folder is
* when it is not named after it or the type has two.
*/
function readModels(data) {
const perType = new Map();
for (const root of data.roots || []) perType.set(root.type, (perType.get(root.type) || 0) + 1);
const roots = new Map((data.roots || []).map((root) => {
const where = perType.get(root.type) > 1 ? `${root.parent}/${root.name}` : root.name !== root.type ? root.name : '';
const name = typeLabel({ type: root.type });
return [`${root.type}|${root.path_idx}`, { ...root, name, label: name + (where ? ` · ${where}` : '') }];
}).sort(([, a], [, b]) => a.name.localeCompare(b.name) || a.path_idx - b.path_idx));
const order = new Map([...roots.keys()].map((key, index) => [key, index]));
const models = (data.models || []).map((model) => {
const meta = model.metadata || {};
const cut = model.rel.lastIndexOf('/');
const root = `${model.type}|${model.path_idx}`;
const name = meta.custom_name || model.filename.replace(/\.safetensors$/i, '');
return {
...model, meta, name, root, dir: cut < 0 ? '' : model.rel.slice(0, cut),
key: pickKey(model), state: scanState(meta),
base: meta.baseModel || (meta.base_guess ? `≈ ${meta.base_guess}` : ''),
path: [roots.get(root)?.label || model.type, ...model.rel.split('/')].join(' / '),
text: `${name}\n${model.rel}`.toLowerCase(),
};
}).sort((a, b) => order.get(a.root) - order.get(b.root) || a.rel.localeCompare(b.rel, undefined, { sensitivity: 'base' }));
return { models, roots };
}
/** The item kept for a picked model (what the settings page lists and the scan needs). */
const asItem = model => ({
type: model.type, path_idx: model.path_idx, rel: model.rel, filename: model.filename,
name: model.name, size: model.size,
});
/** Opens the picker over the page with `picked` (items) picked; `onConfirm(items)` on OK. */
export function openScanPicker(picked, onConfirm) {
const scope = createViewScope();
const picks = new Map(picked.map(item => [pickKey(item), item]));
const filter = { folder: '', state: '', query: '', onlyPicked: false };
let view = stored();
let all = [];
let roots = new Map();
let shown = [];
let drawn = 0;
let lastClicked = -1;
const overlay = el('div', 'anomalous-pick-overlay');
const dialog = el('div', 'anomalous-pick-dialog');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
const close = () => scope.dispose();
const head = el('div', 'anomalous-pick-head');
const search = el('input', 'anomalous-pick-search');
search.type = 'search';
search.placeholder = t('scanPickSearch');
const views = el('div', 'anomalous-pick-views');
head.append(el('h2', 'anomalous-pick-title', t('scanPickTitle')), search, views, button('anomalous-pick-close', '✕', close));
const folders = el('nav', 'anomalous-pick-folders');
const chips = el('div', 'anomalous-pick-chips');
const bar = el('div', 'anomalous-pick-bar');
const list = el('div', 'anomalous-pick-list');
const main = el('div', 'anomalous-pick-main');
main.append(chips, bar, list);
const body = el('div', 'anomalous-pick-body');
body.append(folders, main);
const summary = el('span', 'anomalous-pick-summary');
const foot = el('div', 'anomalous-pick-foot');
foot.append(summary, el('span', 'anomalous-pick-hint', t('scanPickRangeHint')),
button('anomalous-scan-secondary anomalous-scan-small-btn', t('dialogCancel'), close),
button('anomalous-scan-primary anomalous-scan-small-btn', t('dialogOk'), () => {
onConfirm([...picks.values()]);
close();
}));
dialog.append(head, body, foot);
overlay.append(dialog);
document.body.append(overlay);
scope.onDispose(() => overlay.remove());
scope.listen(overlay, 'mousedown', (event) => { if (event.target === overlay) close(); });
scope.listen(window, 'keydown', (event) => {
if (event.key !== 'Escape') return;
event.stopPropagation(); // the scan page behind stays open
close();
}, true);
// More rows when the end of the list comes near.
const more = el('div', 'anomalous-pick-more');
const watcher = new IntersectionObserver((entries) => {
if (entries.some(entry => entry.isIntersecting)) drawMore();
}, { root: list, rootMargin: '300px' });
scope.onDispose(() => watcher.disconnect());
// A folder: '' all, "type|path_idx" a models folder, "type|path_idx|dir" a folder in it (subfolders too).
const inFolder = (model, folder = filter.folder) => !folder || folder === model.root
|| (folder.startsWith(`${model.root}|`) && `${model.dir}/`.startsWith(`${folder.slice(model.root.length + 1)}/`));
const matches = model => (!filter.query || model.text.includes(filter.query)) && (!filter.onlyPicked || picks.has(model.key));
function paintSummary() {
summary.textContent = picksLine([...picks.values()]);
}
function paintViews() {
views.replaceChildren(...[['list', 'scanPickViewList'], ['covers', 'scanPickViewCovers']].map(([value, key]) => {
const node = button(`anomalous-pick-view${view === value ? ' is-on' : ''}`, t(key), () => {
view = value;
store(view);
paintViews();
paintList();
});
node.setAttribute('aria-pressed', String(view === value));
return node;
}));
}
function paintFolders() {
const rows = [['', t('scanPickAllFolders'), 0, all]];
for (const [root, info] of roots) {
const inRoot = all.filter(model => model.root === root);
if (!inRoot.length) continue;
rows.push([root, info.label, 0, inRoot]);
const dirs = [...new Set(inRoot.flatMap(model => model.dir ? model.dir.split('/').map((_, i, parts) => parts.slice(0, i + 1).join('/')) : []))].sort();
for (const dir of dirs) {
const key = `${root}|${dir}`;
rows.push([key, dir.split('/').pop(), dir.split('/').length, inRoot.filter(model => inFolder(model, key))]);
}
}
folders.replaceChildren(...rows.map(([key, label, depth, models]) => {
const row = button(`anomalous-pick-folder${key === filter.folder ? ' is-on' : ''}${depth ? '' : ' is-root'}`, undefined, () => {
filter.folder = key;
lastClicked = -1;
paintFolders();
paintChips();
paintList();
});
row.style.paddingLeft = `${10 + depth * 14}px`;
const count = models.filter(model => picks.has(model.key)).length;
row.append(el('span', 'anomalous-pick-folder-name', label), el('span', 'anomalous-pick-folder-count', String(models.length)));
if (count) row.append(el('span', 'anomalous-pick-folder-picked', String(count)));
row.title = label;
return row;
}));
}
function paintChips() {
const here = all.filter(model => inFolder(model) && matches(model));
const counts = Object.fromEntries(STATES.map(state => [state, here.filter(model => model.state === state).length]));
const chip = (state, label, count) => {
const node = button(`anomalous-pick-chip${filter.state === state ? ' is-on' : ''}${state ? ` is-${state}` : ''}`, undefined, () => {
filter.state = state;
lastClicked = -1;
paintChips();
paintList();
});
node.append(el('span', '', label), el('strong', '', String(count)));
node.setAttribute('aria-pressed', String(filter.state === state));
return node;
};
chips.replaceChildren(chip('', t('scanPickStateAll'), here.length),
...STATES.filter(state => counts[state] || filter.state === state).map(state => chip(state, t(STATE_KEYS[state]), counts[state])));
}
function paintBar() {
const fresh = shown.filter(model => !picks.has(model.key)).length;
const pick = button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPickShown', { count: shown.length }), () => {
for (const model of shown) picks.set(model.key, asItem(model));
refresh();
});
pick.disabled = !fresh;
const unpick = button('anomalous-scan-secondary anomalous-scan-small-btn', t('scanPickUnpickShown'), () => {
for (const model of shown) picks.delete(model.key);
refresh();
});
unpick.disabled = fresh === shown.length;
const only = el('label', 'anomalous-pick-only');
const box = el('input');
box.type = 'checkbox';
box.checked = filter.onlyPicked;
box.onchange = () => {
filter.onlyPicked = box.checked;
refresh();
};
only.append(box, el('span', '', t('scanPickOnlyPicked')));
const clear = button('anomalous-pick-link', t('scanPickClear'), () => {
picks.clear();
refresh();
});
clear.disabled = !picks.size;
bar.replaceChildren(pick, unpick, only, clear);
}
function paintList() {
shown = all.filter(model => inFolder(model) && matches(model) && (!filter.state || model.state === filter.state));
drawn = 0;
list.className = `anomalous-pick-list is-${view}`;
list.replaceChildren();
if (!shown.length) list.append(el('p', 'anomalous-pick-empty', t('scanPickEmpty')));
drawMore();
paintBar();
}
function drawMore() {
if (drawn >= shown.length) return;
more.remove();
const end = Math.min(shown.length, drawn + STEP);
for (let index = drawn; index < end; index += 1) list.append(row(shown[index], index));
drawn = end;
if (drawn < shown.length) {
list.append(more);
watcher.observe(more);
}
}
/** One model: a click picks or unpicks it, a shift-click the models since the last click as well. */
function row(model, index) {
const node = button(`anomalous-pick-row${picks.has(model.key) ? ' is-picked' : ''}`, undefined, (event) => {
const pick = !picks.has(model.key);
const from = event.shiftKey && lastClicked >= 0 ? Math.min(lastClicked, index) : index;
const to = event.shiftKey && lastClicked >= 0 ? Math.max(lastClicked, index) : index;
for (const each of shown.slice(from, to + 1)) {
if (pick) picks.set(each.key, asItem(each));
else picks.delete(each.key);
}
lastClicked = index;
if (filter.onlyPicked && !pick) refresh();
else {
for (const each of list.querySelectorAll('.anomalous-pick-row')) each.classList.toggle('is-picked', picks.has(each.dataset.key));
paintFolders();
paintBar();
paintSummary();
}
});
node.dataset.key = model.key;
node.setAttribute('role', 'checkbox');
node.setAttribute('aria-checked', String(picks.has(model.key)));
const cover = el('span', 'anomalous-pick-cover');
if (model.preview_url) {
const img = el('img');
img.loading = 'lazy';
img.alt = '';
img.src = `${model.preview_url}&variant=${/\.(mp4|webm|mov)(?:&|$)/i.test(model.preview_url) ? 'poster' : 'card'}`;
img.onerror = () => img.remove();
cover.append(img);
}
const names = el('span', 'anomalous-pick-names');
names.append(el('strong', 'anomalous-pick-name', model.name), el('small', 'anomalous-pick-path', model.path));
const facts = el('span', 'anomalous-pick-facts');
facts.append(el('span', `anomalous-pick-state is-${model.state}`, t(STATE_KEYS[model.state])));
facts.append(el('span', 'anomalous-pick-base', model.base));
facts.append(el('span', 'anomalous-pick-size', formatSize(model.size)));
node.append(el('span', 'anomalous-pick-check', '✓'), cover, names, facts);
node.title = model.state === 'skip' ? `${model.rel}\n${t('modelSourceSkippedHint')}` : model.rel;
return node;
}
function refresh() {
paintFolders();
paintChips();
paintList();
paintSummary();
}
scope.listen(search, 'input', (event) => {
if (event.isComposing) return;
filter.query = search.value.trim().toLowerCase();
lastClicked = -1;
refresh();
});
scope.listen(search, 'compositionend', () => search.dispatchEvent(new Event('input')));
paintViews();
paintSummary();
list.append(el('p', 'anomalous-pick-empty', t('scanPickLoading')));
(async () => {
try {
const res = await fetch('/anomalous/scan_pickable', { signal: scope.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (scope.signal.aborted) return;
({ models: all, roots } = readModels(data));
if (data.total > all.length) main.insertBefore(el('p', 'anomalous-pick-note', t('scanPickTruncated', { shown: all.length, total: data.total })), list);
refresh();
search.focus();
} catch (error) {
if (scope.signal.aborted) return;
list.replaceChildren(el('p', 'anomalous-pick-empty', t('scanPickFailed', { error: String(error.message || error) })));
}
})();
}