chore: initialize repository baseline

Import the existing Electron + React + TypeScript app as the version-control
baseline before the scanner rework (C# input/capture sidecar, resolution-anchored
layout profiles, OCR preprocessing, eval harness, rescan-merge, GOOD interop).

Housekeeping in this commit:
- Remove orphaned temp_inputhelper_block.ts (duplicate of the input-helper script).
- Ignore .claude/scheduled_tasks.lock local session state.
- Add .gitattributes to normalize line endings (LF in repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-07-05 20:31:01 +02:00
commit e76d88e0c7
147 changed files with 26220 additions and 0 deletions
@@ -0,0 +1,54 @@
import { useCallback } from "react";
import type { CaptureOptions, CaptureResult } from "../../../types/global";
import { captureSelectedSourceAction, exportCurrentGoodAction, loadStoredArtifactSnapshotAction, refreshCaptureSourcesAction, runDemoScanAction, showOverlayAction } from "../services/appControllerService";
import type { AppControllerContext } from "../services/appControllerService";
interface AppControllerActionsInput {
appControllerContext: AppControllerContext;
}
export interface AppControllerActionsResult {
loadStoredArtifactSnapshot: () => Promise<void>;
refreshCaptureSources: () => Promise<void>;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
runDemoScan: () => Promise<void>;
exportCurrentGood: () => Promise<void>;
showOverlay: () => Promise<void>;
}
export function useAppControllerActions({
appControllerContext,
}: AppControllerActionsInput): AppControllerActionsResult {
const loadStoredArtifactSnapshot = useCallback(async () => {
await loadStoredArtifactSnapshotAction(appControllerContext);
}, [appControllerContext]);
const refreshCaptureSources = useCallback(async () => {
await refreshCaptureSourcesAction(appControllerContext);
}, [appControllerContext]);
const captureSelectedSource = useCallback(async (delayMs = 0, focusGenshin = false, options?: CaptureOptions) => {
return captureSelectedSourceAction(appControllerContext, delayMs, focusGenshin, options);
}, [appControllerContext]);
const runDemoScan = useCallback(async () => {
await runDemoScanAction(appControllerContext, captureSelectedSource);
}, [appControllerContext, captureSelectedSource]);
const exportCurrentGood = useCallback(async () => {
await exportCurrentGoodAction(appControllerContext);
}, [appControllerContext]);
const showOverlay = useCallback(async () => {
await showOverlayAction(appControllerContext);
}, [appControllerContext]);
return {
loadStoredArtifactSnapshot,
refreshCaptureSources,
captureSelectedSource,
runDemoScan,
exportCurrentGood,
showOverlay,
};
}
@@ -0,0 +1,14 @@
import { useEffect } from "react";
import { initializeAppControllerStateAction } from "../services/appControllerService";
import type { AppControllerContext } from "../services/appControllerService";
interface AppControllerLifecycleInput {
appControllerContext: AppControllerContext;
}
export function useAppControllerLifecycle({ appControllerContext }: AppControllerLifecycleInput) {
useEffect(() => {
void initializeAppControllerStateAction(appControllerContext);
}, [appControllerContext.artifactRepo, appControllerContext.captureRepo, appControllerContext.snapshotRepo, appControllerContext.isOverlay]);
}
@@ -0,0 +1,92 @@
import { useMemo, useState } from "react";
import type { AppSnapshot } from "../../../types/domain";
import type { CaptureResult, CaptureSourceInfo } from "../../../types/global";
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import { createAppMetrics } from "../services/appMetricsService";
import type { AppControllerResult } from "../types";
import { createInitialSnapshot, createAppControllerContext } from "../services/appControllerService";
import { useAppControllerActions } from "./useAppControllerActions";
import { useAppControllerLifecycle } from "./useAppControllerLifecycle";
import { type NavigationId } from "../../layout/types";
export function useAppControllerState(): AppControllerResult {
const isOverlay = new URLSearchParams(window.location.search).get("overlay") === "1";
const repositories = useMemo(() => createRendererRepositories(), []);
const [snapshot, setSnapshot] = useState<AppSnapshot>(createInitialSnapshot);
const [activeView, setActiveView] = useState<NavigationId>("scan");
const [isScanning, setIsScanning] = useState(false);
const [captureSources, setCaptureSources] = useState<CaptureSourceInfo[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState("");
const [latestCapture, setLatestCapture] = useState<CaptureResult | null>(null);
const [topbarStatus, setTopbarStatus] = useState(
repositories
? "Electron bridge connected. Refresh sources after Genshin is open."
: "Electron bridge missing. Open the Electron app window, not the browser preview URL.",
);
const appControllerContext = useMemo(
() =>
createAppControllerContext(repositories, {
isOverlay,
selectedSourceId,
snapshot,
setCaptureSources,
setSelectedSourceId,
setTopbarStatus,
setSnapshot,
setLatestCapture,
setIsScanning,
}),
[
repositories,
isOverlay,
selectedSourceId,
snapshot,
setCaptureSources,
setSelectedSourceId,
setTopbarStatus,
setSnapshot,
setLatestCapture,
setIsScanning,
],
);
const bridgeReady = Boolean(repositories?.isAvailable);
const canExportGood = Boolean(repositories?.canExportGood);
const canShowOverlay = Boolean(repositories?.canShowOverlay);
const { cards: metricCards } = useMemo(() => createAppMetrics(snapshot), [snapshot]);
useAppControllerLifecycle({ appControllerContext });
const {
loadStoredArtifactSnapshot,
refreshCaptureSources,
captureSelectedSource,
runDemoScan,
exportCurrentGood,
showOverlay,
} = useAppControllerActions({ appControllerContext });
return {
isOverlay,
snapshot,
activeView,
setActiveView,
isScanning,
captureSources,
selectedSourceId,
setSelectedSourceId,
latestCapture,
topbarStatus,
bridgeReady,
canExportGood,
canShowOverlay,
metricCards,
loadStoredArtifactSnapshot,
refreshCaptureSources,
captureSelectedSource,
runDemoScan,
exportCurrentGood,
showOverlay,
};
}
@@ -0,0 +1,215 @@
import { createDemoSnapshot, getPresets } from "../../../lib/demoData";
import { createLocalAccountSnapshot } from "../../../lib/localAccountSnapshot";
import { exportGood } from "../../../lib/goodFormat";
import { runAccountScan } from "../../../lib/scanner";
import type { AppSnapshot } from "../../../types/domain";
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, SaveResultWithPath, ArtifactStoreLoadResult } from "../../../types/global";
import type {
ArtifactRepositoryPort,
CaptureRepositoryPort,
OverlayRepositoryPort,
RendererRepositories,
ScanExportPort,
SnapshotRepositoryPort,
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { Dispatch, SetStateAction } from "react";
export interface AppControllerContext {
artifactRepo?: ArtifactRepositoryPort;
captureRepo?: CaptureRepositoryPort;
exportRepo?: ScanExportPort;
overlayRepo?: OverlayRepositoryPort;
snapshotRepo?: SnapshotRepositoryPort;
isOverlay: boolean;
selectedSourceId: string;
snapshot: AppSnapshot;
setCaptureSources: Dispatch<SetStateAction<CaptureSourceInfo[]>>;
setSelectedSourceId: (value: string) => void;
setTopbarStatus: (value: string) => void;
setSnapshot: Dispatch<SetStateAction<AppSnapshot>>;
setLatestCapture?: Dispatch<SetStateAction<CaptureResult | null>>;
setIsScanning?: Dispatch<SetStateAction<boolean>>;
}
interface AppControllerUIState {
isOverlay: boolean;
selectedSourceId: string;
snapshot: AppSnapshot;
setCaptureSources: Dispatch<SetStateAction<CaptureSourceInfo[]>>;
setSelectedSourceId: (value: string) => void;
setTopbarStatus: (value: string) => void;
setSnapshot: Dispatch<SetStateAction<AppSnapshot>>;
setLatestCapture?: Dispatch<SetStateAction<CaptureResult | null>>;
setIsScanning?: Dispatch<SetStateAction<boolean>>;
}
export function createAppControllerContext(
repositories: RendererRepositories | null,
uiState: AppControllerUIState,
): AppControllerContext {
return {
artifactRepo: repositories?.artifacts,
captureRepo: repositories?.capture,
exportRepo: repositories?.export,
overlayRepo: repositories?.overlay,
snapshotRepo: repositories?.snapshot,
isOverlay: uiState.isOverlay,
selectedSourceId: uiState.selectedSourceId,
snapshot: uiState.snapshot,
setCaptureSources: uiState.setCaptureSources,
setSelectedSourceId: uiState.setSelectedSourceId,
setTopbarStatus: uiState.setTopbarStatus,
setSnapshot: uiState.setSnapshot,
setLatestCapture: uiState.setLatestCapture,
setIsScanning: uiState.setIsScanning,
};
}
export function loadStoredArtifactSnapshotAction(context: AppControllerContext): Promise<void> {
const { artifactRepo, setSnapshot } = context;
if (!artifactRepo?.loadAll) {
return Promise.resolve();
}
return artifactRepo.loadAll().then((result: ArtifactStoreLoadResult) => {
if (!result?.ok || result.artifacts.length === 0) return;
const nextSnapshot = createLocalAccountSnapshot(result.artifacts, getPresets());
if (!nextSnapshot) return;
setSnapshot(nextSnapshot);
});
}
export async function initializeAppControllerStateAction(context: AppControllerContext): Promise<void> {
const { snapshotRepo, isOverlay, setTopbarStatus, setSnapshot } = context;
try {
await loadStoredArtifactSnapshotAction(context);
if (isOverlay) {
return;
}
const stored = await snapshotRepo?.load();
if (stored) {
setSnapshot((current) =>
current.artifacts.length > 0 && current.scanEvents[0]?.id === "local-db-loaded" ? current : stored,
);
}
await refreshCaptureSourcesAction(context);
} catch (error) {
setTopbarStatus(error instanceof Error ? error.message : "Controller initialization failed.");
}
}
export async function refreshCaptureSourcesAction(context: AppControllerContext): Promise<void> {
const { captureRepo, selectedSourceId, setCaptureSources, setSelectedSourceId, setTopbarStatus } = context;
if (!captureRepo?.listSources) {
setTopbarStatus("Electron bridge missing. Die Browser-Vorschau kann Genshin nicht scannen - Electron-App verwenden.");
return;
}
try {
const sources = await captureRepo.listSources();
setCaptureSources(sources);
const currentStillExists = sources.some((source: CaptureSourceInfo) => source.id === selectedSourceId);
const genshinCandidate = sources.find((source: CaptureSourceInfo) => source.isGenshinCandidate);
const current = currentStillExists ? sources.find((source: CaptureSourceInfo) => source.id === selectedSourceId) : null;
const preferred = genshinCandidate ?? current ?? sources.find((source: CaptureSourceInfo) => source.id.startsWith("screen:")) ?? sources[0];
if (preferred && preferred.id !== selectedSourceId) setSelectedSourceId(preferred.id);
setTopbarStatus(
`${sources.length} capture sources found${preferred?.isGenshinCandidate ? "; Genshin candidate selected" : ""}. Keep Genshin visible on the captured screen.`,
);
} catch (error) {
setTopbarStatus(error instanceof Error ? error.message : "Could not list capture sources.");
}
}
export async function captureSelectedSourceAction(
context: AppControllerContext,
delayMs = 0,
focusGenshin = false,
options?: CaptureOptions,
): Promise<CaptureResult | null> {
const { captureRepo, selectedSourceId, setTopbarStatus, setLatestCapture } = context;
if (!captureRepo?.captureSource) {
setTopbarStatus("Electron bridge missing. Capture only works inside the Electron app.");
return null;
}
if (!selectedSourceId) {
setTopbarStatus("No capture source selected. Press Sources after Genshin is open.");
return null;
}
if (!setLatestCapture) {
setTopbarStatus("Capture state cannot be updated.");
return null;
}
try {
if (delayMs > 0) {
setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`);
}
const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options);
setLatestCapture(capture);
const ocrStatus = capture.ocrSkipped
? "OCR skipped for fast scan."
: capture.ocrTimedOut
? "OCR timed out; review sample needed."
: "OCR handoff is next.";
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
return capture;
} catch (error) {
setTopbarStatus(error instanceof Error ? error.message : "Capture failed.");
return null;
}
}
export async function runDemoScanAction(
context: AppControllerContext,
captureSelectedSource: () => Promise<CaptureResult | null>,
): Promise<void> {
const { snapshotRepo, setSnapshot, setIsScanning, setTopbarStatus } = context;
if (!setIsScanning) {
return;
}
setIsScanning(true);
try {
await captureSelectedSource();
const next = await (snapshotRepo?.runMockScan() ?? Promise.resolve(null));
const resolved = next ?? (await runAccountScan());
setSnapshot(resolved);
await snapshotRepo?.save(resolved);
} catch (error) {
setTopbarStatus(error instanceof Error ? error.message : "Demo scan failed.");
} finally {
setIsScanning(false);
}
}
export function createInitialSnapshot(): AppSnapshot {
return createDemoSnapshot();
}
export function exportCurrentGoodAction(context: AppControllerContext): Promise<void> {
const { exportRepo, snapshot, setTopbarStatus } = context;
if (!exportRepo?.exportGood) {
setTopbarStatus("GOOD-Export ist nur in der Electron-App verfuegbar.");
return Promise.resolve();
}
if (snapshot.artifacts.length === 0) {
setTopbarStatus("Keine Artifacts zum Exportieren vorhanden.");
return Promise.resolve();
}
return exportRepo
.exportGood(exportGood(snapshot.artifacts))
.then((result: SaveResultWithPath) => {
setTopbarStatus(result.ok ? `GOOD exportiert: ${result.path}` : "GOOD-Export fehlgeschlagen.");
});
}
export async function showOverlayAction(context: AppControllerContext): Promise<void> {
const { overlayRepo, setTopbarStatus } = context;
if (!overlayRepo?.show) {
setTopbarStatus("Overlay braucht die Electron-Bridge.");
return;
}
try {
await overlayRepo.show();
} catch {
setTopbarStatus("Overlay konnte nicht gestartet werden.");
}
}
@@ -0,0 +1,43 @@
import type { ArtifactVerdict, AppSnapshot } from "../../../types/domain";
import { summarizeSnapshot } from "../../../lib/snapshotSummary";
import type { AppMetricCard } from "../../layout/types";
const verdictLabelByType: Record<ArtifactVerdict, string> = {
keep: "Keep",
maybe_level: "Test level",
character_specific: "Character-specific",
trash_candidate: "Trash",
needs_review: "Needs review",
};
export interface AppTopbarSafetyCounts {
keep: number;
maybe: number;
trash: number;
review: number;
}
export interface AppMetricsModel {
topbarSafety: AppTopbarSafetyCounts;
cards: AppMetricCard[];
}
export function createAppMetrics(snapshot: AppSnapshot): AppMetricsModel {
const summary = summarizeSnapshot(snapshot);
const recommendations = snapshot.recommendations;
const topbarSafety: AppTopbarSafetyCounts = {
keep: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "keep").length,
maybe: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "maybe_level").length,
trash: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "trash_candidate").length,
review: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "needs_review").length,
};
const cards: AppMetricCard[] = [
{ label: verdictLabelByType.keep, value: summary.artifacts, detail: `${topbarSafety.review} brauchen Review` },
{ label: "Behalten", value: summary.useful, detail: `${topbarSafety.keep + topbarSafety.maybe} / ${snapshot.artifacts.length} usable` },
{ label: "Trash-Kandidaten", value: summary.trash, detail: "wird nie automatisch geloescht" },
{ label: "Build-Optionen", value: summary.builds, detail: "Top-Vorschlaege verfuegbar" },
];
return { topbarSafety, cards };
}
+26
View File
@@ -0,0 +1,26 @@
import type { AppSnapshot } from "../../types/domain";
import type { CaptureOptions, CaptureResult, CaptureSourceInfo } from "../../types/global";
import { type AppMetricCard, type NavigationId } from "../layout/types";
export interface AppControllerResult {
activeView: NavigationId;
setActiveView: (value: NavigationId) => void;
isOverlay: boolean;
isScanning: boolean;
snapshot: AppSnapshot;
captureSources: CaptureSourceInfo[];
selectedSourceId: string;
setSelectedSourceId: (value: string) => void;
latestCapture: CaptureResult | null;
topbarStatus: string;
bridgeReady: boolean;
canExportGood: boolean;
canShowOverlay: boolean;
metricCards: AppMetricCard[];
refreshCaptureSources: () => Promise<void>;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
runDemoScan: () => Promise<void>;
exportCurrentGood: () => Promise<void>;
loadStoredArtifactSnapshot: () => Promise<void>;
showOverlay: () => Promise<void>;
}
+8
View File
@@ -0,0 +1,8 @@
import type { AppControllerResult } from "./types";
import { useAppControllerState } from "./hooks/useAppControllerState";
export function useAppController(): AppControllerResult {
return useAppControllerState();
}
+62
View File
@@ -0,0 +1,62 @@
import { AlertTriangle } from "lucide-react";
import { useBuildCardModel } from "./hooks/useBuildCardModel";
import { useBuildsViewModel } from "./hooks/useBuildsViewModel";
import type { BuildCardProps, BuildsViewProps } from "./types";
function BuildCard({ build, snapshot }: BuildCardProps) {
const artifactLookup = new Map(snapshot.artifacts.map((artifact) => [artifact.id, artifact]));
const {
characterName,
qualityLabel,
roundedScore,
explanation,
artifactRows,
warnings,
hasWarnings,
} = useBuildCardModel({
build,
characters: snapshot.characters,
artifactLookup,
});
return (
<section className="panel build-card">
<div className="panel-heading">
<div>
<p className="eyebrow">{qualityLabel}</p>
<h2>{characterName}</h2>
</div>
<strong className="score">{roundedScore}</strong>
</div>
<p className="build-copy">{explanation}</p>
<div className="build-pieces">
{artifactRows.map((artifact) => (
<div key={artifact.id}>
<span>{artifact.slot}</span>
<strong>{artifact.setName}</strong>
<small>{artifact.details}</small>
</div>
))}
</div>
{hasWarnings && (
<div className="warning-list">
{warnings.map((warning) => (
<span key={warning}><AlertTriangle size={14} />{warning}</span>
))}
</div>
)}
</section>
);
}
export function BuildsView({ snapshot }: BuildsViewProps) {
const { visibleBuilds } = useBuildsViewModel({ snapshot });
return (
<div className="build-grid">
{visibleBuilds.map(({ build }) => (
<BuildCard key={build.id} build={build} snapshot={snapshot} />
))}
</div>
);
}
@@ -0,0 +1,49 @@
import type { BuildSuggestion } from "../../../types/domain";
import type { Artifact, Character } from "../../../types/domain";
export interface BuildCardModel {
characterName: string;
qualityLabel: string;
roundedScore: number;
explanation: string;
hasWarnings: boolean;
artifactRows: Array<{
id: string;
slot: string;
setName: string;
details: string;
}>;
warnings: string[];
}
interface UseBuildCardModelInput {
build: BuildSuggestion;
characters: Character[];
artifactLookup: Map<string, Artifact>;
}
export function useBuildCardModel({
build,
characters,
artifactLookup,
}: UseBuildCardModelInput): BuildCardModel {
const character = characters.find((entry) => entry.id === build.characterId);
const artifacts = build.artifactIds
.map((id) => artifactLookup.get(id))
.filter((artifact): artifact is Artifact => Boolean(artifact));
return {
characterName: character?.name ?? build.characterId,
qualityLabel: build.quality.replace("_", " "),
roundedScore: Math.round(build.score),
explanation: build.explanation,
hasWarnings: build.warnings.length > 0,
artifactRows: artifacts.map((artifact) => ({
id: artifact.id,
slot: artifact.slot,
setName: artifact.setName,
details: `+${artifact.level} - ${artifact.mainStat}${artifact.equipped ? ` - ${artifact.equipped}` : ""}`,
})),
warnings: build.warnings,
};
}
@@ -0,0 +1,18 @@
import type { AppSnapshot } from "../../../types/domain";
import type { BuildSuggestion } from "../../../types/domain";
interface UseBuildsViewModelInput {
snapshot: AppSnapshot;
}
export interface BuildsViewModel {
visibleBuilds: Array<{
build: BuildSuggestion;
}>;
}
export function useBuildsViewModel({ snapshot }: UseBuildsViewModelInput): BuildsViewModel {
return {
visibleBuilds: snapshot.builds.slice(0, 9).map((build) => ({ build })),
};
}
+10
View File
@@ -0,0 +1,10 @@
import type { AppSnapshot, BuildSuggestion } from "../../types/domain";
export interface BuildsViewProps {
snapshot: AppSnapshot;
}
export interface BuildCardProps {
build: BuildSuggestion;
snapshot: AppSnapshot;
}
+10
View File
@@ -0,0 +1,10 @@
import { AlertTriangle, BadgeCheck, Lock, Sparkles, Trash2 } from "lucide-react";
import type { ArtifactVerdict } from "../../types/domain";
export const verdictMeta: Record<ArtifactVerdict, { label: string; className: string; icon: JSX.Element }> = {
keep: { label: "Keep", className: "pill keep", icon: <Lock size={14} /> },
maybe_level: { label: "Test level", className: "pill maybe", icon: <Sparkles size={14} /> },
character_specific: { label: "Character-specific", className: "pill specific", icon: <BadgeCheck size={14} /> },
trash_candidate: { label: "Trash candidate", className: "pill trash", icon: <Trash2 size={14} /> },
needs_review: { label: "Needs review", className: "pill review", icon: <AlertTriangle size={14} /> },
};
+145
View File
@@ -0,0 +1,145 @@
import { ShieldCheck } from "lucide-react";
import type {
AppMetricsProps,
AppShellProps,
AppSidebarProps,
AppTopbarProps,
} from "./types";
import { useAppSidebarModel } from "./hooks/useAppSidebarModel";
import { useAppTopbarModel } from "./hooks/useAppTopbarModel";
export function AppSidebar({ activeView, items, onSelect }: AppSidebarProps) {
const { navigationItems } = useAppSidebarModel({ items, onSelect });
return (
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">GA</div>
<div>
<div className="brand-title">Artifact Assistant</div>
<div className="brand-subtitle">Scanner-first MVP</div>
</div>
</div>
<nav className="nav-list">
{navigationItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
className={`nav-item ${activeView === item.id ? "active" : ""}`}
disabled={Boolean(item.disabled)}
onClick={item.onSelect}
title={item.disabled ? item.disabledReason : undefined}
>
<Icon size={18} />
{item.label}
</button>
);
})}
</nav>
<div className="safety-card">
<ShieldCheck size={18} />
<div>
<strong>Sicherheits-Grenzen</strong>
<span>Keine Eingriffe am Spielinventar, keine Ressourcen-Aktionen, nur Analyse und Scan.</span>
</div>
</div>
</aside>
);
}
export function AppTopbar({
topbarStatus,
canExportGood,
canShowOverlay,
isScanning,
artifactCount,
onShowOverlay,
onDemoScan,
onExportGood,
exportIcon,
overlayIcon,
demoIcon,
}: AppTopbarProps) {
const {
handleShowOverlay,
handleExportGood,
handleDemoScan,
isExportDisabled,
isDemoDisabled,
isOverlayDisabled,
exportButtonLabel,
exportButtonTitle,
overlayButtonLabel,
overlayButtonTitle,
demoButtonLabel,
demoButtonTitle,
} = useAppTopbarModel({
onExportGood,
onShowOverlay,
onDemoScan,
canExportGood,
canShowOverlay,
isScanning,
artifactCount,
});
return (
<header className="topbar">
<div>
<p className="eyebrow">Lokaler Windows-Assistent</p>
<h1>Scanne dein Inventar, triff einfache Artifact-Entscheidungen.</h1>
</div>
<div className="topbar-actions">
{topbarStatus && <span className="topbar-status">{topbarStatus}</span>}
<button className="ghost-button" onClick={handleExportGood} disabled={isExportDisabled} title={exportButtonTitle}>
{exportIcon}
<span>{exportButtonLabel}</span>
</button>
<button
className="ghost-button"
onClick={handleShowOverlay}
disabled={isOverlayDisabled}
title={overlayButtonTitle}
>
{overlayIcon}
<span>{overlayButtonLabel}</span>
</button>
<button
className="ghost-button"
onClick={handleDemoScan}
disabled={isDemoDisabled}
title={demoButtonTitle}
>
{demoIcon}
{demoButtonLabel}
</button>
</div>
</header>
);
}
export function AppMetrics({ metricCards }: AppMetricsProps) {
return (
<section className="metrics-grid">
{metricCards.map((metric) => (
<div className="metric" key={metric.label}>
<span>{metric.label}</span>
<strong>{metric.value}</strong>
<small>{metric.detail}</small>
</div>
))}
</section>
);
}
export function AppShell({ sidebar, children }: AppShellProps) {
return (
<div className="app-shell">
{sidebar}
<main className="main-panel">{children}</main>
</div>
);
}
@@ -0,0 +1,27 @@
import { useMemo } from "react";
import type { NavigationId } from "../types";
import type { AppNavigationItem, AppSidebarItemAction } from "../types";
export interface AppSidebarModel {
navigationItems: AppSidebarItemAction[];
}
interface UseAppSidebarModelInput {
items: AppNavigationItem[];
onSelect: (id: NavigationId) => void;
}
export function useAppSidebarModel({ items, onSelect }: UseAppSidebarModelInput): AppSidebarModel {
const navigationItems = useMemo(
() =>
items.map((item) => ({
...item,
onSelect: () => onSelect(item.id),
})),
[items, onSelect],
);
return {
navigationItems,
};
}
@@ -0,0 +1,67 @@
import { useCallback } from "react";
export interface AppTopbarModel {
handleShowOverlay: () => void;
handleExportGood: () => void;
handleDemoScan: () => void;
isExportDisabled: boolean;
isDemoDisabled: boolean;
isOverlayDisabled: boolean;
exportButtonLabel: string;
exportButtonTitle: string;
overlayButtonLabel: string;
overlayButtonTitle: string;
demoButtonLabel: string;
demoButtonTitle: string;
}
interface UseAppTopbarModelInput {
onExportGood: () => Promise<void> | void;
onShowOverlay: () => Promise<void>;
onDemoScan: () => void;
canExportGood: boolean;
canShowOverlay: boolean;
isScanning: boolean;
artifactCount: number;
}
export function useAppTopbarModel({
onExportGood,
onShowOverlay,
onDemoScan,
canExportGood,
canShowOverlay,
isScanning,
artifactCount,
}: UseAppTopbarModelInput): AppTopbarModel {
const handleShowOverlay = useCallback(() => {
void onShowOverlay();
}, [onShowOverlay]);
const handleExportGood = useCallback(() => {
void onExportGood();
}, [onExportGood]);
const handleDemoScan = useCallback(() => {
onDemoScan();
}, [onDemoScan]);
const isExportDisabled = !canExportGood || artifactCount === 0;
const isDemoDisabled = isScanning;
const isOverlayDisabled = !canShowOverlay;
return {
handleShowOverlay,
handleExportGood,
handleDemoScan,
isExportDisabled,
isDemoDisabled,
isOverlayDisabled,
exportButtonLabel: "GOOD Export",
exportButtonTitle: canExportGood ? "GOOD-Export starten." : "GOOD-Export ist nur in der Electron-App verfuegbar.",
overlayButtonLabel: "Overlay",
overlayButtonTitle: canShowOverlay ? "Overlay oeffnen." : "Overlay benoetigt die Electron-Bridge.",
demoButtonLabel: isScanning ? "Laedt..." : "Demo-Daten",
demoButtonTitle: "Laedt Beispieldaten fuer die Triage- und Build-Ansicht.",
};
}
+12
View File
@@ -0,0 +1,12 @@
export { AppShell, AppMetrics, AppSidebar, AppTopbar } from "./AppLayout";
export {
type AppMetricCard,
type AppNavigationItem,
type AppBarAction,
type NavigationId,
type AppSidebarProps,
type AppTopbarProps,
type AppMetricsProps,
type AppShellProps,
} from "./types";
export { appNavigationItems } from "./navigation";
+10
View File
@@ -0,0 +1,10 @@
import { Layers3, Radar, Wand2, Eye } from "lucide-react";
import type { AppNavigationItem } from "./types";
export const appNavigationItems: AppNavigationItem[] = [
{ id: "scan", label: "Scan", icon: Radar },
{ id: "triage", label: "Triage", icon: Layers3 },
{ id: "builds", label: "Builds", icon: Wand2 },
{ id: "overlay", label: "Overlay", icon: Eye },
];
+60
View File
@@ -0,0 +1,60 @@
import { type ComponentType, type ReactNode } from "react";
import type { LucideProps } from "lucide-react";
export type NavigationId = "scan" | "triage" | "builds" | "overlay";
export interface AppNavigationItem {
id: NavigationId;
label: string;
icon: ComponentType<LucideProps>;
disabled?: boolean;
disabledReason?: string;
}
export interface AppSidebarItemAction extends AppNavigationItem {
onSelect: () => void;
}
export interface AppMetricCard {
label: string;
value: number;
detail: string;
}
export interface AppBarAction {
id: string;
icon?: ReactNode;
label: string;
disabled?: boolean;
onAction: () => void;
title?: string;
}
export interface AppSidebarProps {
activeView: NavigationId;
items: AppNavigationItem[];
onSelect: (id: NavigationId) => void;
}
export interface AppTopbarProps {
topbarStatus: string;
canExportGood: boolean;
canShowOverlay: boolean;
isScanning: boolean;
artifactCount: number;
onExportGood: () => Promise<void> | void;
onShowOverlay: () => Promise<void>;
onDemoScan: () => void;
exportIcon?: ReactNode;
overlayIcon?: ReactNode;
demoIcon?: ReactNode;
}
export interface AppMetricsProps {
metricCards: AppMetricCard[];
}
export interface AppShellProps {
sidebar: ReactNode;
children: ReactNode;
}
+93
View File
@@ -0,0 +1,93 @@
import { Eye } from "lucide-react";
import type { OverlayPreviewProps, OverlaySettingsProps } from "./types";
import { useOverlayPreviewModel } from "./hooks/useOverlayPreviewModel";
import { useOverlaySettingsModel } from "./hooks/useOverlaySettingsModel";
export function OverlaySettings({
canShowOverlay,
onShowOverlay,
}: OverlaySettingsProps) {
const {
handleShowOverlay,
eyebrow,
title,
statusHeadline,
statusText,
buttonLabel,
buttonTitle,
} = useOverlaySettingsModel({ onShowOverlay });
return (
<section className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">{eyebrow}</p>
<h2>{title}</h2>
</div>
<Eye size={20} />
</div>
<div className="overlay-settings">
<div className="overlay-settings-copy">
<strong>{statusHeadline}</strong>
<span>{statusText}</span>
</div>
<button
className="primary-button"
onClick={handleShowOverlay}
disabled={!canShowOverlay}
title={canShowOverlay ? buttonTitle : "Overlay benoetigt die Electron-Bridge."}
>
<Eye size={16} />
{buttonLabel}
</button>
</div>
</section>
);
}
export function OverlayPreview({ snapshot }: OverlayPreviewProps) {
const {
artifactName,
artifactSlotLine,
artifactSubstatsText,
artifactMainStat,
characterNames,
scoreText,
metaClassName,
metaIcon,
metaLabel,
reason,
hasArtifact,
} = useOverlayPreviewModel({ snapshot });
return (
<div className="overlay-root">
<div className="overlay-card">
<div className="overlay-card-head">
<div>
<p className="eyebrow">Reward scan</p>
<h2>{artifactName}</h2>
</div>
<strong>{scoreText}</strong>
</div>
<div className={metaClassName}>
{metaIcon}
{metaLabel}
</div>
{hasArtifact ? (
<div className="overlay-artifact-mini">
<span>{artifactSlotLine}</span>
<strong>{artifactMainStat}</strong>
<small>{artifactSubstatsText}</small>
</div>
) : null}
<div className="overlay-character-list">
{characterNames.map((name) => (
<span key={name}>{name}</span>
))}
</div>
<p>{reason}</p>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import type { AppSnapshot, Recommendation } from "../../../types/domain";
import type { ReactNode } from "react";
import { verdictMeta } from "../../common/verdictMeta";
export interface OverlayPreviewModel {
candidate: Recommendation | null;
artifactName: string;
scoreText: string;
metaClassName: string;
metaLabel: string;
metaIcon: ReactNode;
artifactSlotLine: string;
artifactSubstatsText: string;
artifactMainStat: string;
characterNames: string[];
reason: string;
hasArtifact: boolean;
}
interface UseOverlayPreviewModelInput {
snapshot: AppSnapshot;
}
export function useOverlayPreviewModel({ snapshot }: UseOverlayPreviewModelInput): OverlayPreviewModel {
const candidate = snapshot.recommendations.find((entry) => entry.verdict === "keep")
?? snapshot.recommendations.find((entry) => entry.verdict === "character_specific")
?? snapshot.recommendations.find((entry) => entry.verdict === "maybe_level")
?? snapshot.recommendations[0]
?? null;
const artifact = snapshot.artifacts.find((entry) => entry.id === candidate?.artifactId);
const meta = verdictMeta[candidate?.verdict ?? "needs_review"];
const characterNames =
candidate?.bestCharacters.map((id) => snapshot.characters.find((character) => character.id === id)?.name ?? id).slice(0, 3) ?? [];
const characterDisplayNames = characterNames.length > 0 ? characterNames : ["Review first"];
return {
candidate,
artifactName: artifact?.setName ?? "Artifact detected",
scoreText: candidate ? `${Math.round(candidate.score)}` : "--",
metaClassName: meta.className,
metaLabel: meta.label,
metaIcon: meta.icon,
artifactSlotLine: artifact ? `${artifact.slot} | +${artifact.level}` : "No artifact",
artifactSubstatsText: artifact?.substats
.slice(0, 3)
.map((substat) => `${substat.key} ${substat.value}${substat.unit === "%" ? "%" : ""}`)
.join(" | ") ?? "",
artifactMainStat: artifact?.mainStat ?? "No main stat",
characterNames: characterDisplayNames,
reason: candidate?.reason ?? "Waiting for artifact detail view.",
hasArtifact: Boolean(artifact),
};
}
@@ -0,0 +1,32 @@
import { useCallback } from "react";
export interface OverlaySettingsModel {
handleShowOverlay: () => void;
eyebrow: string;
title: string;
statusHeadline: string;
statusText: string;
buttonLabel: string;
buttonTitle: string;
}
interface UseOverlaySettingsModelInput {
onShowOverlay: () => void;
}
export function useOverlaySettingsModel({ onShowOverlay }: UseOverlaySettingsModelInput): OverlaySettingsModel {
const handleShowOverlay = useCallback(() => {
void onShowOverlay();
}, [onShowOverlay]);
return {
handleShowOverlay,
eyebrow: "Farming overlay",
title: "Read-only reward assistant",
statusHeadline: "Aktueller MVP-Status",
statusText:
"Click-through Preview mit echten lokalen Recommendations. Live Reward Scan und Auto-DB-Sync kommen danach.",
buttonLabel: "Show overlay preview",
buttonTitle: "Overlay oeffnen.",
};
}
+10
View File
@@ -0,0 +1,10 @@
import type { AppSnapshot } from "../../types/domain";
export interface OverlaySettingsProps {
canShowOverlay: boolean;
onShowOverlay: () => void;
}
export interface OverlayPreviewProps {
snapshot: AppSnapshot;
}
+9
View File
@@ -0,0 +1,9 @@
import { ScanViewLayout } from "./components/ScanViewLayout";
import { useScanViewController } from "./hooks/useScanViewController";
import type { ScanViewProps } from "./types";
export function ScanView(props: ScanViewProps) {
const controller = useScanViewController(props);
return <ScanViewLayout {...props} controller={controller} />;
}
@@ -0,0 +1,104 @@
import { AlertTriangle, Camera, Eye } from "lucide-react";
import { ArtifactResultCard } from "./ScanResultCards";
import { useScanMainSectionModel } from "./hooks/useScanMainSectionModel";
import type { ScanMainSectionProps } from "./types";
export function ScanMainSection({
latestCapture,
captureStatus,
parsedArtifact,
sourceLabel,
gridLabel,
inventoryLabel,
activeTargetCount,
storedTotal,
reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen,
autoScanRunning,
canOpenReviewQueue,
openReviewQueue,
}: ScanMainSectionProps) {
const {
canOpenDetails,
handleOpenDetails,
handleOpenReviewQueue,
captureImageSrc,
captureImageAlt,
hasCapture,
captureModeText,
resultHeading,
noArtifactText,
noCaptureMessage,
targetLabel,
dbLabel,
reviewLabel,
rulesLabel,
} = useScanMainSectionModel({
latestCapture,
parsedArtifact,
activeTargetCount,
storedTotal,
reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen,
openReviewQueue,
});
return (
<div className="scanner-main-grid">
<div className="capture-stage-shell">
<div className="capture-stage">
{hasCapture ? (
<img src={captureImageSrc} alt={captureImageAlt} />
) : (
<div className="empty-stage">
<Camera size={28} />
<strong>Noch kein Bild</strong>
<span>{noCaptureMessage}</span>
</div>
)}
</div>
<div className="capture-stage-meta">
<div><span>Quelle</span><strong>{sourceLabel}</strong></div>
<div><span>Grid</span><strong>{gridLabel}</strong></div>
<div><span>Inventar</span><strong>{inventoryLabel}</strong></div>
<div><span>Modus</span><strong>{captureModeText}</strong></div>
</div>
</div>
<aside className="scanner-result-panel">
<div className="result-heading">
<p className="eyebrow">Zuletzt gelesen</p>
<h3>{resultHeading}</h3>
</div>
{parsedArtifact ? <ArtifactResultCard parsed={parsedArtifact} /> : (
<p className="result-empty">{noArtifactText}</p>
)}
<div className="scanner-result-brief">
<span>{targetLabel}</span>
<span>{dbLabel}</span>
<span>{reviewLabel}</span>
<span>{rulesLabel}</span>
</div>
<div className="scanner-result-actions">
<button
className="ghost-button"
onClick={handleOpenDetails}
disabled={!canOpenDetails}
>
<Eye size={15} />
Details
</button>
<button className="ghost-button" onClick={handleOpenReviewQueue} disabled={autoScanRunning || !canOpenReviewQueue}>
<AlertTriangle size={15} />
Review Queue
</button>
</div>
<p className="scanner-result-caption">{captureStatus}</p>
</aside>
</div>
);
}
@@ -0,0 +1,57 @@
import { ScanDiagnosticsModal } from "./modals/ScanDiagnosticsModal";
import { ScanSettingsModal } from "./modals/ScanSettingsModal";
import { ScanDetailsModal } from "./modals/ScanDetailsModal";
import { ScanReviewQueueModal } from "./modals/ScanReviewQueueModal";
import { ScanSummaryModal } from "./modals/ScanSummaryModal";
import type { ScanModalsSectionProps } from "./types";
export function ScanModalsSection({
captureStatus,
latestCapture,
diagnosticsOpen,
settingsOpen,
detailsOpen,
reviewQueueOpen,
controller,
setDiagnosticsOpen,
setSettingsOpen,
setDetailsOpen,
setReviewQueueOpen,
setScanSummary,
}: ScanModalsSectionProps) {
return (
<>
<ScanDiagnosticsModal
open={diagnosticsOpen}
captureStatus={captureStatus}
latestCapture={latestCapture}
controller={controller}
setDetailsOpen={setDetailsOpen}
setDiagnosticsOpen={setDiagnosticsOpen}
/>
<ScanSettingsModal
open={settingsOpen}
latestCapture={latestCapture}
controller={controller}
setSettingsOpen={setSettingsOpen}
/>
<ScanDetailsModal
open={detailsOpen}
latestCapture={latestCapture}
controller={controller}
setDetailsOpen={setDetailsOpen}
/>
<ScanReviewQueueModal
open={reviewQueueOpen}
controller={controller}
setReviewQueueOpen={setReviewQueueOpen}
/>
<ScanSummaryModal
open={Boolean(controller.scanSummary)}
controller={controller}
setScanSummary={setScanSummary}
devMode={controller.devMode}
/>
</>
);
}
@@ -0,0 +1,116 @@
import type { ArtifactResultCardProps, FieldConfidenceListProps, ReviewSampleCardProps, ScanSummaryFooterProps } from "./types";
import { useFieldConfidenceRowsModel, useReviewSampleCardModel, useScanResultCardModel } from "./hooks/useScanResultCardsModel";
import { useScanSummaryFooterModel } from "./hooks/useScanSummaryFooterModel";
export function FieldConfidenceList({ parsedArtifact }: FieldConfidenceListProps) {
const rows = useFieldConfidenceRowsModel({ parsedArtifact });
return (
<div className="field-confidence-list">
{rows.map(({ label, field, confidenceClassName }) => (
<div className={`field-confidence ${confidenceClassName}`} key={label}>
<span>{label}</span>
<strong>{field.confidence}%</strong>
<small>{field.source}</small>
</div>
))}
</div>
);
}
export function ArtifactResultCard({ parsed }: ArtifactResultCardProps) {
const { quality, levelText, equippedText, substats, noSubstatsText } = useScanResultCardModel({ parsed });
return (
<div className="artifact-result-card">
<div className="artifact-result-head">
<span className="artifact-set">{parsed.setName}</span>
<span className={`quality-chip ${quality.className}`}>{quality.label}</span>
</div>
<span className="artifact-slot">{parsed.slot}</span>
<div className="artifact-mainstat">
<span>Hauptwert</span>
<strong>{parsed.mainStat}</strong>
<em>{parsed.mainValue}</em>
</div>
<div className="artifact-equipped">
{levelText}
</div>
<div className="artifact-substats">
{substats.length > 0 ? substats.map((substat) => <span key={substat}>{substat}</span>) : <span className="none">{noSubstatsText}</span>}
</div>
<div className="artifact-equipped">
{equippedText}
</div>
</div>
);
}
export function ReviewSampleCard({ entry }: ReviewSampleCardProps) {
const model = useReviewSampleCardModel({ entry });
const {
artifactTitle,
reasonText,
sourceText,
captureTargetText,
resolutionText,
gridText,
ocrCountText,
parsedSlotText,
parsedMainText,
parsedSetText,
parsedEquippedText,
savedAtText,
showParsed,
ocrRows,
} = model;
return (
<article className="review-sample-card">
<div className="review-sample-head">
<div>
<strong>{artifactTitle}</strong>
<span>{reasonText}</span>
</div>
<time>{savedAtText}</time>
</div>
<div className="review-sample-meta">
<span>{sourceText}</span>
{captureTargetText && <span>{captureTargetText}</span>}
<span>{resolutionText}</span>
<span>{gridText}</span>
<span>{ocrCountText}</span>
</div>
{showParsed && (
<div className="review-sample-parsed">
<span>{parsedSlotText}</span>
<strong>{parsedMainText}</strong>
<span>{parsedSetText}</span>
<span>{parsedEquippedText}</span>
</div>
)}
{ocrRows.length > 0 && (
<div className="review-sample-ocr">
{ocrRows.map((ocrRow) => (
<span key={ocrRow.id}>{ocrRow.text}</span>
))}
</div>
)}
</article>
);
}
export function ScanSummaryFooter({ devMode, scanSummary, storedTotal }: ScanSummaryFooterProps) {
const { summaryCopy, devCopy } = useScanSummaryFooterModel({
devMode,
scanSummary,
storedTotal,
});
return (
<>
<p className="scan-summary-copy">{summaryCopy}</p>
{devCopy && <p className="scan-summary-dev">{devCopy}</p>}
</>
);
}
@@ -0,0 +1,188 @@
import {
AlertTriangle,
Camera,
Play,
Radar,
RefreshCw,
SlidersHorizontal,
Wrench,
} from "lucide-react";
import type { ScanTopControlsSectionProps } from "./types";
import { useScanTopControlsModel } from "./hooks/useScanTopControlsModel";
export function ScanTopControlsSection({
captureSources,
selectedSourceId,
setSelectedSourceId,
refreshCaptureSources,
captureSelectedSource,
bridgeReady,
isScanning,
controller,
}: ScanTopControlsSectionProps) {
const {
shouldShowGenshinSourceButton,
bridgeDisabled,
canStartAutoScan,
canStartManualScan,
canCaptureSingle,
autoScanRunning,
handleSourceChange,
selectGenshinSource,
openSettings,
openDiagnostics,
captureSingleArtifact,
stopScan,
runVisibleGridScan,
runAutoReviewScan,
bridgeStatusText,
bridgePillClass,
runtimeStatusText,
runtimePillClass,
playerStatusText,
autoScanButtonTitle,
autoScanButtonLabel,
manualScanButtonTitle,
captureSingleButtonTitle,
refreshCaptureSourcesTitle,
diagnosticsButtonTitle,
scanSetupButtonTitle,
showPlayerProgress,
progressWidth,
progressStats,
} = useScanTopControlsModel({
captureSources,
selectedSourceId,
setSelectedSourceId,
captureSelectedSource,
bridgeReady,
isScanning,
controller,
});
return (
<>
<div className="scanner-header">
<div>
<p className="eyebrow">Scanner</p>
<h2>Artifact capture workspace</h2>
<p className="scanner-subcopy">Grosse Vorschau vorn, klare Aktionen oben, Diagnose und Review nur bei Bedarf.</p>
</div>
<div className="scanner-header-pills">
<span className={`runtime-pill ${bridgePillClass}`}>{bridgeStatusText}</span>
<span className={`runtime-pill ${runtimePillClass}`}>{runtimeStatusText}</span>
</div>
</div>
{!bridgeReady && (
<div className="bridge-banner">
<AlertTriangle size={16} />
Die Capture-Verbindung fehlt. Bitte das Electron-App-Fenster verwenden - die Browser-Vorschau kann Genshin nicht scannen.
</div>
)}
<div className="player-scan-card">
<div className="player-scan-row">
<label className="source-select">
<span>Quelle</span>
<select value={selectedSourceId} onChange={handleSourceChange} disabled={bridgeDisabled}>
{captureSources.length === 0 && <option value="">Keine Quellen gefunden</option>}
{captureSources.map((source) => (
<option key={source.id} value={source.id}>
{source.isGenshinCandidate ? "Genshin - " : ""}{source.name}
</option>
))}
</select>
</label>
<button
className="ghost-button"
onClick={refreshCaptureSources}
title={refreshCaptureSourcesTitle}
disabled={!bridgeReady}
>
<RefreshCw size={15} />
Neu suchen
</button>
{shouldShowGenshinSourceButton && (
<button className="ghost-button" onClick={selectGenshinSource} disabled={!bridgeReady}>
<Radar size={15} />
Genshin waehlen
</button>
)}
<button
className="ghost-button"
onClick={openSettings}
disabled={bridgeDisabled}
title={scanSetupButtonTitle}
>
<SlidersHorizontal size={15} />
Scan-Setup
</button>
<button
className="ghost-button dev-toggle"
onClick={openDiagnostics}
disabled={!bridgeReady}
title={diagnosticsButtonTitle}
>
<Wrench size={15} />
Scanner Diagnose
</button>
</div>
<div className="player-scan-actions">
<button
className="primary-button scan-cta"
onClick={runVisibleGridScan}
disabled={!canStartAutoScan}
title={autoScanButtonTitle}
>
<Play size={16} />
{autoScanButtonLabel}
</button>
<button
className="ghost-button"
onClick={runAutoReviewScan}
disabled={!canStartManualScan}
title={manualScanButtonTitle}
>
<Radar size={15} />
Manueller Scan
</button>
<button
className="ghost-button"
onClick={captureSingleArtifact}
disabled={!canCaptureSingle}
title={captureSingleButtonTitle}
>
<Camera size={15} />
Einzelnes Artifact lesen
</button>
{autoScanRunning && (
<button className="stop-button" onClick={stopScan}>
Stop
</button>
)}
</div>
<p className="player-status">
{playerStatusText}
</p>
{showPlayerProgress && (
<div className="player-progress">
<div className="player-progress-bar">
<div style={{ width: `${progressWidth}%` }} />
</div>
<div className="player-progress-stats">
{progressStats.map((entry) => (
<span key={entry.label} className={entry.extraClass}>
<strong>{entry.value}</strong> {entry.label}
</span>
))}
</div>
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,85 @@
import { ScanMainSection } from "./ScanMainSection";
import { ScanModalsSection } from "./ScanModalsSection";
import { ScanTopControlsSection } from "./ScanTopControlsSection";
import type { ScanViewLayoutProps } from "./types";
export function ScanViewLayout({
captureSources,
selectedSourceId,
setSelectedSourceId,
latestCapture,
captureStatus,
refreshCaptureSources,
captureSelectedSource,
bridgeReady,
controller,
isScanning,
}: ScanViewLayoutProps) {
const {
setDetailsOpen,
setDiagnosticsOpen,
setSettingsOpen,
setReviewQueueOpen,
setScanSummary,
reviewSampleTotal,
autoScanRunning,
storedTotal,
learningRulesLoaded,
parsedArtifact,
learningRuleCount,
activeTargetCount,
canReadReviewQueue,
sourceLabel,
gridLabel,
inventoryLabel,
openReviewQueue,
} = controller;
return (
<section className="scanner-workbench">
<ScanTopControlsSection
captureSources={captureSources}
selectedSourceId={selectedSourceId}
setSelectedSourceId={setSelectedSourceId}
refreshCaptureSources={refreshCaptureSources}
captureSelectedSource={captureSelectedSource}
bridgeReady={bridgeReady}
isScanning={isScanning}
controller={controller}
/>
<ScanMainSection
latestCapture={latestCapture}
captureStatus={captureStatus}
parsedArtifact={parsedArtifact}
sourceLabel={sourceLabel}
gridLabel={gridLabel}
inventoryLabel={inventoryLabel}
activeTargetCount={activeTargetCount}
storedTotal={storedTotal}
reviewSampleTotal={reviewSampleTotal}
learningRulesLoaded={learningRulesLoaded}
learningRuleCount={learningRuleCount}
canOpenReviewQueue={!autoScanRunning && canReadReviewQueue}
openReviewQueue={openReviewQueue}
autoScanRunning={autoScanRunning}
setDetailsOpen={setDetailsOpen}
/>
<ScanModalsSection
captureStatus={captureStatus}
latestCapture={latestCapture}
diagnosticsOpen={Boolean(controller.diagnosticsOpen)}
settingsOpen={Boolean(controller.settingsOpen)}
detailsOpen={Boolean(controller.detailsOpen)}
reviewQueueOpen={Boolean(controller.reviewQueueOpen)}
controller={controller}
setDiagnosticsOpen={setDiagnosticsOpen}
setSettingsOpen={setSettingsOpen}
setDetailsOpen={setDetailsOpen}
setReviewQueueOpen={setReviewQueueOpen}
setScanSummary={setScanSummary}
/>
</section>
);
}
@@ -0,0 +1,81 @@
import { useCallback } from "react";
import type { ScanMainSectionProps } from "../types";
export interface ScanMainSectionModel {
canOpenDetails: boolean;
handleOpenDetails: () => void;
handleOpenReviewQueue: () => void;
captureImageSrc: string;
captureImageAlt: string;
hasCapture: boolean;
captureModeText: string;
resultHeading: string;
noArtifactText: string;
noCaptureMessage: string;
targetLabel: string;
dbLabel: string;
reviewLabel: string;
rulesLabel: string;
}
type UseScanMainSectionModelProps = Pick<
ScanMainSectionProps,
| "latestCapture"
| "parsedArtifact"
| "activeTargetCount"
| "storedTotal"
| "reviewSampleTotal"
| "learningRulesLoaded"
| "learningRuleCount"
| "setDetailsOpen"
| "openReviewQueue"
>;
export function useScanMainSectionModel({
latestCapture,
parsedArtifact,
activeTargetCount,
storedTotal,
reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen,
openReviewQueue,
}: UseScanMainSectionModelProps): ScanMainSectionModel {
const handleOpenDetails = useCallback(() => {
setDetailsOpen(true);
}, [setDetailsOpen]);
const handleOpenReviewQueue = useCallback(() => {
void openReviewQueue();
}, [openReviewQueue]);
const canOpenDetails = Boolean(latestCapture?.crops?.length || latestCapture?.ocr?.length);
const captureImageSrc = latestCapture ? latestCapture.detailDataUrl ?? latestCapture.dataUrl : "";
const captureImageAlt = latestCapture
? `Latest capture from ${latestCapture.name}`
: "Latest capture is not available yet";
const captureModeText = latestCapture ? "Erkannt" : "Warte";
const resultHeading = parsedArtifact ? parsedArtifact.name : "Noch kein Artifact";
const noArtifactText = "Oeffne ein Artifact in Genshin und nutze \"Einzelnes Artifact lesen\" - oder starte direkt den Auto-Scan.";
const noCaptureMessage = noArtifactText;
const targetLabel = `Ziel ${activeTargetCount}`;
const dbLabel = `DB ${storedTotal ?? "-"}`;
const reviewLabel = `Review ${reviewSampleTotal}`;
const rulesLabel = `Regeln ${learningRulesLoaded ? learningRuleCount : "..."}`;
return {
canOpenDetails,
handleOpenDetails,
handleOpenReviewQueue,
captureImageSrc,
captureImageAlt,
hasCapture: Boolean(latestCapture),
captureModeText,
resultHeading,
noArtifactText,
noCaptureMessage,
targetLabel,
dbLabel,
reviewLabel,
rulesLabel,
};
}
@@ -0,0 +1,166 @@
import type { ArtifactResultCardProps, FieldConfidenceListProps } from "../types";
import type { ParsedArtifactCandidate, ParsedField } from "../../../../lib/artifactOcrParser";
import type { ReviewSampleRecord } from "../../../../types/global";
export interface FieldConfidenceRowModel {
label: string;
field: ParsedField;
confidenceClassName: string;
}
export interface ScanResultCardModel {
levelField: ParsedField;
quality: {
label: string;
className: string;
};
fieldRows: FieldConfidenceRowModel[];
levelText: string;
equippedText: string;
substats: string[];
noSubstatsText: string;
}
export interface ReviewSampleCardModel {
artifactTitle: string;
reasonText: string;
sourceText: string;
captureTargetText: string | null;
resolutionText: string;
gridText: string;
ocrCountText: string;
parsedSlotText: string;
parsedMainText: string;
parsedSetText: string;
parsedEquippedText: string;
savedAtText: string;
showParsed: boolean;
ocrRows: Array<{ id: string; label: string; text: string }>;
}
export function useFieldConfidenceRowsModel({ parsedArtifact }: FieldConfidenceListProps): FieldConfidenceRowModel[] {
const rows: Array<[string, ParsedField]> = [
["Name", parsedArtifact.fields.name],
["Slot", parsedArtifact.fields.slot],
["Level", getLevelField(parsedArtifact)],
["Main", mergeField(parsedArtifact.fields.mainStat, parsedArtifact.fields.mainValue)],
["Set", parsedArtifact.fields.setName],
["Equipped", parsedArtifact.fields.equipped],
["Substats", parsedArtifact.fields.substats],
];
return rows.map(([label, field]) => ({
label,
field,
confidenceClassName: resolveFieldConfidenceClass(field),
}));
}
export function useScanResultCardModel({
parsed,
}: Pick<ArtifactResultCardProps, "parsed">): ScanResultCardModel {
const substats = parsed.substats;
return {
levelField: getLevelField(parsed),
quality: resolveQuality(parsed.confidence),
fieldRows: [
["Name", parsed.fields.name],
["Slot", parsed.fields.slot],
["Level", getLevelField(parsed)],
["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)],
["Set", parsed.fields.setName],
["Equipped", parsed.fields.equipped],
["Substats", parsed.fields.substats],
].map(([label, field]) => ({
label,
field,
confidenceClassName: resolveFieldConfidenceClass(field),
})),
levelText: resolveLevelText(parsed),
equippedText: resolveEquippedText(parsed.equipped),
substats,
noSubstatsText: "Keine Substats gelesen",
};
}
export function useReviewSampleCardModel({ entry }: { entry: ReviewSampleRecord }): ReviewSampleCardModel {
const parsed = entry.sample?.parsed as Partial<ParsedArtifactCandidate> | undefined;
const capture = entry.sample?.capture;
const ocr = capture?.ocr ?? [];
const grid = capture?.inventoryGrid;
const date = new Date(entry.savedAt);
const savedAtText = Number.isNaN(date.getTime()) ? "Unbekannter Zeitpunkt" : date.toLocaleString();
return {
artifactTitle: parsed?.name || "Unparsed capture",
reasonText: entry.sample?.reason || "manual",
sourceText: capture?.name || "Unknown source",
captureTargetText: capture?.captureTarget || null,
resolutionText: `${capture?.width ?? "?"}x${capture?.height ?? "?"}`,
gridText: grid ? `${grid.cols}x${grid.rows} grid ${grid.confidence}%` : "no grid",
ocrCountText: `${ocr.length} OCR fields`,
parsedSlotText: parsed?.slot || "Unknown slot",
parsedMainText: `${parsed?.mainStat || "Unknown main"} ${parsed?.mainValue || ""}`,
parsedSetText: parsed?.setName || "Unknown set",
parsedEquippedText: parsed?.equipped || "Not detected",
savedAtText,
showParsed: Boolean(parsed),
ocrRows: ocr.slice(0, 4).map((item) => ({
id: item.id,
label: item.label,
text: `${item.label}: ${item.confidence}%`,
})),
};
}
function getLevelField(parsedArtifact: ParsedArtifactCandidate): ParsedField {
const fieldLevel = parsedArtifact.fields.level;
if (fieldLevel?.value) {
return fieldLevel;
}
const derivedLevel = parsedArtifact.level;
return {
value: `${derivedLevel}`,
confidence: derivedLevel > 0 ? 80 : 0,
source: derivedLevel > 0 ? "derived" : "missing",
};
}
function resolveQuality(confidence: number): { label: string; className: string } {
return confidence >= 85
? { label: "Sauber gelesen", className: "good" }
: confidence >= 70
? { label: "Etwas unsicher", className: "mid" }
: { label: "Unsicher - bitte prüfen", className: "low" };
}
function mergeField(primary: ParsedField, secondary: ParsedField): ParsedField {
const confidence = primary.value && secondary.value
? Math.round((primary.confidence + secondary.confidence) / 2)
: Math.min(primary.confidence, secondary.confidence);
const source = primary.source === secondary.source
? primary.source
: primary.source === "missing"
? secondary.source
: primary.source;
return {
value: `${primary.value} ${secondary.value}`.trim(),
confidence,
source,
};
}
function resolveFieldConfidenceClass(field: ParsedField): string {
return field.confidence < 70 ? "low" : field.confidence < 86 ? "medium" : "high";
}
function resolveLevelText(parsed: ParsedArtifactCandidate): string {
return parsed.level > 0 ? `Level +${parsed.level}` : "Level nicht erkannt";
}
function resolveEquippedText(equipped: string | null | undefined): string {
return equipped && equipped !== "Not detected" ? `Ausgerüstet: ${equipped}` : "Nicht Ausgerüstet / nicht erkannt";
}
@@ -0,0 +1,25 @@
import type { ScanSummaryFooterProps } from "../types";
export interface ScanSummaryFooterModel {
summaryCopy: string;
devCopy: string | null;
}
export function useScanSummaryFooterModel({
devMode,
scanSummary,
storedTotal,
}: ScanSummaryFooterProps): ScanSummaryFooterModel {
const summaryCopy = scanSummary.status === "blocked" && scanSummary.clicked === 0 && scanSummary.mode !== "Manueller Scan"
? "Es wurden keine Klicks ausgefuehrt. Grund siehe oben."
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen. Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
const devCopy = devMode
? `clicked ${scanSummary.clicked} · attempted ${scanSummary.attempted} · verified ${scanSummary.verified} · parsed ${scanSummary.parsed} · misses ${scanSummary.misses} · pages ${scanSummary.pages}`
: null;
return {
summaryCopy,
devCopy,
};
}
@@ -0,0 +1,143 @@
import { useCallback, useMemo } from "react";
import type { ChangeEvent } from "react";
import type { ScanTopControlsSectionProps } from "../types";
export interface ScanTopControlsModel {
shouldShowGenshinSourceButton: boolean;
bridgeDisabled: boolean;
canStartAutoScan: boolean;
canStartManualScan: boolean;
canCaptureSingle: boolean;
handleSourceChange: (event: ChangeEvent<HTMLSelectElement>) => void;
selectGenshinSource: () => void;
openSettings: () => void;
openDiagnostics: () => void;
captureSingleArtifact: () => void;
stopScan: () => void;
runVisibleGridScan: () => void;
runAutoReviewScan: () => void;
bridgeStatusText: string;
bridgePillClass: string;
runtimeStatusText: string;
runtimePillClass: string;
playerStatusText: string;
autoScanButtonTitle: string;
autoScanButtonLabel: string;
manualScanButtonTitle: string;
captureSingleButtonTitle: string;
refreshCaptureSourcesTitle: string;
diagnosticsButtonTitle: string;
scanSetupButtonTitle: string;
showPlayerProgress: boolean;
progressWidth: number;
progressStats: Array<{ label: string; value: number | string; extraClass?: string }>;
}
export function useScanTopControlsModel({
captureSources,
selectedSourceId,
setSelectedSourceId,
captureSelectedSource,
bridgeReady,
isScanning,
controller,
}: ScanTopControlsSectionProps): ScanTopControlsModel {
const {
setSettingsOpen,
setDiagnosticsOpen,
requestScanStop,
runVisibleGridScan,
runAutoReviewScan,
autoScanRunning,
canCaptureSource,
canAutoScan,
hasSourceSelected,
requiresAdminForAutoScan,
reviewStatus,
runtimeInfo,
autoScanStats,
storedTotal,
scanProgressPercent,
genshinSource,
} = controller;
const handleSourceChange = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => setSelectedSourceId(event.target.value),
[setSelectedSourceId],
);
const selectGenshinSource = useCallback(() => {
if (genshinSource) {
setSelectedSourceId(genshinSource.id);
}
}, [genshinSource, setSelectedSourceId]);
const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]);
const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]);
const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]);
const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]);
const bridgeStatusText = bridgeReady ? "Bridge verbunden" : "Bridge fehlt";
const bridgePillClass = bridgeReady ? "elevated" : "standard";
const runtimeStatusText = runtimeInfo?.isElevated ? "Admin bereit" : "Standard";
const runtimePillClass = runtimeInfo?.isElevated ? "elevated" : "standard";
const playerStatusText = reviewStatus
|| (runtimeInfo?.isElevated
? "App laeuft als Administrator. Oeffne in Genshin das Artifact-Inventar und starte den Auto-Scan."
: "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten.");
const autoScanButtonTitle = requiresAdminForAutoScan
? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten."
: "Klickt und scrollt automatisch durch das sichtbare Artifact-Inventar.";
const autoScanButtonLabel = autoScanRunning ? "Scan laeuft..." : "Auto-Scan starten";
const manualScanButtonTitle = "Du klickst die Artifacts in Genshin selbst an; die App liest nur mit. Kein Auto-Klick, kein Scrollen.";
const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig.";
const refreshCaptureSourcesTitle = "Fenster- und Bildschirmquellen neu suchen";
const diagnosticsButtonTitle = bridgeReady
? "Scanner Diagnose oeffnen: Rechte, Grid-Erkennung und Logs."
: "Scanner Diagnose ist nur in der Electron-App vollstaendig nutzbar.";
const scanSetupButtonTitle = bridgeReady
? "Scan-Ziel, Skip-Zeilen und Operator-Optionen anpassen."
: "Scan-Setup ist nur in der Electron-App vollstaendig nutzbar.";
const showPlayerProgress = autoScanRunning || autoScanStats.clicked > 0 || autoScanStats.parsed > 0;
const progressStats = useMemo(
() => [
{ label: "Klicks", value: autoScanStats.clicked },
{ label: "Positionen", value: autoScanStats.attempted },
{ label: "Verifiziert", value: autoScanStats.verified },
{ label: "Gespeichert", value: autoScanStats.stored },
{ label: "Review", value: autoScanStats.review },
{ label: "Sammlung", value: storedTotal ?? "-", extraClass: "collection" },
],
[autoScanStats.clicked, autoScanStats.attempted, autoScanStats.verified, autoScanStats.stored, autoScanStats.review, storedTotal],
);
return {
shouldShowGenshinSourceButton: Boolean(genshinSource && selectedSourceId !== genshinSource.id),
bridgeDisabled: !bridgeReady || captureSources.length === 0,
canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan,
canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
handleSourceChange,
selectGenshinSource,
openSettings,
openDiagnostics,
captureSingleArtifact,
stopScan,
runVisibleGridScan,
runAutoReviewScan,
bridgeStatusText,
bridgePillClass,
runtimeStatusText,
runtimePillClass,
playerStatusText,
autoScanButtonTitle,
autoScanButtonLabel,
manualScanButtonTitle,
captureSingleButtonTitle,
refreshCaptureSourcesTitle,
diagnosticsButtonTitle,
scanSetupButtonTitle,
showPlayerProgress,
progressWidth: scanProgressPercent,
progressStats,
};
}
@@ -0,0 +1,83 @@
import { FieldConfidenceList } from "../ScanResultCards";
import type { ScanDetailsModalProps } from "./types";
import { useScanDetailsModalModel } from "./hooks/useScanDetailsModalModel";
export function ScanDetailsModal({
open,
latestCapture,
controller,
setDetailsOpen,
}: ScanDetailsModalProps) {
const { parsedArtifact } = controller;
const {
closeDetails,
stopPropagation,
parsedNotes,
showParsedNotes,
cropRows,
ocrRows,
debugText,
showCrops,
showOcr,
} = useScanDetailsModalModel({
setDetailsOpen,
parsedArtifact,
latestCapture,
});
if (!open || !latestCapture) return null;
return (
<div className="modal-backdrop" role="presentation" onClick={closeDetails}>
<div className="modal-panel" role="dialog" aria-modal="true" onClick={stopPropagation}>
<div className="modal-header">
<div>
<p className="eyebrow">Dev</p>
<h2>Crops, OCR & Confidence</h2>
</div>
<button className="ghost-button" onClick={closeDetails}>Schliessen</button>
</div>
<div className="modal-body">
{parsedArtifact && (
<>
<FieldConfidenceList parsedArtifact={parsedArtifact} />
{showParsedNotes && (
<div className="parsed-notes compact">
{parsedNotes.map((note) => <span key={note}>{note}</span>)}
</div>
)}
</>
)}
{showCrops && (
<div className="crop-grid details-grid">
{cropRows.map((crop) => (
<div className="crop-card" key={crop.id}>
<img src={crop.dataUrl} alt={crop.label} />
<div>
<strong>{crop.label}</strong>
<span>{crop.x},{crop.y} - {crop.width}x{crop.height}</span>
</div>
</div>
))}
</div>
)}
{showOcr && (
<div className="ocr-panel">
<strong>OCR candidates</strong>
{ocrRows.map((entry) => (
<div className="ocr-row" key={entry.id}>
<div>
<span>{entry.label}</span>
<small>{entry.confidence}% confidence</small>
</div>
<pre>{entry.text}</pre>
</div>
))}
</div>
)}
<p className="capture-debug">{debugText}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,172 @@
import { AlertTriangle, Eye, Wrench } from "lucide-react";
import type { ScanDiagnosticsModalProps } from "./types";
import { useScanDiagnosticsModalModel } from "./hooks/useScanDiagnosticsModalModel";
export function ScanDiagnosticsModal({
open,
captureStatus,
latestCapture,
controller,
setDetailsOpen,
setDiagnosticsOpen,
}: ScanDiagnosticsModalProps) {
const {
toggleDevMode,
} = controller;
const {
closeDiagnostics,
openDetails,
handleSaveReviewSample,
stopPropagation,
canOpenDetails,
statusTitle,
rightsClassName,
rightsValue,
genshinClassName,
genshinValue,
shouldShowAdminBanner,
gridSourceClass,
gridMainValue,
gridMetaValue,
learningRulesText,
learningRulesSubtext,
autoScanModeLabel,
autoScanRunning,
fingerprintText,
runtimeRows,
scanLimitText,
scanTipText,
autoScanStatsLines,
playerProgress,
showDevRows,
reviewStatus,
automationLogLines,
canSaveReviewSample,
} = useScanDiagnosticsModalModel({
setDetailsOpen,
setDiagnosticsOpen,
saveReviewSample: controller.saveReviewSample,
canSaveReviewSample: controller.canSaveReviewSample,
latestCapture,
controller,
captureStatus,
});
if (!open) return null;
return (
<div className="modal-backdrop" role="presentation" onClick={closeDiagnostics}>
<div className="modal-panel scanner-diagnostics-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
<div className="modal-header">
<div>
<p className="eyebrow">Scanner Diagnose</p>
<h2>Input, Grid & Lernstatus</h2>
</div>
<button className="ghost-button" onClick={closeDiagnostics}>Schliessen</button>
</div>
<div className="modal-body">
<div className="diagnostics-actions">
<button className="ghost-button" onClick={toggleDevMode}>
<Wrench size={15} />
{statusTitle}
</button>
</div>
<div className="scanner-preflight diagnostics-preflight">
<div className={rightsClassName}>
<span>App-Rechte</span>
<strong>{rightsValue}</strong>
</div>
<div className={genshinClassName}>
<span>Genshin</span>
<strong>{genshinValue}</strong>
</div>
</div>
{shouldShowAdminBanner && (
<p className="scanner-subcopy">
App laeuft im Standard-Modus. Auto-Scan braucht Administrator-Rechte: App schliessen und als Administrator neu starten (z.B. Terminal per Rechtsklick "Als Administrator ausfuehren" und darin "npm run dev").
</p>
)}
<div className={`grid-detection-strip ${gridSourceClass}`}>
<span>Tile grid</span>
<strong>{gridMainValue}</strong>
<small>{gridMetaValue}</small>
</div>
<div className="learning-strip">
<span>Learning</span>
<strong>{learningRulesText}</strong>
<small>{learningRulesSubtext}</small>
</div>
{playerProgress.show && (
<div className="auto-scan-strip">
<span>{autoScanModeLabel}</span>
{autoScanStatsLines.map((entry) => (
<span className="auto-scan-stat" key={entry.label}>
<strong>{entry.value}</strong>
<small>{entry.label}</small>
</span>
))}
</div>
)}
<div className="learning-strip">
<span>Fingerprint</span>
<strong>{fingerprintText}</strong>
<small>Active capture fingerprint used for deterministic duplicate guard checks.</small>
</div>
{showDevRows && (
<div className="scanner-status-row diagnostics-status">
{runtimeRows.map((row) => (
<span key={row}>{row}</span>
))}
</div>
)}
{autoScanRunning && playerProgress.show && (
<div className="player-progress">
<div className="player-progress-bar">
<div style={{ width: `${playerProgress.width}%` }} />
</div>
</div>
)}
{reviewStatus && (
<p className="review-status">{reviewStatus}</p>
)}
<div className="automation-log">
<span>Automation</span>
<div className="automation-log-lines">
{automationLogLines.length > 0 ? (
automationLogLines.map((line, index) => (
<span key={`${index}-${line}`}>{line}</span>
))
) : (
<strong>No scan activity yet.</strong>
)}
</div>
</div>
<div className="dev-section-actions">
<button className="ghost-button" onClick={openDetails} disabled={!canOpenDetails}>
<Eye size={15} />
Crops, OCR & Confidence
</button>
<button className="ghost-button" onClick={handleSaveReviewSample} disabled={!canSaveReviewSample}>
<AlertTriangle size={15} />
Review-Sample speichern
</button>
</div>
<p className="scanner-result-caption">{scanLimitText}</p>
<p className="scan-summary-copy">{scanTipText}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { AlertTriangle } from "lucide-react";
import { ReviewSampleCard } from "../ScanResultCards";
import type { ScanReviewQueueModalProps } from "./types";
import { useScanReviewQueueModalModel } from "./hooks/useScanReviewQueueModalModel";
export function ScanReviewQueueModal({
open,
controller,
setReviewQueueOpen,
}: ScanReviewQueueModalProps) {
const { reviewSamples, reviewSampleTotal, reviewAnalysis, canReadReviewQueue, loadReviewQueue } = controller;
const {
closeReviewQueue,
refreshReviewQueue,
stopPropagation,
emptyText,
reviewAnalysisRows,
showReviewAnalysis,
} = useScanReviewQueueModalModel({
setReviewQueueOpen,
loadReviewQueue,
reviewAnalysis,
reviewSampleTotal,
});
if (!open) return null;
return (
<div className="modal-backdrop" role="presentation" onClick={closeReviewQueue}>
<div className="modal-panel review-queue-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
<div className="modal-header">
<div>
<p className="eyebrow">Review Queue</p>
<h2>Unsichere Scanner-Faelle</h2>
</div>
<button className="ghost-button" onClick={closeReviewQueue}>Schliessen</button>
</div>
<div className="modal-body">
<div className="review-queue-summary">
<strong>{reviewSampleTotal}</strong>
<span>Samples gespeichert</span>
<button className="ghost-button" onClick={refreshReviewQueue} disabled={!canReadReviewQueue}>Aktualisieren</button>
</div>
{showReviewAnalysis && (
<div className="review-analysis">
{reviewAnalysisRows.map((row) => (
<div key={row.label}>
<span>{row.label}</span><strong>{row.value}</strong>
</div>
))}
</div>
)}
{reviewSamples.length === 0 ? (
<div className="empty-stage compact">
<AlertTriangle size={22} />
<strong>{emptyText}</strong>
<span>Unsichere Auto-Scan- oder OCR-Faelle erscheinen hier automatisch.</span>
</div>
) : (
<div className="review-sample-list">
{reviewSamples.map((entry, index) => (
<ReviewSampleCard entry={entry} key={`${entry.savedAt}-${index}`} />
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,99 @@
import type { ScanSettingsModalProps } from "./types";
import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel";
export function ScanSettingsModal({
open,
latestCapture,
controller,
setSettingsOpen,
}: ScanSettingsModalProps) {
const {
scanLimit,
skipRows,
runtimeInfo,
activeTargetCount,
} = controller;
const {
closeSettings,
handleScanLimitChange,
handleSkipRowsChange,
stopPropagation,
inventoryCountText,
inventoryClassName,
scanLimitClassName,
skipRowsClassName,
activeTargetText,
scanSummaryText,
} = useScanSettingsModalModel({
setSettingsOpen,
setScanLimit: controller.setScanLimit,
setScanLimitTouched: controller.setScanLimitTouched,
setSkipRows: controller.setSkipRows,
latestCapture,
scanLimit,
skipRows,
activeTargetCount,
runtimeInfo,
});
if (!open) return null;
return (
<div className="modal-backdrop" role="presentation" onClick={closeSettings}>
<div className="modal-panel scanner-settings-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
<div className="modal-header">
<div>
<p className="eyebrow">Scan-Setup</p>
<h2>Operator-Einstellungen</h2>
</div>
<button className="ghost-button" onClick={closeSettings}>Schliessen</button>
</div>
<div className="modal-body">
<div className="scan-config-strip">
<label>
<span>Anzahl Artifacts</span>
<input
type="number"
min={1}
max={1800}
value={scanLimit}
onChange={handleScanLimitChange}
/>
</label>
<label>
<span>Zeilen ueberspringen</span>
<input
type="number"
min={0}
max={8}
value={skipRows}
onChange={handleSkipRowsChange}
/>
</label>
<p>
Die App uebernimmt die erkannte Inventar-Anzahl nur als Startwert und Deckel nach oben. Dein manuell gesetztes Ziel bleibt erhalten.
Mit "Zeilen ueberspringen" kannst du den Startverzug korrigieren, falls du nicht am Anfang der Liste beginnst.
</p>
</div>
<div className="scanner-preflight diagnostics-preflight">
<div className={inventoryClassName}>
<span>Inventarzaehler</span>
<strong>{inventoryCountText}</strong>
</div>
<div className={scanLimitClassName}>
<span>Limit</span>
<strong>{scanLimit}</strong>
</div>
<div className={skipRowsClassName}>
<span>Skip</span>
<strong>{skipRows}</strong>
</div>
<p>{activeTargetText}</p>
</div>
<p className="scan-summary-copy">{scanSummaryText}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { Play, AlertTriangle } from "lucide-react";
import { ScanSummaryFooter } from "../ScanResultCards";
import type { ScanSummaryModalProps } from "./types";
import { useScanSummaryModalModel } from "./hooks/useScanSummaryModalModel";
export function ScanSummaryModal({
open,
controller,
setScanSummary,
devMode,
}: ScanSummaryModalProps) {
const { closeSummary, stopPropagation, iconKind, summaryTitle, gridSummaryText } = useScanSummaryModalModel({
setScanSummary,
scanSummary: controller.scanSummary,
});
if (!open || !controller.scanSummary) return null;
return (
<div className="modal-backdrop scan-summary-backdrop" role="presentation" onClick={closeSummary}>
<div className="scan-summary-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
<div className="scan-summary-icon">
{iconKind === "blocked" ? <AlertTriangle size={24} /> : <Play size={24} />}
</div>
<div>
<p className="eyebrow">Scan-Ergebnis</p>
<h2>{summaryTitle}</h2>
{gridSummaryText && <p className="scan-summary-copy">{gridSummaryText}</p>}
</div>
<div className="scan-summary-grid player">
<div><strong>{controller.scanSummary.stored}</strong><span>neu gespeichert</span></div>
<div><strong>{controller.scanSummary.review}</strong><span>unsicher (Review)</span></div>
<div><strong>{controller.scanSummary.duplicates}</strong><span>Duplikate</span></div>
<div><strong>{controller.scanSummary.verified}</strong><span>verifizierte Ansichten</span></div>
</div>
<ScanSummaryFooter devMode={devMode} scanSummary={controller.scanSummary} storedTotal={controller.storedTotal} />
<button className="primary-button" onClick={closeSummary}>Ok</button>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import { useCallback, type MouseEvent } from "react";
import type { ScanDetailsModalProps } from "../types";
import type { ParsedArtifactCandidate } from "../../../../lib/artifactOcrParser";
export interface ScanDetailsModalModel {
closeDetails: () => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
parsedNotes: string[];
showParsedNotes: boolean;
cropRows: Array<{ id: string; dataUrl: string; label: string; x: number; y: number; width: number; height: number }>;
ocrRows: Array<{ id: string; label: string; confidence: number; text: string }>;
debugText: string;
showCrops: boolean;
showOcr: boolean;
}
interface UseScanDetailsModalModelInput {
setDetailsOpen: ScanDetailsModalProps["setDetailsOpen"];
parsedArtifact: ScanDetailsModalProps["controller"]["parsedArtifact"];
latestCapture: ScanDetailsModalProps["latestCapture"];
}
export function useScanDetailsModalModel({
setDetailsOpen,
parsedArtifact,
latestCapture,
}: UseScanDetailsModalModelInput): ScanDetailsModalModel {
const closeDetails = useCallback(() => setDetailsOpen(false), [setDetailsOpen]);
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
}, []);
const parsedNotes = (parsedArtifact?.notes ?? []) as ParsedArtifactCandidate["notes"];
const crops = latestCapture?.crops ?? [];
const ocr = latestCapture?.ocr ?? [];
return {
closeDetails,
stopPropagation,
parsedNotes,
showParsedNotes: parsedNotes.length > 0,
cropRows: crops.map((crop) => ({
id: crop.id,
dataUrl: crop.dataUrl,
label: crop.label,
x: crop.rect.x,
y: crop.rect.y,
width: crop.rect.width,
height: crop.rect.height,
})),
ocrRows: ocr.map((entry) => ({
id: entry.id,
label: entry.label,
confidence: entry.confidence,
text: entry.text || "No text detected",
})),
debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`,
showCrops: crops.length > 0,
showOcr: ocr.length > 0,
};
}
@@ -0,0 +1,185 @@
import { detailFingerprint } from "../../../../lib/autoScanLoop";
import { sourceVersion } from "../../../../lib/genshinData";
import { useCallback, useMemo, type MouseEvent } from "react";
import type { ScanDiagnosticsModalProps } from "../types";
export interface ScanDiagnosticsModelProgress {
width: number;
show: boolean;
}
export interface ScanDiagnosticsModalModel {
closeDiagnostics: () => void;
openDetails: () => void;
handleSaveReviewSample: () => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
canOpenDetails: boolean;
statusTitle: string;
rightsClassName: string;
rightsValue: string;
genshinClassName: string;
genshinValue: string;
shouldShowAdminBanner: boolean;
gridSourceClass: string;
gridMainValue: string;
gridMetaValue: string;
learningRulesText: string;
learningRulesSubtext: string;
autoScanModeLabel: string;
fingerprintText: string;
runtimeRows: string[];
scanLimitText: string;
scanTipText: string;
autoScanStatsLines: Array<{ label: string; value: number }>;
playerProgress: ScanDiagnosticsModelProgress;
autoScanRunning: boolean;
showDevRows: boolean;
reviewStatus: string;
automationLogLines: string[];
canSaveReviewSample: boolean;
}
interface UseScanDiagnosticsModalModelInput extends Pick<
ScanDiagnosticsModalProps,
| "setDetailsOpen"
| "setDiagnosticsOpen"
| "saveReviewSample"
| "canSaveReviewSample"
| "latestCapture"
| "controller"
> {
captureStatus: string;
}
export function useScanDiagnosticsModalModel({
setDetailsOpen,
setDiagnosticsOpen,
saveReviewSample,
canSaveReviewSample,
latestCapture,
controller,
captureStatus,
}: UseScanDiagnosticsModalModelInput): ScanDiagnosticsModalModel {
const closeDiagnostics = useCallback(() => setDiagnosticsOpen(false), [setDiagnosticsOpen]);
const openDetails = useCallback(() => setDetailsOpen(true), [setDetailsOpen]);
const handleSaveReviewSample = useCallback(() => {
if (canSaveReviewSample && controller.parsedArtifact) {
void saveReviewSample();
}
}, [canSaveReviewSample, controller.parsedArtifact, saveReviewSample]);
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
}, []);
const canOpenDetails = Boolean(latestCapture?.crops?.length || latestCapture?.ocr?.length);
const shouldShowAdminBanner = !controller.runtimeInfo?.isElevated;
const rightsClassName = controller.runtimeInfo?.isElevated ? "ok" : "blocked";
const rightsValue = controller.runtimeInfo?.isElevated ? "Admin" : "Standard";
const genshinClassName = controller.runtimeInfo?.genshinFound ? "ok" : "neutral";
const genshinValue = controller.runtimeInfo?.genshinFound ? "Gefunden" : "Nicht gefunden";
const statusTitle = controller.devMode ? "Dev-Ausgabe kompakt" : "Dev-Ausgabe erweitern";
const autoScanModeLabel = controller.autoScanRunning ? "Scan active" : "Last scan";
const inventoryGrid = latestCapture?.inventoryGrid;
const gridSourceClass = inventoryGrid?.source ?? "missing";
const gridMainValue = inventoryGrid
? inventoryGrid?.source === "missing"
? "not detected"
: `${inventoryGrid?.cols} x ${inventoryGrid?.rows}`
: "waiting";
const gridMetaValue = inventoryGrid
? `${inventoryGrid?.confidence}% confidence - ${inventoryGrid?.centers.length} click targets`
: "Run a capture once while the artifact inventory is visible.";
const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading";
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. Data package: ${sourceVersion}`;
const playerProgress = useMemo(() => {
const width = Math.min(
100,
controller.autoScanStats.attempted > 0
? Math.round((controller.autoScanStats.verified / Math.max(1, controller.autoScanStats.attempted)) * 100)
: 0,
);
const show = controller.autoScanRunning || controller.autoScanStats.clicked > 0 || controller.autoScanStats.parsed > 0;
return { width, show };
}, [
controller.autoScanRunning,
controller.autoScanStats.attempted,
controller.autoScanStats.clicked,
controller.autoScanStats.parsed,
controller.autoScanStats.verified,
]);
const autoScanStatsLines = useMemo(
() => [
{ label: "clicked", value: controller.autoScanStats.clicked },
{ label: "attempted", value: controller.autoScanStats.attempted },
{ label: "verified", value: controller.autoScanStats.verified },
{ label: "parsed", value: controller.autoScanStats.parsed },
{ label: "stored", value: controller.autoScanStats.stored },
{ label: "review", value: controller.autoScanStats.review },
{ label: "duplicates", value: controller.autoScanStats.duplicates },
{ label: "misses", value: controller.autoScanStats.misses },
{ label: "pages", value: controller.autoScanStats.pages },
],
[
controller.autoScanStats.clicked,
controller.autoScanStats.attempted,
controller.autoScanStats.verified,
controller.autoScanStats.parsed,
controller.autoScanStats.stored,
controller.autoScanStats.review,
controller.autoScanStats.duplicates,
controller.autoScanStats.misses,
controller.autoScanStats.pages,
],
);
const runtimeRows = useMemo(() => {
const selectedSourceLabel = controller.selectedSource
? `Selected: ${controller.selectedSource.name}`
: "No source selected";
const storedTotalLabel = controller.storedTotal !== null ? `DB: ${controller.storedTotal} Artifacts` : "DB: -";
const runtime = controller.runtimeInfo
? `${controller.runtimeInfo?.isElevated ? "admin" : "standard"} - target ${controller.runtimeInfo?.genshinFound ? controller.runtimeInfo?.targetProcess || "Genshin" : "missing"} - fg ${controller.runtimeInfo?.foregroundProcess || "unknown"}`
: "unknown";
return [selectedSourceLabel, storedTotalLabel, runtime, captureStatus, controller.reviewStatus].filter(Boolean);
}, [controller.selectedSource, controller.storedTotal, controller.runtimeInfo, captureStatus, controller.reviewStatus]);
const fingerprintText = latestCapture ? detailFingerprint(latestCapture) : "--";
const scanLimitText = controller.scanLimit ? `Scan-Limit: ${controller.scanLimit}` : "Keine Limitinfo.";
const scanTipText = "Tip: Die Auto-Scan Logik bleibt aktivierbar, aber Dev-Ansicht ist rein informativ.";
return {
closeDiagnostics,
openDetails,
handleSaveReviewSample,
stopPropagation,
canOpenDetails,
statusTitle,
rightsClassName,
rightsValue,
genshinClassName,
genshinValue,
shouldShowAdminBanner,
gridSourceClass,
gridMainValue,
gridMetaValue,
learningRulesText,
learningRulesSubtext,
autoScanModeLabel,
fingerprintText,
runtimeRows,
scanLimitText,
scanTipText,
autoScanStatsLines,
playerProgress,
autoScanRunning: controller.autoScanRunning,
showDevRows: controller.devMode,
reviewStatus: controller.reviewStatus,
automationLogLines: controller.automationLog,
canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact),
};
}
@@ -0,0 +1,68 @@
import { useCallback, useMemo, type MouseEvent } from "react";
import type { ReviewSampleAnalysis } from "../../../../lib/reviewSampleAnalysis";
import type { ScanReviewQueueModalProps } from "../types";
export interface ScanReviewQueueRow {
label: string;
value: string;
}
export interface ScanReviewQueueModalModel {
closeReviewQueue: () => void;
refreshReviewQueue: () => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
emptyText: string;
reviewAnalysisRows: ScanReviewQueueRow[];
showReviewAnalysis: boolean;
}
interface UseScanReviewQueueModalModelInput extends Pick<
ScanReviewQueueModalProps,
"setReviewQueueOpen" | "loadReviewQueue"
> {
reviewAnalysis: ReviewSampleAnalysis;
reviewSampleTotal: number;
}
export function useScanReviewQueueModalModel({
setReviewQueueOpen,
loadReviewQueue,
reviewAnalysis,
reviewSampleTotal,
}: UseScanReviewQueueModalModelInput): ScanReviewQueueModalModel {
const closeReviewQueue = useCallback(() => setReviewQueueOpen(false), [setReviewQueueOpen]);
const refreshReviewQueue = useCallback(() => {
void loadReviewQueue();
}, [loadReviewQueue]);
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
}, []);
const weakFieldsSummary = useMemo(
() => reviewAnalysis.weakFields.slice(0, 3).map((entry) => `${entry.field} ${entry.count}`).join(", "),
[reviewAnalysis.weakFields],
);
const topReasonsSummary = useMemo(
() => reviewAnalysis.reasons.slice(0, 2).map((entry) => `${entry.reason} ${entry.count}`).join(", "),
[reviewAnalysis.reasons],
);
const reviewAnalysisRows = useMemo(
() => [
{ label: "Parsed", value: `${reviewAnalysis.withParsed}/${reviewAnalysis.total}` },
{ label: "Avg confidence", value: `${reviewAnalysis.averageConfidence}%` },
{ label: "Weak fields", value: weakFieldsSummary || "none" },
{ label: "Top reasons", value: topReasonsSummary || "none" },
],
[reviewAnalysis.averageConfidence, reviewAnalysis.total, reviewAnalysis.withParsed, topReasonsSummary, weakFieldsSummary],
);
return {
closeReviewQueue,
refreshReviewQueue,
stopPropagation,
emptyText: reviewSampleTotal > 0 ? "none" : "Keine Review-Samples",
reviewAnalysisRows,
showReviewAnalysis: reviewSampleTotal > 0,
};
}
@@ -0,0 +1,74 @@
import { useCallback, type ChangeEvent, type MouseEvent } from "react";
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
import { clampScanLimit, clampSkipRows } from "../../../../lib/scannerSession";
export interface ScanSettingsModalModel {
closeSettings: () => void;
handleScanLimitChange: (event: ChangeEvent<HTMLInputElement>) => void;
handleSkipRowsChange: (event: ChangeEvent<HTMLInputElement>) => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
inventoryCountText: string;
inventoryClassName: string;
scanLimitClassName: string;
skipRowsClassName: string;
activeTargetText: string;
scanSummaryText: string;
}
export function useScanSettingsModalModel({
setSettingsOpen,
setScanLimit,
setScanLimitTouched,
setSkipRows,
latestCapture,
scanLimit,
skipRows,
activeTargetCount,
runtimeInfo,
}: {
latestCapture: CaptureResult | null;
scanLimit: number;
skipRows: number;
activeTargetCount: number;
runtimeInfo: RuntimeInfo | null;
setSettingsOpen: (open: boolean) => void;
setScanLimit: (value: number) => void;
setScanLimitTouched: (touched: boolean) => void;
setSkipRows: (rows: number) => void;
}): ScanSettingsModalModel {
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
const handleScanLimitChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setScanLimitTouched(true);
setScanLimit(clampScanLimit(Number(event.target.value)));
}, [setScanLimit, setScanLimitTouched]);
const handleSkipRowsChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setSkipRows(clampSkipRows(Number(event.target.value)));
}, [setSkipRows]);
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
}, []);
const detectedInventoryCount = latestCapture?.inventoryCount?.current;
const detectedInventoryTotal = latestCapture?.inventoryCount?.total;
const inventoryClassName = detectedInventoryCount ? "ok" : "neutral";
const inventoryCountText = detectedInventoryCount
? `${detectedInventoryCount}/${detectedInventoryTotal || "?"}`
: "Noch nicht erkannt";
const scanLimitClassName = scanLimit !== detectedInventoryCount ? "ok" : "standard";
const skipRowsClassName = skipRows > 0 ? "ok" : "standard";
const focusModeText = runtimeInfo?.isElevated ? "Admin" : "Standard";
const activeTargetText = `Aktive Zielvorgabe ${activeTargetCount} und Fokusmodus ${focusModeText}.`;
const scanSummaryText = "Der Auto-Scan zaehlt \"Positionen\" und \"verified\", bevor die Datenbank in den Save-Pfad laeuft. So wird \"scanned\" nicht mit \"erfolgreich gespeichert\" verwechselt.";
return {
closeSettings,
handleScanLimitChange,
handleSkipRowsChange,
stopPropagation,
inventoryCountText,
inventoryClassName,
scanLimitClassName,
skipRowsClassName,
activeTargetText,
scanSummaryText,
};
}
@@ -0,0 +1,37 @@
import { useCallback, type MouseEvent } from "react";
import type { ScanSummaryModalProps } from "../types";
export interface ScanSummaryModalModel {
closeSummary: () => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
iconKind: "running" | "blocked" | "stopped" | "finished";
summaryTitle: string;
gridSummaryText: string | null;
}
interface UseScanSummaryModalModelInput extends Pick<ScanSummaryModalProps, "setScanSummary"> {
scanSummary: ScanSummaryModalProps["controller"]["scanSummary"];
}
export function useScanSummaryModalModel({
setScanSummary,
scanSummary,
}: UseScanSummaryModalModelInput): ScanSummaryModalModel {
const closeSummary = useCallback(() => setScanSummary(null), [setScanSummary]);
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
}, []);
const isBlocked = scanSummary?.status === "blocked";
const isStopped = scanSummary?.status === "stopped";
const summaryTitle = isStopped ? "Scan gestoppt" : isBlocked ? "Scan abgebrochen" : "Scan fertig";
const iconKind = isBlocked ? "blocked" : isStopped ? "stopped" : "finished";
const gridSummaryText = scanSummary?.gridLabel ?? null;
return {
closeSummary,
stopPropagation,
iconKind,
summaryTitle,
gridSummaryText,
};
}
@@ -0,0 +1,38 @@
import type { Dispatch, SetStateAction } from "react";
import type { ScanViewProps, ScanViewControllerResult } from "../../types";
export interface ScanDetailsModalProps {
open: boolean;
latestCapture: ScanViewProps["latestCapture"];
controller: Pick<ScanViewControllerResult, "parsedArtifact">;
setDetailsOpen: (open: boolean) => void;
}
export interface ScanDiagnosticsModalProps {
open: boolean;
captureStatus: string;
latestCapture: ScanViewProps["latestCapture"];
controller: ScanViewControllerResult;
setDetailsOpen: (open: boolean) => void;
setDiagnosticsOpen: (open: boolean) => void;
}
export interface ScanReviewQueueModalProps {
open: boolean;
controller: ScanViewControllerResult;
setReviewQueueOpen: (open: boolean) => void;
}
export interface ScanSettingsModalProps {
open: boolean;
latestCapture: ScanViewProps["latestCapture"];
controller: ScanViewControllerResult;
setSettingsOpen: (open: boolean) => void;
}
export interface ScanSummaryModalProps {
open: boolean;
controller: Pick<ScanViewControllerResult, "scanSummary" | "storedTotal">;
setScanSummary: Dispatch<SetStateAction<ScanViewControllerResult["scanSummary"]>>;
devMode: boolean;
}
+80
View File
@@ -0,0 +1,80 @@
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { ReviewSampleRecord } from "../../../types/global";
import type { ScanSummary } from "../../../lib/scannerSession";
import type { ScanViewControllerResult, ScanViewProps } from "../types";
import type { Dispatch, SetStateAction } from "react";
export interface FieldConfidenceListProps {
parsedArtifact: ParsedArtifactCandidate;
}
export interface ArtifactResultCardProps {
parsed: ParsedArtifactCandidate;
}
export interface ReviewSampleCardProps {
entry: ReviewSampleRecord;
}
export interface ScanSummaryFooterProps {
devMode: boolean;
scanSummary: ScanSummary;
storedTotal: number | null;
}
export interface ScanViewLayoutProps {
captureSources: ScanViewProps["captureSources"];
selectedSourceId: string;
setSelectedSourceId: (value: string) => void;
latestCapture: ScanViewProps["latestCapture"];
captureStatus: string;
refreshCaptureSources: () => Promise<void>;
captureSelectedSource: ScanViewProps["captureSelectedSource"];
bridgeReady: boolean;
controller: ScanViewControllerResult;
isScanning: boolean;
}
export interface ScanMainSectionProps {
latestCapture: ScanViewProps["latestCapture"];
captureStatus: string;
parsedArtifact: ScanViewControllerResult["parsedArtifact"];
sourceLabel: string;
gridLabel: string;
inventoryLabel: string;
activeTargetCount: number;
storedTotal: ScanViewControllerResult["storedTotal"];
reviewSampleTotal: number;
learningRulesLoaded: ScanViewControllerResult["learningRulesLoaded"];
learningRuleCount: number;
canOpenReviewQueue: boolean;
autoScanRunning: boolean;
openReviewQueue: () => void;
setDetailsOpen: (value: boolean) => void;
}
export interface ScanTopControlsSectionProps {
captureSources: ScanViewProps["captureSources"];
selectedSourceId: string;
setSelectedSourceId: (value: string) => void;
refreshCaptureSources: () => Promise<void>;
captureSelectedSource: ScanViewProps["captureSelectedSource"];
bridgeReady: boolean;
isScanning: boolean;
controller: ScanViewControllerResult;
}
export interface ScanModalsSectionProps {
captureStatus: string;
latestCapture: ScanViewProps["latestCapture"];
diagnosticsOpen: boolean;
settingsOpen: boolean;
detailsOpen: boolean;
reviewQueueOpen: boolean;
controller: ScanViewControllerResult;
setDiagnosticsOpen: (open: boolean) => void;
setSettingsOpen: (open: boolean) => void;
setDetailsOpen: (open: boolean) => void;
setReviewQueueOpen: (open: boolean) => void;
setScanSummary: Dispatch<SetStateAction<ScanViewControllerResult["scanSummary"]>>;
}
@@ -0,0 +1,109 @@
import { shouldFlagArtifactForReview } from "../../../lib/scannerLearning";
import type { ScannerLearningRules } from "../../../lib/scannerLearning";
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
import type { ScanActionContext } from "./scanViewScanActions";
import type {
ReviewStateContext,
} from "./scanViewReviewHelpers";
import type { ArtifactRepositoryPort, ReviewSampleRepositoryPort, LearningRepositoryPort, AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord, RuntimeInfo } from "../../../types/global";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
export interface ReviewStateContextInput {
artifactRepo?: ArtifactRepositoryPort;
reviewSamplesRepo?: ReviewSampleRepositoryPort;
learningRepo?: LearningRepositoryPort;
onStoredArtifactsChanged?: () => Promise<void> | void;
setReviewSampleTotal: Dispatch<SetStateAction<number>>;
setReviewSamples: Dispatch<SetStateAction<ReviewSampleRecord[]>>;
setLearningRulesLoaded: Dispatch<SetStateAction<boolean>>;
setScannerLearningRules: Dispatch<SetStateAction<ScannerLearningRules>>;
setReviewStatus: Dispatch<SetStateAction<string>>;
setStoredTotal: Dispatch<SetStateAction<number | null>>;
appendAutomationLog: (line: string) => void;
}
export interface ScanActionContextInput {
autoScanRunning: boolean;
setAutoScanRunning: Dispatch<SetStateAction<boolean>>;
isScanning: boolean;
stopVisibleScanRef: MutableRefObject<boolean>;
selectedSourceId: string;
bridgeReady: boolean;
automationRepo?: AutomationRepositoryPort;
runtimeInfo?: RuntimeInfo | null;
runtimeRepo?: RuntimeRepositoryPort;
scanLimit: number;
skipRows: number;
detectedInventoryCount: number;
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
setAutoScanStats: Dispatch<SetStateAction<AutoScanStats>>;
setReviewStatus: Dispatch<SetStateAction<string>>;
appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
persistParsedArtifact: (
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate,
source: string,
needsReview: boolean,
) => Promise<boolean>;
saveReviewSample: (
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate | null,
reason?: string,
) => Promise<BooleanResult | null>;
focusDashboard: () => Promise<void>;
captureSelectedSource: (
delayMs?: number,
focusGenshin?: boolean,
options?: CaptureOptions,
) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
}
export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext {
return {
artifactRepo: input.artifactRepo,
reviewSamplesRepo: input.reviewSamplesRepo,
learningRepo: input.learningRepo,
onStoredArtifactsChanged: input.onStoredArtifactsChanged,
setReviewSampleTotal: input.setReviewSampleTotal,
setReviewSamples: input.setReviewSamples,
setLearningRulesLoaded: input.setLearningRulesLoaded,
setScannerLearningRules: input.setScannerLearningRules,
setReviewStatus: input.setReviewStatus,
setStoredTotal: input.setStoredTotal,
appendAutomationLog: input.appendAutomationLog,
};
}
export function createScanActionContext(input: ScanActionContextInput): ScanActionContext {
return {
autoScanRunning: input.autoScanRunning,
setAutoScanRunning: input.setAutoScanRunning,
isScanning: input.isScanning,
stopVisibleScanRef: input.stopVisibleScanRef,
selectedSourceId: input.selectedSourceId,
bridgeReady: input.bridgeReady,
automationRepo: input.automationRepo,
runtimeInfo: input.runtimeInfo,
runtimeRepo: input.runtimeRepo,
scanLimit: input.scanLimit,
skipRows: input.skipRows,
detectedInventoryCount: input.detectedInventoryCount,
setScanSummary: input.setScanSummary,
setAutoScanStats: input.setAutoScanStats,
setReviewStatus: input.setReviewStatus,
appendAutomationLog: input.appendAutomationLog,
appendClickDiagnostics: input.appendClickDiagnostics,
parseArtifact: input.parseArtifact,
persistParsedArtifact: input.persistParsedArtifact,
shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false),
saveReviewSample: input.saveReviewSample,
focusDashboard: input.focusDashboard,
captureSelectedSource: input.captureSelectedSource,
captureFastSelectedSource: input.captureFastSelectedSource,
};
}
@@ -0,0 +1,342 @@
import type { Dispatch, SetStateAction } from "react";
import {
applyScannerLearningRules,
countScannerLearningRules,
deriveScannerLearningRules,
deriveScannerLearningRulesFromReviewSamples,
shouldFlagArtifactForReview,
type ScannerLearningRules,
} from "../../../lib/scannerLearning";
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "../../../lib/scannerCaptureQuality";
import { parseArtifactCandidate, type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import { isReviewOnlyArtifactSource, storedArtifactStrength, toStoredArtifact } from "../../../lib/artifactStore";
import {
type ArtifactRepositoryPort,
type LearningRepositoryPort,
type ReviewSampleRepositoryPort,
} from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { CaptureResult, ReviewSampleRecord, SaveResultWithPath } from "../../../types/global";
import type { StoredArtifactRecord } from "../../../types/storage";
const REVIEW_SAMPLE_LIMIT_INITIAL = 120;
const REVIEW_SAMPLE_LIMIT_QUEUE = 80;
export interface ReviewStateContext {
artifactRepo?: ArtifactRepositoryPort;
reviewSamplesRepo?: ReviewSampleRepositoryPort;
learningRepo?: LearningRepositoryPort;
onStoredArtifactsChanged?: () => Promise<void> | void;
setReviewSampleTotal: Dispatch<SetStateAction<number>>;
setReviewSamples: Dispatch<SetStateAction<ReviewSampleRecord[]>>;
setLearningRulesLoaded: Dispatch<SetStateAction<boolean>>;
setScannerLearningRules: Dispatch<SetStateAction<ScannerLearningRules>>;
setReviewStatus: Dispatch<SetStateAction<string>>;
setStoredTotal: Dispatch<SetStateAction<number | null>>;
appendAutomationLog: (line: string) => void;
}
async function loadReviewSamplesForContext(
context: ReviewStateContext,
limit: number,
): Promise<ReturnType<ReviewSampleRepositoryPort["loadSamples"]> | null> {
return context.reviewSamplesRepo?.loadSamples(limit).catch(() => null) ?? null;
}
async function loadReviewSamplesAndSetTotal(context: ReviewStateContext, limit: number) {
const result = await loadReviewSamplesForContext(context, limit);
if (result?.ok) {
context.setReviewSampleTotal(result.total);
}
return result;
}
export function parseLearnedArtifact(
capture: CaptureResult | null,
scannerLearningRules: ScannerLearningRules,
): ParsedArtifactCandidate | null {
return parseArtifactCandidate(applyScannerLearningRules(capture, scannerLearningRules));
}
export function createCaptureFromReviewSample(entry: ReviewSampleRecord): CaptureResult | null {
const capture = entry.sample?.capture;
if (!capture?.ocr) return null;
return {
id: `review-${entry.savedAt}`,
name: capture.name ?? "review-sample",
width: capture.width ?? 0,
height: capture.height ?? 0,
dataUrl: "",
capturedAt: capture.capturedAt ?? entry.savedAt,
captureTarget: capture.captureTarget,
crops: [],
inventoryDataUrl: capture.inventoryDataUrl,
ocr: capture.ocr,
inventoryGrid: capture.inventoryGrid,
};
}
function storedArtifactFingerprint(record: StoredArtifactRecord | null | undefined) {
if (!record) return "";
return [
record.name,
record.slot,
record.level ?? 0,
record.mainStat,
record.mainValue,
record.setName,
record.equipped,
(record.substats ?? []).join("|"),
record.needsReview ? "review" : "clean",
].join("::");
}
function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, incoming: StoredArtifactRecord) {
if (!existing) return true;
if (storedArtifactFingerprint(existing) === storedArtifactFingerprint(incoming)) return false;
const existingStrength = storedArtifactStrength(existing);
const incomingStrength = storedArtifactStrength(incoming);
const existingSubstats = existing.substats?.length ?? 0;
const incomingSubstats = incoming.substats?.length ?? 0;
const existingEquippedKnown = Boolean(existing.equipped && !/not detected/i.test(existing.equipped));
const incomingEquippedKnown = Boolean(incoming.equipped && !/not detected/i.test(incoming.equipped));
if (existing.needsReview && !incoming.needsReview) return true;
if (incomingSubstats > existingSubstats) return true;
if (!existingEquippedKnown && incomingEquippedKnown) return true;
if (incomingStrength >= existingStrength + 6) return true;
if (isReviewOnlyArtifactSource(existing.source)) return true;
return false;
}
function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules {
return { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } };
}
async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) {
const reviewResult = await loadReviewSamplesAndSetTotal(context, limit);
if (reviewResult?.ok) {
await recoverArtifactsFromReviewSamples(reviewResult.samples, rules, context);
}
return reviewResult;
}
export async function recoverArtifactsFromReviewSamples(
samples: ReviewSampleRecord[],
rules: ScannerLearningRules,
context: ReviewStateContext,
): Promise<number> {
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
if (!artifactRepo?.saveMany || samples.length === 0) return 0;
const existingResult = await artifactRepo.loadAll().catch(() => null);
const existingById = new Map(
(existingResult?.artifacts ?? []).map((record: StoredArtifactRecord) => [record.id, record]),
);
const recoveredCandidates = samples.flatMap((entry) => {
const capture = createCaptureFromReviewSample(entry);
const parsed = parseLearnedArtifact(capture, rules);
if (!capture || !parsed) return [];
if (captureSourceRejectionReason(capture)) return [];
const needsReview = shouldFlagArtifactForReview(parsed);
if (!shouldPersistParsedArtifact(parsed, needsReview)) return [];
const stored = toStoredArtifact(parsed, "review-reprocess", needsReview);
return shouldRecoverIntoStore(existingById.get(stored.id), stored) ? [stored] : [];
});
const recoveredById = new Map<string, StoredArtifactRecord>();
for (const record of recoveredCandidates) {
const existing = recoveredById.get(record.id);
if (!existing || storedArtifactStrength(record) > storedArtifactStrength(existing)) {
recoveredById.set(record.id, record);
}
}
const recovered = [...recoveredById.values()];
if (recovered.length === 0) return 0;
const result = await artifactRepo.saveMany(recovered).catch(() => null);
if (result?.ok) {
setStoredTotal(result.total);
void onStoredArtifactsChanged?.();
appendAutomationLog(`review reprocess: ${result.added} neu, ${result.updated} aktualisiert`);
return result.added + result.updated;
}
return 0;
}
export async function initializeLearningState(context: ReviewStateContext): Promise<void> {
const {
learningRepo,
setLearningRulesLoaded,
setScannerLearningRules,
} = context;
try {
const loadedRules = await learningRepo?.loadRules().catch(() => null);
const currentRules = getDefaultScannerRules(loadedRules);
if (countScannerLearningRules(currentRules) > 0) {
setScannerLearningRules(currentRules);
await loadReviewSamplesAndRecover(context, currentRules);
setLearningRulesLoaded(true);
return;
}
const reviewResult = await loadReviewSamplesAndRecover(context, { ...currentRules }, REVIEW_SAMPLE_LIMIT_INITIAL);
const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []);
const activeRules = countScannerLearningRules(derived) > 0 ? derived : currentRules;
if (countScannerLearningRules(derived) > 0) {
setScannerLearningRules(derived);
await learningRepo?.saveRules?.(derived).catch(() => null);
} else {
setScannerLearningRules(currentRules);
}
setLearningRulesLoaded(true);
} catch {
const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_INITIAL);
const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []);
setScannerLearningRules(derived);
if (countScannerLearningRules(derived) > 0) {
await learningRepo?.saveRules?.(derived).catch(() => null);
}
if (reviewResult?.ok) {
await recoverArtifactsFromReviewSamples(reviewResult.samples, derived, context);
}
setLearningRulesLoaded(true);
}
}
export async function mergeLearningRules(
nextRules: Partial<ScannerLearningRules> | null | undefined,
currentRules: ScannerLearningRules,
context: ReviewStateContext,
) {
if (!nextRules || countScannerLearningRules(nextRules) === 0) return null;
const merged = {
textReplacements: {
...currentRules.textReplacements,
...(nextRules.textReplacements ?? {}),
},
};
context.setScannerLearningRules(merged);
const result = await context.learningRepo?.saveRules?.(merged).catch(() => null);
return { result, merged };
}
export async function persistParsedArtifact(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate,
source: string,
needsReview: boolean,
context: ReviewStateContext,
) {
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
if (!artifactRepo?.saveMany) return false;
const rejection = captureRejectionReason(capture, parsed);
if (rejection) {
appendAutomationLog(`persist skip: ${rejection}`);
return false;
}
if (!shouldPersistParsedArtifact(parsed, needsReview)) {
appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${parsed.name})`);
return false;
}
try {
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview)]);
if (result?.ok) {
setStoredTotal(result.total);
void onStoredArtifactsChanged?.();
return true;
}
return false;
} catch {
return false;
}
}
export async function saveReviewSample(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate | null,
reason: string,
scannerLearningRules: ScannerLearningRules,
context: ReviewStateContext,
): Promise<{ result: SaveResultWithPath | null; recoveredParsed: ParsedArtifactCandidate | null; recoveredToDb: boolean }> {
const {
reviewSamplesRepo,
setReviewStatus,
appendAutomationLog,
setReviewSamples,
onStoredArtifactsChanged,
} = context;
if (!reviewSamplesRepo?.saveSample) {
setReviewStatus("Review-Sample speichern ist nur in der Electron-App verfuegbar.");
return { result: null, recoveredParsed: null, recoveredToDb: false };
}
if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false };
const result = await reviewSamplesRepo.saveSample({
reason,
capture: {
id: capture.id,
name: capture.name,
width: capture.width,
height: capture.height,
dataUrl: capture.dataUrl,
detailDataUrl: capture.detailDataUrl,
inventoryDataUrl: capture.inventoryDataUrl,
captureTarget: capture.captureTarget,
capturedAt: capture.capturedAt,
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
id: crop.id,
label: crop.label,
rect: crop.rect,
dataUrl: crop.dataUrl,
})),
inventoryGrid: capture.inventoryGrid,
inventoryCount: capture.inventoryCount,
ocr: capture.ocr,
},
parsed,
});
const learned = deriveScannerLearningRules(capture, parsed);
const learnedResult = await mergeLearningRules(learned, scannerLearningRules, context);
const learnedCount = countScannerLearningRules(learned);
const activeRules = learnedResult?.merged ?? scannerLearningRules;
const recoveredParsed = parseLearnedArtifact(capture, activeRules);
const recoveredNeedsReview = shouldFlagArtifactForReview(recoveredParsed);
let recoveredToDb = false;
if (recoveredParsed && shouldPersistParsedArtifact(recoveredParsed, recoveredNeedsReview)) {
const extendedContext: ReviewStateContext = {
...context,
artifactRepo: context.artifactRepo,
onStoredArtifactsChanged,
};
recoveredToDb = await persistParsedArtifact(capture, recoveredParsed, "review-recovered", recoveredNeedsReview, extendedContext);
}
setReviewStatus(
result?.path
? `Review-Sample gespeichert: ${result.path}${learnedCount > 0 ? ` - ${learnedCount} lokale Lernregeln aktualisiert` : ""}${recoveredToDb ? " - Artifact direkt in DB nachgezogen" : ""}`
: "Review-Sample konnte nicht gespeichert werden.",
);
if (learnedResult?.result?.ok) appendAutomationLog(`learning: ${learnedResult.result.total} aktive Textregeln`);
if (recoveredToDb && recoveredParsed) appendAutomationLog(`review recovered: ${recoveredParsed.name} +${recoveredParsed.level}`);
const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE);
if (reviewResult?.ok) {
setReviewSamples(reviewResult.samples);
}
return { result, recoveredParsed, recoveredToDb };
}
export async function loadReviewQueue(context: ReviewStateContext): Promise<void> {
const { setReviewSamples } = context;
const result = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE);
if (!result?.ok) return;
setReviewSamples(result.samples);
}
@@ -0,0 +1,294 @@
import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner";
import { captureRejectionReason } from "../../../lib/scannerCaptureQuality";
import { runAutoScanLoop } from "../../../lib/autoScanLoop";
import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils";
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { AutomationGuard, BooleanResult, CaptureOptions, CaptureResult, ClickResult, RuntimeInfo, ScrollResult } from "../../../types/global";
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { MutableRefObject } from "react";
import type { Dispatch, SetStateAction } from "react";
export interface ScanActionContext {
autoScanRunning: boolean;
setAutoScanRunning: Dispatch<SetStateAction<boolean>>;
isScanning: boolean;
stopVisibleScanRef: MutableRefObject<boolean>;
selectedSourceId: string;
bridgeReady: boolean;
automationRepo?: AutomationRepositoryPort;
runtimeRepo?: RuntimeRepositoryPort;
runtimeInfo?: RuntimeInfo | null;
scanLimit: number;
skipRows: number;
detectedInventoryCount: number;
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
setAutoScanStats: Dispatch<SetStateAction<AutoScanStats>>;
setReviewStatus: Dispatch<SetStateAction<string>>;
appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise<boolean>;
saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise<BooleanResult | null>;
shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean;
focusDashboard: () => Promise<void>;
}
function buildScanSignature(parsed: ParsedArtifactCandidate) {
return `${parsed.name}-${parsed.slot}-${parsed.mainStat}-${parsed.mainValue}-${parsed.level ?? 0}`;
}
export async function runAutoReviewScan(context: ScanActionContext): Promise<void> {
const {
autoScanRunning,
selectedSourceId,
stopVisibleScanRef,
setAutoScanRunning,
setReviewStatus,
setScanSummary,
setAutoScanStats,
appendAutomationLog,
scanLimit,
detectedInventoryCount,
captureSelectedSource,
parseArtifact,
persistParsedArtifact,
saveReviewSample,
shouldFlagArtifactForReview,
focusDashboard,
} = context;
if (autoScanRunning || !selectedSourceId) return;
setAutoScanRunning(true);
stopVisibleScanRef.current = false;
setScanSummary(null);
setAutoScanStats(emptyAutoScanStats);
setReviewStatus("Manueller Scan laeuft. Klicke in Genshin auf ein anderes Artifact; nur neue Artifacts werden verarbeitet.");
const seen = new Set<string>();
const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 };
let idleTicks = 0;
const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount);
const maxIdleTicks = 90;
while (!stopVisibleScanRef.current && stats.parsed < maxArtifacts && idleTicks < maxIdleTicks) {
const capture = await captureSelectedSource(0, true);
const rejection = captureRejectionReason(capture, parseArtifact(capture));
const parsed = parseArtifact(capture);
if (!capture || !parsed || rejection) {
if (capture && rejection) {
await saveReviewSample(capture, parsed, `manual:capture-rejected`);
stats.review++;
setAutoScanStats({ ...stats });
}
idleTicks++;
setReviewStatus(`Manueller Scan wartet auf ein lesbares Artifact... (${stats.parsed}/${maxArtifacts})${rejection ? ` ${rejection}` : ""}`);
await wait(700);
continue;
}
const signature = buildScanSignature(parsed);
if (seen.has(signature)) {
idleTicks++;
setReviewStatus(`Manueller Scan wartet auf ein neues Artifact... (${stats.parsed}/${maxArtifacts})`);
await wait(700);
continue;
}
seen.add(signature);
idleTicks = 0;
stats.attempted++;
stats.verified++;
stats.parsed++;
const reason = getAutoReviewReason(capture, parsed);
const needsReview = shouldFlagArtifactForReview(parsed);
if (reason) {
await saveReviewSample(capture, parsed, `manual:${reason}`);
stats.review++;
}
if (await persistParsedArtifact(capture, parsed, "manual-scan", needsReview)) {
stats.stored++;
}
setAutoScanStats({ ...stats });
setReviewStatus(`Manueller Scan: neues Artifact erkannt (${stats.parsed}/${maxArtifacts}). Klicke das naechste Artifact an oder druecke Stop.`);
await wait(700);
}
setAutoScanRunning(false);
const status: ScanSummary["status"] = stopVisibleScanRef.current ? "stopped" : "done";
const idleSuffix = idleTicks >= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : "";
await focusDashboard();
setReviewStatus(
`Manueller Scan ${status}. ${stats.verified} neue Ansichten verifiziert, ${stats.parsed} Artifacts gelesen, ${stats.stored} in der Datenbank gespeichert, ${stats.review} Review-Samples.${idleSuffix}`,
);
setScanSummary({
mode: "Manueller Scan",
status,
...stats,
targetCount: maxArtifacts,
gridLabel: "Nur neue, vom User angeklickte Artifacts wurden verarbeitet.",
});
appendAutomationLog(`manual scan finished: ${stats.parsed} parsed, ${stats.stored} stored, ${stats.review} review`);
}
export async function runVisibleGridScan(context: ScanActionContext): Promise<void> {
const {
autoScanRunning,
bridgeReady,
selectedSourceId,
runtimeInfo,
automationRepo,
runtimeRepo,
stopVisibleScanRef,
setAutoScanRunning,
setScanSummary,
setAutoScanStats,
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
captureSelectedSource,
captureFastSelectedSource,
parseArtifact,
persistParsedArtifact,
saveReviewSample,
shouldFlagArtifactForReview,
scanLimit,
skipRows,
detectedInventoryCount,
focusDashboard,
} = context;
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
if (requiresAdminForAutoScan) {
setReviewStatus(
"App laeuft nicht als Administrator. Bitte die App schliessen und als Administrator neu starten - Auto-Scan braucht Administrator-Rechte, damit Windows die simulierten Eingaben an Genshin nicht blockiert.",
);
setScanSummary({
mode: "Automatischer Scan",
status: "blocked",
...emptyAutoScanStats,
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
gridLabel: "App laeuft nicht als Administrator. Neustart als Administrator noetig.",
});
return;
}
setAutoScanRunning(true);
stopVisibleScanRef.current = false;
setScanSummary(null);
setAutoScanStats(emptyAutoScanStats);
setReviewStatus("Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null);
const adminBlockReason = automationBlockReason(freshRuntime);
if (adminBlockReason) {
setAutoScanRunning(false);
setReviewStatus(adminBlockReason);
appendAutomationLog("blocked: App laeuft nicht als Administrator, keine In-Game-Klicks ausgefuehrt");
setScanSummary({
mode: "Automatischer Scan",
status: "blocked",
...emptyAutoScanStats,
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
gridLabel: adminBlockReason,
});
return;
}
if (freshRuntime) {
const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found";
appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`);
}
setReviewStatus("Genshin wird in den Vordergrund geholt...");
const focusResult = await automationRepo?.focusGenshin().catch(() => null);
if (focusResult) {
appendAutomationLog(
`focus: ${focusResult.focused ? "ok" : "fehlgeschlagen"} found:${focusResult.genshinFound ? "yes" : "no"} setForeground:${focusResult.setForegroundResult ?? "n/a"} target:${focusResult.targetProcess || "?"} fg:${focusResult.foregroundProcess || "?"}`,
);
}
if (!focusResult?.focused) {
setAutoScanRunning(false);
const reason = !focusResult?.genshinFound
? "Genshin-Prozess wurde nicht gefunden. Bitte pruefen, ob Genshin laeuft, und Auto-Scan erneut starten."
: "Genshin konnte nicht in den Vordergrund geholt werden. Bitte Genshin manuell anklicken/fokussieren und Auto-Scan erneut starten.";
setReviewStatus(reason);
setScanSummary({
mode: "Automatischer Scan",
status: "blocked",
...emptyAutoScanStats,
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
gridLabel: reason,
});
return;
}
setReviewStatus("Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
const result = await runAutoScanLoop(
{
api: {
clickScreen: (x: number, y: number) => {
if (!automationRepo?.clickScreen) {
return Promise.resolve<ClickResult>({
ok: false,
x: 0,
y: 0,
clicked: false,
moved: false,
focused: false,
inputBlocked: false,
});
}
return automationRepo.clickScreen(x, y);
},
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => {
if (!automationRepo?.scrollScreen) {
return Promise.resolve<ScrollResult>({ ok: false, notchesSent: 0, inputBlocked: false });
}
return automationRepo.scrollScreen(notches, anchorX, anchorY);
},
getAutomationGuard: () =>
automationRepo?.getAutomationGuard?.() ??
Promise.resolve<AutomationGuard>({ ok: false, escapePressed: false, enterPressed: false, f9Pressed: false }),
},
captureSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin),
captureFastSelectedSource,
parseArtifact,
persistParsedArtifact,
saveReviewSample,
getAutoReviewReason,
shouldFlagArtifactForReview,
appendAutomationLog,
appendClickDiagnostics,
setReviewStatus,
setAutoScanStats,
shouldStop: () => stopVisibleScanRef.current,
},
{
scanLimit,
skipRows,
detectedInventoryCount,
},
);
setAutoScanRunning(false);
if (result.blockedReason) appendAutomationLog(`stop: ${result.blockedReason}`);
await focusDashboard();
setReviewStatus(`Automatischer Scan ${result.status === "stopped" ? "gestoppt" : result.status === "blocked" ? "blockiert" : "fertig"}. ${result.stats.clicked} Klicks, ${result.stats.attempted} Positionen bearbeitet, ${result.stats.verified} Ansichten verifiziert, ${result.stats.parsed} gelesen, ${result.stats.stored} in der Datenbank, ${result.stats.review} Review-Samples, ${result.stats.duplicates} Duplikate, ${result.stats.misses} Misses.${result.blockedReason ? ` ${result.blockedReason}` : ""}`);
setScanSummary({
mode: "Automatischer Scan",
status: result.status,
...result.stats,
targetCount: result.targetCount,
gridLabel: result.gridLabel,
});
}
@@ -0,0 +1,34 @@
import { useEffect } from "react";
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
interface ScanCommandListenerInput {
automationRepo?: AutomationRepositoryPort;
autoScanRunning: boolean;
isScanning: boolean;
selectedSourceId: string;
requestScanStop: (reason: string) => void;
runVisibleGridScan: () => Promise<void>;
}
export function useScanCommandListener({
automationRepo,
autoScanRunning,
isScanning,
selectedSourceId,
requestScanStop,
runVisibleGridScan,
}: ScanCommandListenerInput) {
useEffect(() => {
if (!automationRepo?.onCommand) return;
return automationRepo.onCommand((command: "start-auto" | "stop") => {
if (command === "stop") {
requestScanStop("Hotkey/Dev-Stop gedrueckt.");
return;
}
if (command === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
void runVisibleGridScan();
}
});
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
}
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import type { RuntimeInfo } from "../../../types/global";
import type { RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
export function useScanRuntimeInfo(runtimeRepo?: RuntimeRepositoryPort) {
const [runtimeInfo, setRuntimeInfo] = useState<RuntimeInfo | null>(null);
useEffect(() => {
let mounted = true;
runtimeRepo?.getRuntimeInfo().then((result) => {
if (mounted) {
setRuntimeInfo(result);
}
}).catch(() => undefined);
const timer = window.setInterval(() => {
runtimeRepo?.getRuntimeInfo().then((result) => {
if (mounted) setRuntimeInfo(result);
}).catch(() => undefined);
}, 5000);
return () => {
mounted = false;
window.clearInterval(timer);
};
}, [runtimeRepo]);
return runtimeInfo;
}
@@ -0,0 +1,75 @@
import { useEffect } from "react";
import { type AppSnapshot } from "../../../types/domain";
import { type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
import type { RuntimeInfo } from "../../../types/global";
import type { SnapshotRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { CaptureResult } from "../../../types/global";
type InventoryGrid = NonNullable<CaptureResult["inventoryGrid"]>;
interface ScanSnapshotPublisherInput {
autoScanRunning: boolean;
reviewStatus: string;
captureStatus: string;
selectedSourceName: string | null;
autoScanStats: AutoScanStats;
scanSummary: ScanSummary | null;
snapshot: AppSnapshot;
latestInventoryGrid: InventoryGrid | null | undefined;
automationLog: string[];
runtimeInfo: RuntimeInfo | null;
storedTotal: number | null;
learningRuleCount: number;
snapshotRepo?: SnapshotRepositoryPort;
}
export function useScanSnapshotPublisher({
autoScanRunning,
reviewStatus,
captureStatus,
selectedSourceName,
autoScanStats,
scanSummary,
snapshot,
latestInventoryGrid,
automationLog,
runtimeInfo,
storedTotal,
learningRuleCount,
snapshotRepo,
}: ScanSnapshotPublisherInput) {
useEffect(() => {
void snapshotRepo?.publishScannerStatus({
running: autoScanRunning,
reviewStatus,
captureStatus,
selectedSource: selectedSourceName,
stats: autoScanStats,
summary: scanSummary,
snapshotArtifacts: snapshot.artifacts.length,
snapshotCharacters: snapshot.characters.filter((character) => character.owned).length,
snapshotRecommendations: snapshot.recommendations.length,
snapshotBuilds: snapshot.builds.length,
grid: latestInventoryGrid ?? null,
automationLog: automationLog.slice(-12),
runtimeInfo,
storedTotal,
learningRuleCount,
updatedAt: new Date().toISOString(),
}).catch(() => undefined);
}, [
autoScanRunning,
reviewStatus,
captureStatus,
selectedSourceName,
autoScanStats,
scanSummary,
snapshot,
latestInventoryGrid,
automationLog,
runtimeInfo,
storedTotal,
learningRuleCount,
snapshotRepo,
]);
}
@@ -0,0 +1,296 @@
import { useCallback, useEffect, useMemo } from "react";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction } from "./scanViewScanActions";
import {
initializeLearningState,
loadReviewQueue as loadReviewQueueFromRepo,
persistParsedArtifact as persistParsedArtifactHelper,
saveReviewSample as saveReviewSampleHelper,
} from "./scanViewReviewHelpers";
import { createReviewContext, createScanActionContext } from "./scanViewControllerService";
import { useScanCommandListener } from "./useScanCommandListener";
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { ScannerLearningRules } from "../../../lib/scannerLearning";
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
import type {
ArtifactRepositoryPort,
ReviewSampleRepositoryPort,
LearningRepositoryPort,
RuntimeRepositoryPort,
AutomationRepositoryPort,
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
import type { RuntimeInfo } from "../../../types/global";
type BooleanSetter = Dispatch<SetStateAction<boolean>>;
type NumberSetter = Dispatch<SetStateAction<number>>;
type StringSetter = Dispatch<SetStateAction<string>>;
type NumberOrNullSetter = Dispatch<SetStateAction<number | null>>;
type ScannerRulesSetter = Dispatch<SetStateAction<ScannerLearningRules>>;
type ReviewSamplesSetter = Dispatch<SetStateAction<ReviewSampleRecord[]>>;
type ScanSummarySetter = Dispatch<SetStateAction<ScanSummary | null>>;
type AutoScanStatsSetter = Dispatch<SetStateAction<AutoScanStats>>;
interface ScanViewActionInput {
autoScanRunning: boolean;
setAutoScanRunning: BooleanSetter;
isScanning: boolean;
stopVisibleScanRef: MutableRefObject<boolean>;
selectedSourceId: string;
bridgeReady: boolean;
automationRepo?: AutomationRepositoryPort;
runtimeInfo?: RuntimeInfo | null;
runtimeRepo?: RuntimeRepositoryPort;
scanLimit: number;
skipRows: number;
detectedInventoryCount: number;
setScanSummary: ScanSummarySetter;
setAutoScanStats: AutoScanStatsSetter;
setReviewStatus: StringSetter;
appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
artifactRepo?: ArtifactRepositoryPort;
reviewSamplesRepo?: ReviewSampleRepositoryPort;
learningRepo?: LearningRepositoryPort;
onStoredArtifactsChanged?: (() => Promise<void>) | (() => void);
setReviewSampleTotal: NumberSetter;
setReviewSamples: ReviewSamplesSetter;
setLearningRulesLoaded: BooleanSetter;
setScannerLearningRules: ScannerRulesSetter;
setStoredTotal: NumberOrNullSetter;
scannerLearningRules: ScannerLearningRules;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
latestCapture: CaptureResult | null;
parsedArtifact: ParsedArtifactCandidate | null;
canCaptureSource: boolean;
setReviewQueueOpen: BooleanSetter;
}
export interface ScanViewActionResult {
requestScanStop: (reason?: string) => void;
saveReviewSample: (
capture?: CaptureResult | null,
parsed?: ParsedArtifactCandidate | null,
reason?: string,
) => Promise<BooleanResult | null>;
loadReviewQueue: () => Promise<void>;
openReviewQueue: () => Promise<void>;
runAutoReviewScan: () => Promise<void>;
runVisibleGridScan: () => Promise<void>;
}
export function useScanViewActions(input: ScanViewActionInput): ScanViewActionResult {
const {
autoScanRunning,
setAutoScanRunning,
isScanning,
stopVisibleScanRef,
selectedSourceId,
bridgeReady,
automationRepo,
runtimeInfo,
runtimeRepo,
scanLimit,
skipRows,
detectedInventoryCount,
setScanSummary,
setAutoScanStats,
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
parseArtifact,
artifactRepo,
reviewSamplesRepo,
learningRepo,
onStoredArtifactsChanged,
setReviewSampleTotal,
setReviewSamples,
setLearningRulesLoaded,
setScannerLearningRules,
setStoredTotal,
scannerLearningRules,
captureSelectedSource,
latestCapture,
parsedArtifact,
canCaptureSource,
setReviewQueueOpen,
} = input;
const requestScanStop = useCallback((reason = "Stop angefordert.") => {
stopVisibleScanRef.current = true;
appendAutomationLog(`stop requested: ${reason}`);
setReviewStatus(`${reason} Der aktuelle Klick/Capture-Schritt wird noch sauber beendet.`);
}, [appendAutomationLog, setReviewStatus, stopVisibleScanRef]);
const reviewContext = useMemo(
() =>
createReviewContext({
artifactRepo,
reviewSamplesRepo,
learningRepo,
onStoredArtifactsChanged,
setReviewSampleTotal,
setReviewSamples,
setLearningRulesLoaded,
setScannerLearningRules,
setReviewStatus,
setStoredTotal,
appendAutomationLog,
}),
[
artifactRepo,
reviewSamplesRepo,
learningRepo,
onStoredArtifactsChanged,
setReviewSampleTotal,
setReviewSamples,
setLearningRulesLoaded,
setScannerLearningRules,
setReviewStatus,
setStoredTotal,
appendAutomationLog,
],
);
useEffect(() => {
void initializeLearningState(reviewContext);
}, [reviewContext]);
const focusDashboard = useCallback(async () => {
try {
await automationRepo?.focusMainWindow?.();
} catch {
// Best-effort fallback; state remains in renderer.
}
}, [automationRepo?.focusMainWindow]);
const parseArtifactAndPersist = useCallback(
async function parseArtifactAndPersist(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate,
source: string,
needsReview: boolean,
) {
return persistParsedArtifactHelper(capture, parsed, source, needsReview, reviewContext);
},
[reviewContext],
);
const handleSaveReviewSample = useCallback(
async function handleSaveReviewSample(
capture: CaptureResult | null = latestCapture,
parsed: ParsedArtifactCandidate | null = parsedArtifact,
reason = "manual",
): Promise<BooleanResult | null> {
const result = await saveReviewSampleHelper(
capture,
parsed,
reason,
scannerLearningRules,
reviewContext,
);
return result.result ? { ok: result.result.ok } : null;
},
[latestCapture, parsedArtifact, reviewContext, scannerLearningRules],
);
const scanActionContext = useMemo(
() =>
createScanActionContext({
autoScanRunning,
setAutoScanRunning,
isScanning,
stopVisibleScanRef,
selectedSourceId,
bridgeReady,
automationRepo,
runtimeInfo,
runtimeRepo,
scanLimit,
skipRows,
detectedInventoryCount,
setScanSummary,
setAutoScanStats,
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
parseArtifact,
persistParsedArtifact: parseArtifactAndPersist,
saveReviewSample: handleSaveReviewSample,
focusDashboard,
captureSelectedSource,
captureFastSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin, { skipOcr: true }),
}),
[
autoScanRunning,
setAutoScanRunning,
isScanning,
stopVisibleScanRef,
selectedSourceId,
bridgeReady,
automationRepo,
runtimeInfo,
runtimeRepo,
scanLimit,
skipRows,
detectedInventoryCount,
setScanSummary,
setAutoScanStats,
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
parseArtifact,
parseArtifactAndPersist,
handleSaveReviewSample,
focusDashboard,
captureSelectedSource,
],
);
const loadReviewQueueAction = useCallback(async () => {
await loadReviewQueueFromRepo(reviewContext);
}, [reviewContext]);
const openReviewQueueModal = useCallback(async () => {
await loadReviewQueueAction();
setReviewQueueOpen(true);
}, [loadReviewQueueAction, setReviewQueueOpen]);
const runAutoReviewScan = useCallback(async () => {
if (autoScanRunning || !selectedSourceId || !canCaptureSource) return;
await runAutoReviewScanAction(scanActionContext);
}, [autoScanRunning, canCaptureSource, selectedSourceId, scanActionContext]);
const runVisibleGridScan = useCallback(async () => {
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) {
return;
}
await runVisibleGridScanAction(scanActionContext);
}, [
autoScanRunning,
bridgeReady,
selectedSourceId,
automationRepo?.clickScreen,
automationRepo?.scrollScreen,
scanActionContext,
]);
useScanCommandListener({
automationRepo,
autoScanRunning,
isScanning,
selectedSourceId,
requestScanStop,
runVisibleGridScan,
});
return {
requestScanStop,
saveReviewSample: handleSaveReviewSample,
loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal,
runAutoReviewScan,
runVisibleGridScan,
};
}
@@ -0,0 +1,233 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { requiresAdminForAutomation } from "../../../lib/automationPlanner";
import {
countScannerLearningRules,
type ScannerLearningRules,
} from "../../../lib/scannerLearning";
import { type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import { analyzeReviewSamples } from "../../../lib/reviewSampleAnalysis";
import {
parseLearnedArtifact as parseLearnedArtifactHelper,
} from "./scanViewReviewHelpers";
import { useScanRuntimeInfo } from "./useScanRuntimeInfo";
import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher";
import { useScanViewActions } from "./useScanViewActions";
import { useScanViewStateSync } from "./useScanViewStateSync";
import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
import type { ScanViewProps, ScanViewControllerResult } from "../types";
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
export function useScanViewController({
snapshot,
isScanning,
captureSources,
selectedSourceId,
latestCapture,
captureStatus,
captureSelectedSource,
bridgeReady,
onStoredArtifactsChanged,
}: ScanViewProps): ScanViewControllerResult {
const repositories = useMemo(() => createRendererRepositories(), []);
const artifactRepo = repositories?.artifacts;
const runtimeRepo = repositories?.runtime;
const reviewSamplesRepo = repositories?.reviewSamples;
const learningRepo = repositories?.learning;
const snapshotRepo = repositories?.snapshot;
const automationRepo = repositories?.automation;
const captureRepo = repositories?.capture;
const [detailsOpen, setDetailsOpen] = useState(false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [reviewQueueOpen, setReviewQueueOpen] = useState(false);
const [reviewSamples, setReviewSamples] = useState<ReviewSampleRecord[]>([]);
const [reviewSampleTotal, setReviewSampleTotal] = useState(0);
const [reviewStatus, setReviewStatus] = useState("");
const [autoScanRunning, setAutoScanRunning] = useState(false);
const stopVisibleScanRef = useRef(false);
const [autoScanStats, setAutoScanStats] = useState<AutoScanStats>(emptyAutoScanStats);
const [scanSummary, setScanSummary] = useState<ScanSummary | null>(null);
const [scanLimit, setScanLimit] = useState(16);
const [scanLimitTouched, setScanLimitTouched] = useState(false);
const [skipRows, setSkipRows] = useState(0);
const [automationLog, setAutomationLog] = useState<string[]>([]);
const [storedTotal, setStoredTotal] = useState<number | null>(null);
const [devMode, setDevMode] = useState(() => localStorage.getItem("gaa-dev-mode") === "1");
const [scannerLearningRules, setScannerLearningRules] = useState<ScannerLearningRules>({ textReplacements: {} });
const [learningRulesLoaded, setLearningRulesLoaded] = useState(false);
const runtimeInfo = useScanRuntimeInfo(runtimeRepo);
const parsedArtifact = useMemo(
() => parseLearnedArtifactHelper(latestCapture, scannerLearningRules),
[latestCapture, scannerLearningRules],
);
const selectedSource = captureSources.find((source) => source.id === selectedSourceId);
const genshinSource = captureSources.find((source) => source.isGenshinCandidate);
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
const hasSourceSelected = selectedSourceId.length > 0;
const canCaptureSource = bridgeReady && Boolean(captureRepo?.captureSource);
const canReadReviewQueue = bridgeReady && Boolean(reviewSamplesRepo?.loadSamples);
const canSaveReviewSample = bridgeReady && Boolean(reviewSamplesRepo?.saveSample);
const canAutoScan = bridgeReady && Boolean(automationRepo?.clickScreen) && Boolean(automationRepo?.scrollScreen);
const reviewAnalysis = useMemo(() => analyzeReviewSamples(reviewSamples), [reviewSamples]);
const learningRuleCount = countScannerLearningRules(scannerLearningRules);
const detectedInventoryCount = latestCapture?.inventoryCount?.current ?? 0;
const activeTargetCount = autoScanRunning
? resolveScanTargetCount(scanLimit, detectedInventoryCount)
: scanSummary?.targetCount ?? resolveScanTargetCount(scanLimit, detectedInventoryCount);
const scanProgressBase = Math.max(autoScanStats.attempted, autoScanStats.parsed, autoScanStats.verified);
const scanProgressPercent = Math.min(100, Math.round((scanProgressBase / Math.max(1, activeTargetCount)) * 100));
const scannerModeLabel = autoScanRunning ? "Scan laeuft" : runtimeInfo?.isElevated ? "Admin bereit" : "Bereit";
const sourceLabel = selectedSource?.name ?? "Keine Quelle";
const gridLabel = latestCapture?.inventoryGrid ? `${latestCapture.inventoryGrid.cols} x ${latestCapture.inventoryGrid.rows}` : "warte auf Capture";
const inventoryLabel = latestCapture?.inventoryCount?.current ? `${latestCapture.inventoryCount.current}/${latestCapture.inventoryCount.total || "?"}` : "nicht erkannt";
const appendAutomationLog = useCallback((line: string) => {
setAutomationLog((previous) => [...previous.slice(-11), `${new Date().toLocaleTimeString()} ${line}`]);
}, []);
const appendClickDiagnostics = useCallback((result: ClickResult, prefix = "input") => {
const cursor = `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`;
const focus = result.focused ? `fg:${result.foregroundProcess || "Genshin"}` : `fg-miss:${result.foregroundProcess || "?"}`;
const blocked = result.inputBlocked ? " input:blocked" : "";
appendAutomationLog(`${prefix}: ${focus}${blocked} cursor ${cursor} moved:${result.moved ? "yes" : "no"} clicked:${result.clicked ? "yes" : "no"}`);
}, [appendAutomationLog]);
const toggleDevMode = useCallback(() => {
setDevMode((previous) => {
const next = !previous;
localStorage.setItem("gaa-dev-mode", next ? "1" : "0");
return next;
});
}, []);
const parseArtifact = useCallback(
(capture: CaptureResult | null) => parseLearnedArtifactHelper(capture, scannerLearningRules),
[scannerLearningRules],
);
const {
requestScanStop,
saveReviewSample: handleSaveReviewSample,
loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal,
runAutoReviewScan,
runVisibleGridScan,
} = useScanViewActions({
autoScanRunning,
setAutoScanRunning,
isScanning,
stopVisibleScanRef,
selectedSourceId,
bridgeReady,
automationRepo,
runtimeInfo,
runtimeRepo,
scanLimit,
skipRows,
detectedInventoryCount,
setScanSummary,
setAutoScanStats,
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
parseArtifact,
artifactRepo,
reviewSamplesRepo,
learningRepo,
onStoredArtifactsChanged,
setReviewSampleTotal,
setReviewSamples,
setLearningRulesLoaded,
setScannerLearningRules,
setStoredTotal,
scannerLearningRules,
captureSelectedSource,
latestCapture,
parsedArtifact,
canCaptureSource,
setReviewQueueOpen,
});
useScanViewStateSync({
artifactRepo,
latestCapture,
scanLimitTouched,
setScanLimit,
setStoredTotal,
});
useScanSnapshotPublisher({
autoScanRunning,
reviewStatus,
captureStatus,
selectedSourceName: selectedSource?.name ?? null,
autoScanStats,
scanSummary,
snapshot,
latestInventoryGrid: latestCapture?.inventoryGrid ?? null,
automationLog,
runtimeInfo,
storedTotal,
learningRuleCount,
snapshotRepo,
});
return {
detailsOpen,
diagnosticsOpen,
settingsOpen,
reviewQueueOpen,
reviewSamples,
reviewSampleTotal,
reviewStatus,
autoScanRunning,
autoScanStats,
scanSummary,
scanLimit,
scanLimitTouched,
skipRows,
automationLog,
storedTotal,
devMode,
scannerLearningRules,
learningRulesLoaded,
runtimeInfo,
parsedArtifact,
reviewAnalysis,
learningRuleCount,
detectedInventoryCount,
scanProgressPercent,
activeTargetCount,
requiresAdminForAutoScan,
hasSourceSelected,
canCaptureSource,
canReadReviewQueue,
canSaveReviewSample,
canAutoScan,
scannerModeLabel,
sourceLabel,
gridLabel,
inventoryLabel,
selectedSource,
genshinSource,
setDetailsOpen,
setDiagnosticsOpen,
setSettingsOpen,
setReviewQueueOpen,
setScanSummary,
setScanLimit,
setScanLimitTouched,
setSkipRows,
toggleDevMode,
requestScanStop,
saveReviewSample: handleSaveReviewSample,
loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal,
runAutoReviewScan,
runVisibleGridScan,
};
}
@@ -0,0 +1,35 @@
import { useEffect } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { CaptureResult } from "../../../types/global";
import type { ArtifactRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import { clampScanLimit } from "../../../lib/scannerSession";
interface ScanViewStateSyncInput {
artifactRepo?: ArtifactRepositoryPort;
latestCapture: CaptureResult | null;
scanLimitTouched: boolean;
setScanLimit: Dispatch<SetStateAction<number>>;
setStoredTotal: Dispatch<SetStateAction<number | null>>;
}
export function useScanViewStateSync({
artifactRepo,
latestCapture,
scanLimitTouched,
setScanLimit,
setStoredTotal,
}: ScanViewStateSyncInput) {
useEffect(() => {
const detectedCount = latestCapture?.inventoryCount?.current ?? 0;
if (!scanLimitTouched && detectedCount > 0) {
setScanLimit(clampScanLimit(detectedCount));
}
}, [latestCapture?.inventoryCount?.current, scanLimitTouched, setScanLimit]);
useEffect(() => {
artifactRepo?.loadAll().then((result) => {
if (result?.ok) setStoredTotal(result.total);
}).catch(() => undefined);
}, [artifactRepo, setStoredTotal]);
}
+82
View File
@@ -0,0 +1,82 @@
import type { AppSnapshot } from "../../types/domain";
import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, RuntimeInfo, ReviewSampleRecord } from "../../types/global";
import type { AutoScanStats, ScanSummary } from "../../lib/scannerSession";
import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser";
import type { ScannerLearningRules } from "../../lib/scannerLearning";
import type { Dispatch, SetStateAction } from "react";
import type { analyzeReviewSamples } from "../../lib/reviewSampleAnalysis";
export interface ScanViewProps {
snapshot: AppSnapshot;
isScanning: boolean;
captureSources: CaptureSourceInfo[];
selectedSourceId: string;
setSelectedSourceId: (value: string) => void;
latestCapture: CaptureResult | null;
captureStatus: string;
refreshCaptureSources: () => Promise<void>;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
bridgeReady: boolean;
onStoredArtifactsChanged?: () => Promise<void>;
}
export interface ScanViewControllerResult {
detailsOpen: boolean;
diagnosticsOpen: boolean;
settingsOpen: boolean;
reviewQueueOpen: boolean;
reviewSamples: ReviewSampleRecord[];
reviewSampleTotal: number;
reviewStatus: string;
autoScanRunning: boolean;
autoScanStats: AutoScanStats;
scanSummary: ScanSummary | null;
scanLimit: number;
scanLimitTouched: boolean;
skipRows: number;
automationLog: string[];
storedTotal: number | null;
devMode: boolean;
scannerLearningRules: ScannerLearningRules;
learningRulesLoaded: boolean;
runtimeInfo: RuntimeInfo | null;
parsedArtifact: ParsedArtifactCandidate | null;
reviewAnalysis: ReturnType<typeof analyzeReviewSamples>;
learningRuleCount: number;
detectedInventoryCount: number;
scanProgressPercent: number;
activeTargetCount: number;
requiresAdminForAutoScan: boolean;
hasSourceSelected: boolean;
canCaptureSource: boolean;
canReadReviewQueue: boolean;
canSaveReviewSample: boolean;
canAutoScan: boolean;
scannerModeLabel: string;
sourceLabel: string;
gridLabel: string;
inventoryLabel: string;
selectedSource: CaptureSourceInfo | undefined;
genshinSource: CaptureSourceInfo | undefined;
setDetailsOpen: Dispatch<SetStateAction<boolean>>;
setDiagnosticsOpen: Dispatch<SetStateAction<boolean>>;
setSettingsOpen: Dispatch<SetStateAction<boolean>>;
setReviewQueueOpen: Dispatch<SetStateAction<boolean>>;
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
setScanLimit: Dispatch<SetStateAction<number>>;
setScanLimitTouched: Dispatch<SetStateAction<boolean>>;
setSkipRows: Dispatch<SetStateAction<number>>;
toggleDevMode: () => void;
requestScanStop: (reason?: string) => void;
saveReviewSample: (
capture?: CaptureResult | null,
parsed?: ParsedArtifactCandidate | null,
reason?: string,
) => Promise<BooleanResult | null>;
loadReviewQueue: () => Promise<void>;
openReviewQueue: () => Promise<void>;
runAutoReviewScan: () => Promise<void>;
runVisibleGridScan: () => Promise<void>;
}
+61
View File
@@ -0,0 +1,61 @@
import type { ArtifactRowProps, TriageViewProps } from "./types";
import { useArtifactRowModel } from "./hooks/useArtifactRowModel";
import { useTriageViewModel } from "./hooks/useTriageViewModel";
function ArtifactRow({
artifact,
recommendation,
characters,
}: ArtifactRowProps) {
const { metaLabel, metaClassName, metaIcon, score, characterNames, artifactSummary, substatRows } = useArtifactRowModel({
recommendation,
artifact,
characters,
characterIds: recommendation?.bestCharacters,
});
return (
<div className="artifact-row">
<div>
<strong>{artifact.setName}</strong>
<span>{artifactSummary}</span>
</div>
<div className="substats">
{substatRows.map((substat) => (
<span key={`${artifact.id}-${substat}`}>{substat}</span>
))}
</div>
<div className={metaClassName}>{metaIcon}{metaLabel}</div>
<div className="reason">
<strong>{score}</strong>
<span>{characterNames}</span>
</div>
</div>
);
}
export function TriageView({ snapshot }: TriageViewProps) {
const { panelEyebrow, panelTitle, artifactCountLabel, recommendationByArtifact } = useTriageViewModel({ snapshot });
return (
<section className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">{panelEyebrow}</p>
<h2>{panelTitle}</h2>
</div>
<span className="muted">{artifactCountLabel}</span>
</div>
<div className="artifact-table">
{snapshot.artifacts.map((artifact) => (
<ArtifactRow
key={artifact.id}
artifact={artifact}
recommendation={recommendationByArtifact.get(artifact.id)}
characters={snapshot.characters}
/>
))}
</div>
</section>
);
}
@@ -0,0 +1,43 @@
import { verdictMeta } from "../../common/verdictMeta";
import type { Artifact, Character, Recommendation } from "../../../types/domain";
import type { ReactNode } from "react";
export interface ArtifactRowModel {
metaLabel: string;
metaClassName: string;
metaIcon: ReactNode;
score: number;
characterNames: string;
artifactSummary: string;
substatRows: string[];
}
interface UseArtifactRowModelInput {
artifact: Artifact;
recommendation?: Recommendation;
characters: Character[];
characterIds?: string[];
}
export function useArtifactRowModel({
recommendation,
characters,
artifact,
characterIds,
}: UseArtifactRowModelInput): ArtifactRowModel {
const meta = verdictMeta[recommendation?.verdict ?? "needs_review"];
const characterNames = !characterIds || characterIds.length === 0
? "Review first"
: characterIds.map((id) => characters.find((character) => character.id === id)?.name ?? id).join(", ");
const score = recommendation ? Math.round(recommendation.score) : 0;
return {
metaLabel: meta.label,
metaClassName: meta.className,
metaIcon: meta.icon,
score,
characterNames,
artifactSummary: `${artifact.slot} - +${artifact.level} - ${artifact.mainStat}${artifact.equipped ? ` - ${artifact.equipped}` : ""}`,
substatRows: artifact.substats.map((substat) => `${substat.key} ${substat.value}${substat.unit === "%" ? "%" : ""}`),
};
}
@@ -0,0 +1,27 @@
import type { AppSnapshot, Recommendation } from "../../../types/domain";
import type { Artifact, Character } from "../../../types/domain";
export interface TriageViewModel {
artifactCount: number;
panelEyebrow: string;
panelTitle: string;
artifactCountLabel: string;
recommendationByArtifact: Map<string, Recommendation>;
}
interface UseTriageViewModelInput {
snapshot: AppSnapshot;
}
export function useTriageViewModel({ snapshot }: UseTriageViewModelInput): TriageViewModel {
const recommendationByArtifact = new Map(snapshot.recommendations.map((entry) => [entry.artifactId, entry]));
const artifactCount = snapshot.artifacts.length;
return {
artifactCount,
panelEyebrow: "Artifact triage",
panelTitle: "No-brainer decisions",
artifactCountLabel: `${artifactCount} scanned pieces`,
recommendationByArtifact,
};
}
+11
View File
@@ -0,0 +1,11 @@
import type { AppSnapshot, Artifact, Character, Recommendation } from "../../types/domain";
export interface TriageViewProps {
snapshot: AppSnapshot;
}
export interface ArtifactRowProps {
artifact: Artifact;
recommendation?: Recommendation;
characters: Character[];
}