Files
DemonGatanjieuandClaude Opus 5.5 e466e9dfcf feat(scan): list unmatched and unscanned models, show each scan's result, explain why
The scan page now says which models are unmatched or not scanned yet (type, folder,
why Civitai had nothing) and what each scan did, model by model; rows open the model,
scan it, look it up again or search Civitai. The progress box shows the counts and,
when floating, a way to the result. The activity log records a scan when it ends with
its result, and model edits (from -> to) and deletions (files moved) in detail.

- scraper.py reports one JSON line per changed model (--report-file); a failing model
  no longer ends the scan. Unmatched info keeps why (not_found / network / offline);
  online scans look network/offline ones up again. After two models in a row without
  an answer the rest of the scan infers from the files (--civitai-down for the next
  folders) instead of waiting out every timeout.
- civitai_client.py: the network side moved out of scraper.py; tells a 404 from
  Civitai being unreachable (CivitaiUnreachable).
- api/scan_report.py: ScanJob turns a scan's reports into one result, kept as the last
  scan (/anomalous/last_scan) and logged; /anomalous/scan_model opens a listed model.
- /anomalous/scan_all takes picked or listed models as `targets`, so one scan has one
  result; scan_summary lists unmatched and new models with their reason.
- Activity middleware takes details a handler attaches (request[ACTIVITY_DETAIL]).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:52:17 +08:00

201 lines
7.8 KiB
JavaScript

