Files
DemonGatanjieu-Anomalous_Mo…/web/modules/scan_results.js
T
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

69 lines
3.2 KiB
JavaScript

/**
* Words for what a scan found (the result GET /anomalous/last_scan returns, also kept in the
* activity log): a model's outcome, why an unmatched model has no Civitai record, a scan's
* counts. Shared by the scan page, the activity page and the progress box. No DOM.
*/
import { translate as t } from './locales.js';
const REASON_KEYS = { not_found: 'scanReasonNotFound', network: 'scanReasonNetwork', offline: 'scanReasonOffline' };
const ERROR_KEYS = { name_conflict: 'scanErrorNameConflict', duplicate_kept: 'scanErrorDuplicateKept' };
const COUNT_KEYS = [
['matched', 'scanCountMatched'], ['inferred', 'scanCountInferred'], ['failed', 'scanCountFailed'],
['covers', 'scanCountCovers'], ['renamed', 'scanCountRenamed'],
];
/** The mark before a model's line. */
export const STATUS_MARKS = { matched: '✓', inferred: '≈', failed: '✕', unchanged: '·' };
/** Why an unmatched model has no Civitai record. */
export function reasonText(reason) {
return t(REASON_KEYS[reason] || 'scanReasonUnknown');
}
/** Models whose lookup did not happen (offline, or no connection): the next online scan asks. */
export function isPending(reason) {
return reason === 'offline' || reason === 'network';
}
/** "3 matched · 1 inferred …", the parts that are not zero. */
export function countsLine(counts = {}) {
const parts = COUNT_KEYS.filter(([key]) => counts[key]).map(([key, label]) => t(label, { count: counts[key] }));
return parts.length ? parts.join(' · ') : t('scanCountNothing');
}
/** What happened to one model in a scan. */
export function fileOutcome(item) {
let text;
if (item.status === 'matched') text = t('scanFileMatched', { name: item.name || item.filename });
else if (item.status === 'inferred') {
text = item.base ? t('scanFileInferredBase', { reason: reasonText(item.reason), base: item.base })
: t('scanFileInferred', { reason: reasonText(item.reason) });
} else if (item.status === 'failed') text = t('scanFileFailed', { error: ERROR_KEYS[item.error] ? t(ERROR_KEYS[item.error]) : item.error || '?' });
else text = t('scanFileUpdated');
if (item.renamed_from) text += ` · ${t('scanFileRenamed', { from: item.renamed_from })}`;
if (item.duplicate) text += ` · ${t('scanFileDuplicate')}`;
if (item.cover === 'kept') text += ` · ${t('scanFileCoverKept')}`;
return text;
}
/** Which models a scan covered: all, picked, or one (`target`: its file name). */
export function scanScope(scan, target = '') {
if (scan?.kind === 'one') return t('scanScopeOne', { name: target || '?' });
if (scan?.kind === 'picked') return t('scanScopePicked');
return t(scan?.options?.retry_unmatched ? 'scanScopeRetry' : 'scanScopeAll');
}
/** The progress box's line when a scan has ended. */
export function resultLine(result) {
if (!result?.counts) return '';
if (result.kind === 'one') return oneModelLine(result);
return result.civitai_down ? `${countsLine(result.counts)} · ${t('scanCivitaiDownShort')}` : countsLine(result.counts);
}
/** After one model's scan (its card's radar button). */
export function oneModelLine(result) {
const item = result?.files?.[0];
return item ? fileOutcome(item) : t('scanOneUnchanged');
}