diff --git a/src/main.js b/src/main.js index b6682bb..ed6b0b6 100644 --- a/src/main.js +++ b/src/main.js @@ -1,19 +1,174 @@ +// 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 { startCamera } from './camera.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: async () => { - const answer = await askForStack(app, { - spec: { capacityGb: 64, rank: null, speed: null, formFactor: null, partNumber: null }, - candidates: ['A', 'B'], - }); - view.setLast(`Antwort: ${JSON.stringify(answer)}`); + onCapture: () => processCapture(() => grabFrame(view.video)), + onUndo: () => { + const entry = undoLast(session); + view.setLast(entry ? `zurueckgenommen: Stapel ${entry.stackId}` : 'nichts zurueckzunehmen'); + syncView(); }, - onUndo: () => view.setLast('zurueckgenommen'), - onOpenList: () => {}, - onPickFile: () => {}, + onOpenList: openSessionList, + onPickFile: (file) => processCapture(() => imageDataFromFile(file)), }); -view.setStacks([{ id: 'A', count: 12 }, { id: 'B', count: 4 }]); -startCamera(view.video).catch(() => view.setStatus('Kamera nicht verfuegbar', true)); + +/** 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(); diff --git a/src/ui/session-list.js b/src/ui/session-list.js new file mode 100644 index 0000000..25a3e71 --- /dev/null +++ b/src/ui/session-list.js @@ -0,0 +1,102 @@ +// Rein darstellend: Vollbild-Liste aller Stapel der laufenden Sitzung, zum +// Umsortieren und Entfernen einzelner Eintraege sowie zum Beenden der +// Sitzung. Kennt weder Kamera noch Erkennung - alles kommt ueber die +// Rueckrufe, genau wie bei scan-view.js und ambiguous-dialog.js. + +/** Kurzbeschreibung eines Specs fuer Stapel-Kopfzeilen und Eintrags-Zeilen ohne Teilenummer. */ +function describeSpec(spec) { + const parts = [ + spec.capacityGb ? `${spec.capacityGb}GB` : '?', + spec.rank ?? '?', + spec.speed ?? '?', + spec.formFactor ?? '?', + ]; + return parts.join(' '); +} + +/** + * Vollbild-Liste aller Stapel der laufenden Sitzung. + * @param {HTMLElement} root + * @param {{stacks: Array<{id: string, count: number, spec: object}>, entries: Array<{entryId: number, spec: object, stackId: string}>}} session + * @param {{onMove: (entryId: number, stackId: string) => void, onRemove: (entryId: number) => void, onEndSession: () => void, onClose: () => void}} callbacks + */ +export function renderSessionList(root, session, { onMove, onRemove, onEndSession, onClose }) { + const overlay = document.createElement('div'); + overlay.className = 'overlay'; + overlay.style.background = 'var(--bg)'; + overlay.style.justifyContent = 'flex-start'; + overlay.style.overflowY = 'auto'; + overlay.innerHTML = ` +