import { translate } from './locales.js';
const PANEL_ID = 'anomalous-scan-progress';
let closeTimer = null;
let host = null; // the scan page's slot while it is shown; otherwise the panel floats on the page
// Held here, not looked up by id: the scan page re-renders and briefly detaches it.
let current = null;
function ensurePanel() {
if (current) return current;
const panel = document.createElement('section');
panel.id = PANEL_ID;
panel.className = 'anomalous-scan-progress';
panel.setAttribute('role', 'status');
panel.setAttribute('aria-live', 'polite');
const title = document.createElement('strong');
title.className = 'anomalous-scan-progress-title';
panel.appendChild(title);
const track = document.createElement('div');
track.className = 'anomalous-scan-progress-track';
track.setAttribute('role', 'progressbar');
track.setAttribute('aria-valuemin', '0');
track.setAttribute('aria-valuemax', '100');
const fill = document.createElement('div');
fill.className = 'anomalous-scan-progress-fill';
track.appendChild(fill);
panel.appendChild(track);
const detail = document.createElement('div');
detail.className = 'anomalous-scan-progress-detail';
detail.style.whiteSpace = 'normal';
detail.style.lineHeight = '1.4';
detail.style.wordBreak = 'break-word';
panel.appendChild(detail);
const item = document.createElement('div');
item.className = 'anomalous-scan-progress-item';
panel.appendChild(item);
panel.classList.toggle('is-inline', Boolean(host));
(host || document.body).appendChild(panel);
current = panel;
return panel;
}
function removePanel() {
current?.remove();
current = null;
}
/** The scan page shows the progress inside itself (`element`), or lets it float again (null). */
export function setScanProgressHost(element) {
host = element;
const panel = current;
if (!panel) return;
// A result kept on the page is not worth floating over the canvas afterwards.
if (!host && panel.getAttribute('aria-busy') === 'false') {
removePanel();
return;
}
panel.classList.toggle('is-inline', Boolean(host));
(host || document.body).appendChild(panel);
}
function progressRatio(status) {
const total = Number(status.total) || 0;
const current = Math.min(Number(status.current) || 0, total);
const folderTotal = Number(status.folder_total) || 0;
const folderCurrent = Math.min(Number(status.folder_current) || 0, folderTotal);
const fileRatio = total > 0 ? current / total : 0;
if (total === 0 && (status.phase === 'preparing' || status.phase === 'enumerating')) return null;
if (folderTotal > 0 && folderCurrent > 0) {
return Math.min(1, ((folderCurrent - 1) + fileRatio) / folderTotal);
}
return total > 0 ? fileRatio : null;
}
export function updateScanProgress(status, titleText = '') {
if (!status || (!status.scanning && !status.interrupted)) return;
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
const panel = ensurePanel();
panel.classList.remove('is-complete', 'is-error');
const title = panel.querySelector('.anomalous-scan-progress-title');
const fill = panel.querySelector('.anomalous-scan-progress-fill');
const track = panel.querySelector('.anomalous-scan-progress-track');
const detail = panel.querySelector('.anomalous-scan-progress-detail');
const item = panel.querySelector('.anomalous-scan-progress-item');
title.textContent = titleText || translate('scanProgressTitle');
if (status.interrupted && !status.scanning) {
failScanProgress(translate('scanProgressInterrupted'));
return;
}
const ratio = progressRatio(status);
panel.setAttribute('aria-busy', 'true');
fill.classList.toggle('is-indeterminate', ratio === null);
fill.style.width = ratio === null ? '35%' : `${Math.max(2, Math.round(ratio * 100))}%`;
if (ratio === null) track.removeAttribute('aria-valuenow');
else track.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
const folderTotal = Number(status.folder_total) || 0;
const folderCurrent = Number(status.folder_current) || 0;
if (folderTotal > 0) {
detail.textContent = translate('scanProgressFolder', {
current: folderCurrent,
total: folderTotal,
folder: status.folder || '',
});
} else if (status.phase === 'enumerating' || status.phase === 'preparing') {
detail.textContent = translate('scanProgressPreparing');
} else {
detail.textContent = translate('sidebarScanning');
}
const total = Number(status.total) || 0;
item.textContent = total > 0
? translate('scanProgressModel', {
current: Number(status.current) || 0,
total,
filename: status.filename || '',
})
: translate('scanProgressCounting');
if (status.recovered && total === 0) item.textContent = translate('scanProgressInterrupted');
if (status.error) item.textContent = translate('scanProgressLastError', { error: status.error });
}
function closeLater(panel, ms = 4000) {
if (closeTimer) clearTimeout(closeTimer);
if (host) return; // on the scan page the result stays until the page is left
closeTimer = setTimeout(() => {
if (panel === current) removePanel();
closeTimer = null;
}, ms);
}
/** The scan has ended. `action` ({label, onClick}): a button while the box floats, e.g. to the result. */
export function finishScanProgress(detailText = '', action = null) {
const panel = ensurePanel();
panel.classList.remove('is-error');
panel.classList.add('is-complete');
panel.setAttribute('aria-busy', 'false');
panel.querySelector('.anomalous-scan-progress-title').textContent = translate('sidebarScanDone');
const fill = panel.querySelector('.anomalous-scan-progress-fill');
fill.classList.remove('is-indeterminate');
fill.style.width = '100%';
panel.querySelector('.anomalous-scan-progress-track').setAttribute('aria-valuenow', '100');
if (detailText) {
panel.querySelector('.anomalous-scan-progress-detail').textContent = detailText;
} else {
const currentDetail = panel.querySelector('.anomalous-scan-progress-detail').textContent;
if (!currentDetail || currentDetail === translate('sidebarScanning') || currentDetail === translate('scanProgressPreparing')) {
panel.querySelector('.anomalous-scan-progress-detail').textContent = translate('scanProgressFinished');
}
}
const item = panel.querySelector('.anomalous-scan-progress-item');
item.textContent = '';
if (action && !host) {
const open = document.createElement('button');
open.type = 'button';
open.className = 'anomalous-scan-progress-action';
open.textContent = action.label;
open.onclick = () => {
removePanel();
action.onClick();
};
item.appendChild(open);
}
closeLater(panel, action && !host ? 12000 : 4000);
}
export function failScanProgress(message) {
const panel = ensurePanel();
panel.classList.remove('is-complete');
panel.classList.add('is-error');
panel.setAttribute('aria-busy', 'false');
panel.querySelector('.anomalous-scan-progress-title').textContent = translate('scanProgressProblem');
const fill = panel.querySelector('.anomalous-scan-progress-fill');
fill.classList.remove('is-indeterminate');
fill.style.width = '100%';
panel.querySelector('.anomalous-scan-progress-track').removeAttribute('aria-valuenow');
panel.querySelector('.anomalous-scan-progress-detail').textContent = message || translate('scanProgressInterrupted');
panel.querySelector('.anomalous-scan-progress-item').textContent = translate('scanProgressRetryReady');
closeLater(panel);
}