// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung // und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig. import { startCamera, grabFrame, imageDataFromFile } from './camera.js'; import { decodeBarcodes } from './barcode.js'; import { runOcr, isOcrAvailable } from './ocr.js'; import { recognize } from './pipeline.js'; import { createSession, proposeAssignment, commitAssignment, undoLast, moveEntry, removeEntry, } from './session.js'; import { saveSession, loadSession, clearSession } from './storage.js'; import { renderScanView } from './ui/scan-view.js'; import { showResult } from './ui/result-overlay.js'; import { askForStack } from './ui/ambiguous-dialog.js'; import { renderSessionList } from './ui/session-list.js'; const app = document.querySelector('#app'); const store = window.localStorage; // loadSession liefert entweder eine in sich stimmige Sitzung oder null - nie // einen halb brauchbaren Zustand (siehe storage.js). Eine gefundene Sitzung // nach einem Neuladen hat Vorrang vor einer neuen. let session = loadSession(store) ?? createSession(); // Sperrt die Erfassung, waehrend eine Erkennung laeuft. Wird in processCapture() // im finally-Block in jedem Ausgang wieder aufgehoben - auch wenn der Nutzer // im Rot-Dialog "nochmal scannen" waehlt und die Funktion damit vorzeitig // verlassen wird (der fruehe return liegt innerhalb des try-Blocks). let busy = false; const view = renderScanView(app, { onCapture: () => processCapture(() => grabFrame(view.video)), onUndo: () => { const entry = undoLast(session); view.setLast(entry ? `zurueckgenommen: Stapel ${entry.stackId}` : 'nichts zurueckzunehmen'); syncView(); }, onOpenList: openSessionList, onPickFile: (file) => processCapture(() => imageDataFromFile(file)), }); /** Spiegelt den Sitzungsstand in die Oberflaeche und sichert ihn gegen Neuladen ab. */ function syncView() { view.setStacks(session.stacks); saveSession(session, store); } /** Kurzbeschreibung fuer die "Zuletzt"-Zeile. */ function describeEntry(spec, stackId) { const parts = [ spec.capacityGb ? `${spec.capacityGb}GB` : '?', spec.speed ?? '?', spec.formFactor ?? '', ].filter(Boolean); return `${parts.join(' ')} → Stapel ${stackId}`; } /** Naechster noch nicht vergebener Stapel-Buchstabe - fuer den Fall, dass der Nutzer im Rot-Dialog ausdruecklich einen neuen Stapel waehlt. */ function nextFreeStackId() { const used = new Set(session.stacks.map((stack) => stack.id)); for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') { if (!used.has(letter)) return letter; } // Nach Z weiter mit A2, B2, ... - in der Praxis nie erreicht (siehe session.js). return `A${session.stacks.length}`; } /** * Ein Scan-Durchlauf: Bild beschaffen, erkennen, bei unsicherer Erkennung * nachfragen, buchen, kurz rueckmelden. Laeuft ohne Bestaetigung durch, * solange die Erkennung eindeutig ist - haelt nur bei roter Konfidenz oder * mehrdeutiger Stapelzuordnung an. */ async function processCapture(getFrame) { if (busy) return; busy = true; view.setStatus('erkenne …'); let failed = false; try { const frame = await getFrame(); const { spec, source, confidence } = await recognize(frame, { decodeBarcodes, runOcr }); const plan = proposeAssignment(session, spec); let stackId = plan.stackId; if (confidence === 'red' || plan.kind === 'ambiguous') { const answer = await askForStack(app, { spec, candidates: plan.kind === 'ambiguous' ? plan.candidates : session.stacks.map((stack) => stack.id), }); // "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt // innerhalb des try - der finally-Block hebt die Sperre trotzdem auf. if (answer.action === 'retry') return; if (answer.action === 'stack') { // Nutzer hat einen bestehenden Stapel gewaehlt: genau dieser wird genommen. stackId = answer.stackId; } else { // "neuer Stapel": unabhaengig von jeder Vermutung der naechste freie Buchstabe. stackId = nextFreeStackId(); } } const entry = commitAssignment(session, spec, source, stackId); view.setLast(describeEntry(spec, entry.stackId)); syncView(); // Nach einer manuellen Entscheidung im Rot-Dialog gibt es nichts mehr // rueckzumelden - der Dialog selbst war die Rueckmeldung. if (confidence !== 'red') { await showResult(app, { stackId: entry.stackId, spec, confidence }); } } catch (error) { failed = true; view.setStatus(`Fehler: ${error.message}`, true); } finally { busy = false; // Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer // noch lesen muss, nicht ueberschreiben. if (!failed) { const available = isOcrAvailable(); view.setStatus( available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen', !available, ); } } } /** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */ function closeOverlays() { app.querySelectorAll('.overlay').forEach((element) => element.remove()); } /** * Oeffnet die Sitzungsliste. Nach einer Aenderung (Umsortieren, Entfernen) * wird die aktuelle Liste zuerst geschlossen und dann frisch neu aufgebaut, * damit nach dem Entfernen eines Eintrags nie zwei Listen uebereinanderliegen * und Stapel-Kopfzeilen sowie Zaehler stets aktuell bleiben. */ function openSessionList() { renderSessionList(app, session, { onMove: (entryId, stackId) => { moveEntry(session, entryId, stackId); syncView(); closeOverlays(); openSessionList(); }, onRemove: (entryId) => { removeEntry(session, entryId); syncView(); closeOverlays(); openSessionList(); }, onEndSession: () => { session = createSession(); clearSession(store); view.setLast('Sitzung beendet'); syncView(); }, onClose: () => {}, }); } startCamera(view.video).catch((error) => { view.setStatus(`Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`, true); view.openFilePicker(); }); syncView();