// 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, nextStackId, } 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 { askResumeSession } from './ui/resume-dialog.js'; import { renderSessionList } from './ui/session-list.js'; import { describeSpec } from './ui/describe-spec.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 // wird nicht stillschweigend uebernommen, sondern dem Nutzer zur Fortsetzung // angeboten (siehe initResumeOffer() weiter unten). const restoredSession = loadSession(store); let session = 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; // Kamera nicht verfuegbar: der Hinweis darauf muss dauerhaft sichtbar // bleiben und darf nicht von der OCR-Verfuegbarkeitsanzeige am Ende jedes // Scan-Durchlaufs ueberschrieben werden. let cameraUnavailableMessage = null; const view = renderScanView(app, { onCapture: () => processCapture(() => grabFrame(view.video)), onUndo: () => { // Waehrend eine Erkennung laeuft, darf kein Eintrag zurueckgenommen // werden - sonst koennte die Rueckgaengig-Flaeche einen Eintrag treffen, // der gerade erst durch die laufende Erkennung entstehen wird. if (busy) return; const entry = undoLast(session); view.setLast(entry ? `zurueckgenommen: Stapel ${entry.stackId}` : 'nichts zurueckzunehmen'); syncView(); }, onOpenList: () => { // Waehrend eine Erkennung laeuft, bleibt die Sitzungsliste (und damit // auch "Sitzung beenden") unerreichbar - siehe processCapture(). if (busy) return; 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) { return `${describeSpec(spec)} → Stapel ${stackId}`; } /** * 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; // Die Sitzung, gegen die dieser Durchlauf arbeitet, wird hier fest // gehalten. Wird "Sitzung beenden" ausgeloest, waehrend diese Erkennung // noch laeuft (bis zu 20 Sekunden bei OCR), zeigt das Modul-level `session` // danach auf eine neue, leere Sitzung - der Vergleich am Ende dieser // Funktion verhindert, dass das verspaetete Ergebnis dort noch gebucht wird. const targetSession = session; view.setStatus('erkenne …'); let failed = false; try { const frame = await getFrame(); const { spec, source, confidence } = await recognize(frame, { decodeBarcodes, runOcr }); const plan = proposeAssignment(targetSession, spec); let stackId = plan.stackId; if (confidence === 'red' || plan.kind === 'ambiguous') { const answer = await askForStack(app, { spec, candidates: plan.kind === 'ambiguous' ? plan.candidates : targetSession.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 = nextStackId(targetSession); } } // Siehe Kommentar zu targetSession oben: eine zwischenzeitlich beendete // Sitzung bucht das Ergebnis nicht mehr nach. if (targetSession !== session) return; const entry = commitAssignment(targetSession, 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. Der Hinweis auf eine nicht // verfuegbare Kamera hat Vorrang vor beidem und bleibt dauerhaft stehen. if (!failed) { if (cameraUnavailableMessage) { view.setStatus(cameraUnavailableMessage, true); } else { 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. Raeumt zuerst jede bereits offene Liste weg - * unabhaengig davon, ob dieser Aufruf von aussen (Stapel-Leiste) oder aus * einem eigenen Rueckruf (Umsortieren, Entfernen) kommt -, damit nie zwei * Listen uebereinanderliegen, etwa bei einem Doppeltipp auf die * Stapel-Schaltflaeche. Nach einer Aenderung wird die Liste anschliessend * frisch neu aufgebaut, damit Stapel-Kopfzeilen sowie Zaehler stets aktuell * bleiben. */ function openSessionList() { closeOverlays(); renderSessionList(app, session, { onMove: (entryId, stackId) => { moveEntry(session, entryId, stackId); syncView(); openSessionList(); }, onRemove: (entryId) => { removeEntry(session, entryId); syncView(); openSessionList(); }, onEndSession: () => { session = createSession(); clearSession(store); view.setLast('Sitzung beendet'); view.setStacks(session.stacks); }, onClose: () => {}, }); } startCamera(view.video).catch((error) => { // Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy- // Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg // ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar. cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`; view.setStatus(cameraUnavailableMessage, true); view.setFilePickerVisible(true); }); /** * Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an, * statt sie stillschweigend zu uebernehmen. Wird fortgesetzt, zeigt die * "Zuletzt"-Zeile den tatsaechlich letzten Eintrag, damit die Rueckgaengig- * Flaeche das tut, was daneben steht. Wird verworfen, wird der gesicherte * Stand geloescht. */ async function initResumeOffer() { if (restoredSession && restoredSession.entries.length > 0) { const answer = await askResumeSession(app, { entryCount: restoredSession.entries.length }); if (answer.action === 'resume') { session = restoredSession; const last = session.entries[session.entries.length - 1]; view.setLast(describeEntry(last.spec, last.stackId)); } else { clearSession(store); } } syncView(); } initResumeOffer();