diff --git a/api/index.php b/api/index.php index 2c6f4e4..544facb 100644 --- a/api/index.php +++ b/api/index.php @@ -121,6 +121,14 @@ try { getServerSettings(); break; + case 'client_log': + clientLog(); + break; + + case 'get_client_log': + getClientLog(); + break; + // ===== SPESA ONLINE ===== case 'dupliclick_login': dupliclickLogin(); @@ -149,6 +157,48 @@ try { echo json_encode(['error' => $e->getMessage()]); } +// ===== CLIENT LOG ===== + +function clientLog(): void { + $input = json_decode(file_get_contents('php://input'), true); + $logFile = __DIR__ . '/../data/client_debug.log'; + $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'; + // Identify device from UA + $device = 'unknown'; + if (preg_match('/tablet|ipad|playbook|silk/i', $ua)) $device = 'tablet'; + elseif (preg_match('/mobile|android|iphone/i', $ua)) $device = 'phone'; + else $device = 'desktop'; + $ts = date('Y-m-d H:i:s'); + $msgs = $input['messages'] ?? []; + $lines = []; + foreach ($msgs as $m) { + $lines[] = "[$ts] [$device] $m"; + } + if ($lines) { + // Keep log under 100KB — truncate oldest if needed + if (file_exists($logFile) && filesize($logFile) > 100000) { + $existing = file($logFile); + $existing = array_slice($existing, -200); + file_put_contents($logFile, implode('', $existing)); + } + file_put_contents($logFile, implode("\n", $lines) . "\n", FILE_APPEND | LOCK_EX); + } + echo json_encode(['ok' => true]); +} + +function getClientLog(): void { + $logFile = __DIR__ . '/../data/client_debug.log'; + $lines = 100; + if (isset($_GET['lines'])) $lines = min(500, max(1, (int)$_GET['lines'])); + if (!file_exists($logFile)) { + echo json_encode(['log' => '(empty)', 'lines' => 0]); + return; + } + $all = file($logFile); + $tail = array_slice($all, -$lines); + echo json_encode(['log' => implode('', $tail), 'lines' => count($tail), 'total' => count($all)]); +} + // ===== PRODUCT FUNCTIONS ===== function searchBarcode(PDO $db): void { @@ -1055,6 +1105,7 @@ function generateRecipe(PDO $db): void { $options = $input['options'] ?? []; $appliances = $input['appliances'] ?? []; $dietaryRestrictions = $input['dietary_restrictions'] ?? ''; + $todayRecipes = $input['today_recipes'] ?? []; // Fetch all inventory items with expiry info $stmt = $db->query(" @@ -1146,9 +1197,16 @@ function generateRecipe(PDO $db): void { $dietaryText = "\n\nRESTRIZIONI ALIMENTARI:\n{$dietaryRestrictions}\nRispetta SEMPRE queste restrizioni."; } + // Today's previous recipes - avoid repetition + $todayText = ''; + if (!empty($todayRecipes)) { + $todayList = implode(', ', array_map(function($t) { return '"' . $t . '"'; }, $todayRecipes)); + $todayText = "\n\nRICETTE GIÀ PREPARATE OGGI:\n{$todayList}\nNON proporre una ricetta simile o con lo stesso concetto di quelle già fatte oggi. Varia il tipo di piatto, gli ingredienti principali e lo stile di cucina. Ad esempio se a pranzo c'era una piadina, a cena proponi pasta, riso, zuppa o altro — MAI un'altra piadina o wrap o piatto concettualmente simile."; + } + $prompt = << $query, - 'page' => 1, - 'order_by' => 'search_score desc' - ]); - - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 15, - CURLOPT_HTTPHEADER => $baseHeaders, - CURLOPT_SSL_VERIFYPEER => true, - ]); - - $response = curl_exec($ch); - if (curl_errno($ch)) { - echo json_encode(['error' => 'Errore connessione DupliClick: ' . curl_error($ch)]); - curl_close($ch); + // Search catalog by item name only first + $searchResults = dupliclickCatalogSearch($query, $baseHeaders); + if ($searchResults === null) { + echo json_encode(['error' => 'Errore nella ricerca']); return; } - curl_close($ch); - - $data = json_decode(trim($response), true); - if (!$data || ($data['response']['status'] ?? -1) !== 0) { - echo json_encode(['error' => 'Errore nella ricerca', 'details' => $data['response'] ?? null]); - return; - } - - $products = $data['data']['products'] ?? []; + + $products = $searchResults['products']; + $total = $searchResults['total']; + if (empty($products)) { - echo json_encode(['success' => true, 'query' => $query, 'product' => null, 'total' => 0]); - return; + // Fallback: try searching with spec keywords appended + $specKeywords = dupliclickExtractSpecKeywords($spec); + if ($specKeywords) { + $searchResults = dupliclickCatalogSearch($query . ' ' . $specKeywords, $baseHeaders); + if ($searchResults && !empty($searchResults['products'])) { + $products = $searchResults['products']; + $total = $searchResults['total']; + } + } + if (empty($products)) { + echo json_encode(['success' => true, 'query' => $query, 'product' => null, 'total' => 0]); + return; + } } // Format top 10 products $topProducts = array_slice($products, 0, 10); $formatted = array_map('formatDupliclickProduct', $topProducts); - $total = $data['data']['page']['totItems'] ?? 0; // If multiple results, use AI to pick the best match $bestProduct = $formatted[0]; @@ -2246,6 +2294,22 @@ function dupliclickSearch(): void { if ($aiResult !== null) { $bestProduct = $aiResult; $aiUsed = true; + } elseif ($aiResult === null && !empty($spec)) { + // AI said no match — try refined search with spec keywords + $specKeywords = dupliclickExtractSpecKeywords($spec); + if ($specKeywords) { + $refined = dupliclickCatalogSearch($query . ' ' . $specKeywords, $baseHeaders); + if ($refined && !empty($refined['products'])) { + $refinedFormatted = array_map('formatDupliclickProduct', array_slice($refined['products'], 0, 10)); + $aiResult2 = aiSelectBestProduct($query, $spec, $refinedFormatted, $aiPrompt); + if ($aiResult2 !== null) { + $bestProduct = $aiResult2; + $aiUsed = true; + } else { + $bestProduct = $refinedFormatted[0]; + } + } + } } } @@ -2258,6 +2322,59 @@ function dupliclickSearch(): void { ]); } +/** + * Search DupliClick catalog and return raw products array + */ +function dupliclickCatalogSearch(string $query, array $headers): ?array { + $url = 'https://www.dupliclick.it/ebsn/api/products?' . http_build_query([ + 'q' => $query, + 'page' => 1, + 'order_by' => 'search_score desc' + ]); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $response = curl_exec($ch); + if (curl_errno($ch)) { curl_close($ch); return null; } + curl_close($ch); + + $data = json_decode(trim($response), true); + if (!$data || ($data['response']['status'] ?? -1) !== 0) return null; + + return [ + 'products' => $data['data']['products'] ?? [], + 'total' => $data['data']['page']['totItems'] ?? 0, + ]; +} + +/** + * Extract meaningful product keywords from a Bring specification string, + * stripping quantities, emojis, and noise words. + */ +function dupliclickExtractSpecKeywords(string $spec): string { + if (empty($spec)) return ''; + // Remove priority emojis + $clean = preg_replace('/[\x{1F534}\x{1F7E1}\x{1F7E2}]/u', '', $spec); + // Remove quantities (150g, 500ml, 2x, 1 flacone, etc.) + $clean = preg_replace('/\d+\s*(g|kg|ml|l|pz|pezzi|conf|flacon[ei]|x)\b/i', '', $clean); + $clean = preg_replace('/\d+x\d*/i', '', $clean); + // Remove standalone numbers + $clean = preg_replace('/\b\d+\b/', '', $clean); + // Remove noise words + $noise = ['senza', 'con', 'più', 'meno', 'circa', 'tipo', 'lidl', 'coop', 'conad', 'esselunga']; + $clean = preg_replace('/\b(' . implode('|', $noise) . ')\b/i', '', $clean); + // Remove commas and extra spaces + $clean = preg_replace('/[,+]/', ' ', $clean); + $clean = preg_replace('/\s+/', ' ', trim($clean)); + return $clean; +} + /** * Use Gemini AI to pick the best product from search results */ @@ -2266,17 +2383,21 @@ function aiSelectBestProduct(string $itemName, string $spec, array $products, st $apiKey = $env['GEMINI_API_KEY'] ?? ''; if (empty($apiKey)) return null; - $defaultPrompt = "Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare e una lista di prodotti trovati nel catalogo del supermercato. + $defaultPrompt = "Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare (con eventuale descrizione tra parentesi) e una lista di prodotti trovati nel catalogo del supermercato. Regole di selezione: - Scegli il prodotto che corrisponde ESATTAMENTE a quello richiesto (stessa categoria merceologica) +- La DESCRIZIONE tra parentesi è FONDAMENTALE: se l'utente cerca \"Pancetta (a cubetti)\", DEVI trovare pancetta A CUBETTI, non pancetta generica +- Se la descrizione include un tipo specifico (\"a cubetti\", \"a fette\", \"biologico\", \"cotto\", \"a pasta dura\"), il prodotto DEVE contenere quella caratteristica nel nome - Preferisci prodotti freschi/sfusi rispetto a trasformati (es. \"Arance\" = arance frutta, NON aranciata bevanda) -- Se c'è una descrizione (es. \"a cubetti\", \"biologico\"), trova il prodotto che include quella caratteristica - Se ci sono più varianti valide, scegli quella con il miglior rapporto qualità/prezzo - Preferisci formati standard per una famiglia -- NON scegliere mai un prodotto di categoria diversa (bevanda vs frutta, surgelato vs fresco, condimento vs ortaggio, ecc.) +- NON scegliere mai un prodotto di categoria diversa (bevanda vs frutta, surgelato vs fresco, condimento vs ortaggio, pasta ripiena vs formaggio, ecc.) - \"Finocchio\" = ortaggio fresco, NON semi di finocchio o tisana - \"Arance\" = frutta fresca, NON aranciata o succo +- \"Formaggio\" = formaggio intero/pezzo, NON prodotti che contengono formaggio come ingrediente (ravioli, sfogliavelo, ecc.) +- \"Detergente intimo\" = detergente per igiene intima, NON detersivo generico +- Rispondi -1 se NESSUN prodotto corrisponde ragionevolmente alla richiesta Rispondi SOLO con il numero (indice 0-based) del prodotto migliore, oppure -1 se nessun prodotto è appropriato."; diff --git a/assets/css/style.css b/assets/css/style.css index 94233c0..f3f9f27 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -863,6 +863,22 @@ body { background: var(--danger); box-shadow: 0 0 10px var(--danger); animation: scanLine 2s ease-in-out infinite; + transition: background 0.2s, box-shadow 0.2s, height 0.2s; +} + +/* While Quagga is actively scanning frames */ +.scanner-line.scanning { + background: #00c853; + box-shadow: 0 0 12px #00c853; + animation: scanLineActive 0.8s ease-in-out infinite; +} + +/* Barcode partially detected — strong pulse */ +.scanner-line.detecting { + background: #ffd600; + box-shadow: 0 0 20px #ffd600, 0 0 40px rgba(255,214,0,0.4); + height: 5px; + animation: scanLineDetect 0.3s ease-in-out infinite; } @keyframes scanLine { @@ -870,6 +886,16 @@ body { 50% { opacity: 0.3; } } +@keyframes scanLineActive { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes scanLineDetect { + 0%, 100% { opacity: 1; transform: scaleY(1); } + 50% { opacity: 0.7; transform: scaleY(2); } +} + .scan-result { background: var(--bg-card); border-radius: var(--radius); diff --git a/assets/js/app.js b/assets/js/app.js index ce6e705..01efeaa 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -306,6 +306,43 @@ let scannerStream = null; let quaggaRunning = false; let aiStream = null; +// ===== CAMERA HELPER ===== +function getCameraConstraints(extraVideo = {}) { + const s = getSettings(); + const mode = s.camera_facing || 'environment'; + // Front cameras on older devices often have lower resolution — don't over-request + const isFront = (mode === 'user'); + const videoConstraints = { + width: { ideal: isFront ? 640 : 1280 }, + height: { ideal: isFront ? 480 : 720 }, + ...extraVideo + }; + if (mode === 'environment' || mode === 'user') { + videoConstraints.facingMode = mode; + } else { + // Specific deviceId selected + videoConstraints.deviceId = { exact: mode }; + } + return { video: videoConstraints }; +} + +function isFrontCamera() { + const s = getSettings(); + return (s.camera_facing || 'environment') === 'user'; +} + +async function enumerateCameras() { + try { + // Need a temporary stream to get device labels + const tempStream = await navigator.mediaDevices.getUserMedia({ video: true }); + const devices = await navigator.mediaDevices.enumerateDevices(); + tempStream.getTracks().forEach(t => t.stop()); + return devices.filter(d => d.kind === 'videoinput'); + } catch(e) { + return []; + } +} + // ===== SETTINGS / CONFIG ===== function getSettings() { try { @@ -340,6 +377,10 @@ async function loadSettingsUI() { document.getElementById('setting-pref-comfort').checked = !!s.pref_comfort; document.getElementById('setting-pref-zerowaste').checked = !!s.pref_zerowaste; document.getElementById('setting-dietary').value = s.dietary || ''; + // Camera + const cameraSelect = document.getElementById('setting-camera-facing'); + if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment'; + loadCameraDevices(); renderAppliances(s.appliances || []); loadSpesaSettings(); @@ -369,6 +410,23 @@ function renderAppliances(appliances) { `).join(''); } +async function loadCameraDevices() { + const select = document.getElementById('setting-camera-facing'); + if (!select) return; + const s = getSettings(); + const current = s.camera_facing || 'environment'; + // Remove old device-specific options (keep first 2: environment, user) + while (select.options.length > 2) select.remove(2); + const cameras = await enumerateCameras(); + cameras.forEach(cam => { + const opt = document.createElement('option'); + opt.value = cam.deviceId; + opt.textContent = cam.label || `Camera ${cam.deviceId.slice(0, 8)}…`; + select.appendChild(opt); + }); + select.value = current; +} + function addAppliance() { const input = document.getElementById('new-appliance-input'); const name = (input.value || '').trim(); @@ -420,6 +478,8 @@ async function saveSettings() { s.pref_comfort = document.getElementById('setting-pref-comfort').checked; s.pref_zerowaste = document.getElementById('setting-pref-zerowaste').checked; s.dietary = document.getElementById('setting-dietary').value.trim(); + // Camera + s.camera_facing = document.getElementById('setting-camera-facing').value; // Save spesa AI prompt if the field exists const spesaPromptEl = document.getElementById('setting-spesa-ai-prompt'); if (spesaPromptEl) s.spesa_ai_prompt = spesaPromptEl.value.trim(); @@ -1162,31 +1222,80 @@ async function submitEditInventory(e, id, productId) { refreshCurrentPage(); } +// ===== SCAN DEBUG LOG ===== +let _scanDebugVisible = false; +let _scanLogBuffer = []; +let _scanLogTimer = null; + +function scanLog(msg) { + const el = document.getElementById('scan-debug-log'); + if (el) { + const ts = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:1}); + el.textContent += `[${ts}] ${msg}\n`; + el.scrollTop = el.scrollHeight; + } + console.log('[ScanDebug]', msg); + // Buffer for remote send + _scanLogBuffer.push(msg); + if (!_scanLogTimer) { + _scanLogTimer = setTimeout(flushScanLog, 2000); + } +} + +function flushScanLog() { + _scanLogTimer = null; + if (_scanLogBuffer.length === 0) return; + const msgs = _scanLogBuffer.splice(0); + fetch(`${API_BASE}?action=client_log`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: msgs }) + }).catch(() => {}); +} + +function toggleScanDebug() { + const el = document.getElementById('scan-debug-log'); + if (!el) return; + _scanDebugVisible = !_scanDebugVisible; + el.style.display = _scanDebugVisible ? 'block' : 'none'; +} + // ===== BARCODE SCANNER ===== +let _useBarcodeDetector = ('BarcodeDetector' in window); + async function initScanner() { const video = document.getElementById('scanner-video'); const viewport = document.getElementById('scanner-viewport'); + const logEl = document.getElementById('scan-debug-log'); + if (logEl) logEl.textContent = ''; + + const constraints = getCameraConstraints(); + scanLog(`Camera mode: ${getSettings().camera_facing || 'environment'}`); + scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (Quagga fallback)'}`); + scanLog(`Constraints: ${JSON.stringify(constraints.video)}`); try { - // Stop any existing stream stopScanner(); - const stream = await navigator.mediaDevices.getUserMedia({ - video: { - facingMode: 'environment', - width: { ideal: 1280 }, - height: { ideal: 720 } - } - }); + const stream = await navigator.mediaDevices.getUserMedia(constraints); + const track = stream.getVideoTracks()[0]; + const caps = track.getSettings ? track.getSettings() : {}; + scanLog(`Stream OK — track: ${track.label}`); + scanLog(`Resolution: ${caps.width||'?'}x${caps.height||'?'}, facing: ${caps.facingMode||'N/A'}`); scannerStream = stream; video.srcObject = stream; await video.play(); + scanLog(`Video playing — videoWidth: ${video.videoWidth}, videoHeight: ${video.videoHeight}`); - // Start Quagga for barcode detection - startQuagga(video); + if (_useBarcodeDetector) { + startNativeScanner(video); + } else { + startQuaggaScanner(video); + } } catch (err) { + scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`); console.error('Camera error:', err); document.getElementById('scan-result').style.display = 'block'; document.getElementById('scan-result').innerHTML = ` @@ -1199,29 +1308,165 @@ async function initScanner() { } } -function startQuagga(videoEl) { +// ===== NATIVE BarcodeDetector SCANNER ===== +async function startNativeScanner(videoEl) { + if (quaggaRunning) return; + + const scannerLine = document.querySelector('.scanner-line'); + const detector = new BarcodeDetector({ + formats: ['ean_13', 'ean_8', 'code_128', 'code_39', 'upc_a', 'upc_e'] + }); + + let scanning = true; + quaggaRunning = true; + let frameCount = 0; + let partialCount = 0; + let lastDetected = ''; + let detectCount = 0; + let detectionHistory = {}; + + scanLog('Native BarcodeDetector started'); + + function updateFeedback(state) { + if (!scannerLine) return; + scannerLine.classList.remove('scanning', 'detecting'); + if (state) scannerLine.classList.add(state); + } + + async function scanFrame() { + if (!scanning || !scannerStream) return; + frameCount++; + + if (frameCount === 1) updateFeedback('scanning'); + + try { + const barcodes = await detector.detect(videoEl); + + if (barcodes.length > 0) { + const code = barcodes[0].rawValue; + const format = barcodes[0].format; + partialCount++; + scanLog(`Native detect #${partialCount} [f${frameCount}]: ${code} (${format})`); + updateFeedback('detecting'); + + if (!detectionHistory[code]) detectionHistory[code] = { count: 0 }; + detectionHistory[code].count++; + + if (code === lastDetected) { + detectCount++; + } else { + lastDetected = code; + detectCount = 1; + } + + if (detectCount >= 2 || detectionHistory[code].count >= 2) { + scanning = false; + quaggaRunning = false; + updateFeedback(null); + scanLog(`CONFIRMED: ${code} after ${frameCount} frames`); + onBarcodeDetected(code); + return; + } + } else { + updateFeedback('scanning'); + } + } catch (e) { + scanLog(`Native detect error: ${e.message}`); + } + + if (scanning) { + if (frameCount % 30 === 0) { + scanLog(`Native scanning... f${frameCount}, partials: ${partialCount}`); + } + requestAnimationFrame(scanFrame); + } + } + + requestAnimationFrame(scanFrame); +} + +// ===== QUAGGA FALLBACK SCANNER ===== +function startQuaggaScanner(videoEl) { if (quaggaRunning) return; const canvas = document.getElementById('scanner-canvas'); const ctx = canvas.getContext('2d'); + const frontCam = isFrontCamera(); + const scannerLine = document.querySelector('.scanner-line'); + let frameCount = 0; + let partialCount = 0; + + scanLog(`Quagga starting — frontCam: ${frontCam}`); let scanning = true; quaggaRunning = true; let lastDetected = ''; let detectCount = 0; + let detectionHistory = {}; + + // Alternate between full frame and center-cropped for better detection + let scanPass = 0; // 0=full, 1=center-crop, 2=full-enhanced, 3=center-enhanced + + function updateScannerFeedback(state) { + if (!scannerLine) return; + scannerLine.classList.remove('scanning', 'detecting'); + if (state) scannerLine.classList.add(state); + } + + function getFrameDataUrl(pass) { + const vw = videoEl.videoWidth; + const vh = videoEl.videoHeight; + + if (pass % 2 === 0) { + // Full frame + canvas.width = vw; + canvas.height = vh; + ctx.drawImage(videoEl, 0, 0); + } else { + // Center crop: 60% of frame, focused on barcode area + const cropW = Math.round(vw * 0.7); + const cropH = Math.round(vh * 0.4); + const sx = Math.round((vw - cropW) / 2); + const sy = Math.round((vh - cropH) / 2); + canvas.width = cropW; + canvas.height = cropH; + ctx.drawImage(videoEl, sx, sy, cropW, cropH, 0, 0, cropW, cropH); + } + + // Apply enhancement on passes 2,3 or always for front cam + if (frontCam || pass >= 2) { + enhanceCanvasForBarcode(ctx, canvas.width, canvas.height); + } + + return canvas.toDataURL('image/jpeg', 0.95); + } function scanFrame() { if (!scanning || !scannerStream) return; + frameCount++; + scanPass = (scanPass + 1) % 4; - canvas.width = videoEl.videoWidth; - canvas.height = videoEl.videoHeight; - ctx.drawImage(videoEl, 0, 0); + const dataUrl = getFrameDataUrl(scanPass); + + if (frameCount === 1) { + scanLog(`Frame #1 — video: ${videoEl.videoWidth}x${videoEl.videoHeight}`); + updateScannerFeedback('scanning'); + } + + let callbackCalled = false; + const safetyTimer = setTimeout(() => { + if (!callbackCalled && scanning) { + scanLog(`Quagga timeout on f${frameCount}, retrying...`); + setTimeout(scanFrame, 100); + } + }, 5000); try { + const imgSize = Math.max(canvas.width, canvas.height); Quagga.decodeSingle({ - src: canvas.toDataURL('image/jpeg', 0.8), + src: dataUrl, numOfWorkers: 0, - inputStream: { size: 800 }, + inputStream: { size: Math.min(imgSize, 800) }, decoder: { readers: [ 'ean_reader', @@ -1230,39 +1475,81 @@ function startQuagga(videoEl) { 'code_39_reader', 'upc_reader', 'upc_e_reader' - ] + ], + multiple: false }, - locate: true + locate: true, + locator: { patchSize: 'large', halfSample: false } }, function(result) { + callbackCalled = true; + clearTimeout(safetyTimer); if (result && result.codeResult) { const code = result.codeResult.code; + const format = result.codeResult.format; + partialCount++; + const passName = ['full','crop','full+enh','crop+enh'][scanPass]; + scanLog(`Partial #${partialCount} [f${frameCount} ${passName}]: ${code} (${format})`); + updateScannerFeedback('detecting'); + + if (!detectionHistory[code]) detectionHistory[code] = { count: 0, lastFrame: 0 }; + detectionHistory[code].count++; + detectionHistory[code].lastFrame = frameCount; + if (code === lastDetected) { detectCount++; } else { lastDetected = code; detectCount = 1; } - // Require 2 consecutive reads for reliability - if (detectCount >= 2) { + + const dominated = detectionHistory[code]; + if (detectCount >= 2 || dominated.count >= 2) { scanning = false; quaggaRunning = false; + updateScannerFeedback(null); + scanLog(`CONFIRMED: ${code} after ${frameCount} frames (consec:${detectCount}, total:${dominated.count})`); onBarcodeDetected(code); return; } + } else { + updateScannerFeedback('scanning'); } if (scanning) { - setTimeout(scanFrame, 300); + if (frameCount % 20 === 0) { + scanLog(`Scanning... f${frameCount}, partials: ${partialCount}, pass: ${scanPass}`); + } + setTimeout(scanFrame, 150); } }); } catch (e) { + callbackCalled = true; + clearTimeout(safetyTimer); + scanLog(`Quagga error: ${e.message}`); if (scanning) setTimeout(scanFrame, 500); } } - // Start scanning after a small delay setTimeout(scanFrame, 500); } +// Enhance low-quality camera frames for better barcode recognition +function enhanceCanvasForBarcode(ctx, w, h) { + const imageData = ctx.getImageData(0, 0, w, h); + const d = imageData.data; + // Convert to high-contrast grayscale + for (let i = 0; i < d.length; i += 4) { + // Luminance + let gray = 0.299 * d[i] + 0.587 * d[i+1] + 0.114 * d[i+2]; + // Increase contrast + gray = ((gray - 128) * 1.5) + 128; + gray = gray < 0 ? 0 : gray > 255 ? 255 : gray; + // Threshold to make bars more distinct + gray = gray < 140 ? 0 : 255; + d[i] = d[i+1] = d[i+2] = gray; + } + ctx.putImageData(imageData, 0, 0); +} + function stopScanner() { quaggaRunning = false; if (scannerStream) { @@ -2560,9 +2847,7 @@ async function initAICamera() { if (aiStream) { aiStream.getTracks().forEach(t => t.stop()); } - aiStream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } - }); + aiStream = await navigator.mediaDevices.getUserMedia(getCameraConstraints()); video.srcObject = aiStream; await video.play(); } catch (err) { @@ -3156,12 +3441,9 @@ async function searchItemPrice(idx, force = false) { renderShoppingItems(); try { - // Include specification in the search query for better catalog results - let searchQ = item.name; + // Send item name as query, spec separately for AI selection + const searchQ = item.name; const spec = item.specification || ''; - // Strip priority emojis from spec before appending - const cleanSpec = spec.replace(/[🔴🟡🟢]/g, '').trim(); - if (cleanSpec) searchQ += ' ' + cleanSpec; const s2 = getSettings(); const aiPrompt = s2.spesa_ai_prompt || ''; @@ -3451,9 +3733,7 @@ async function scanExpiryWithAI() { // Start camera try { - expiryStream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } - }); + expiryStream = await navigator.mediaDevices.getUserMedia(getCameraConstraints()); const video = document.getElementById('expiry-video'); video.srcObject = expiryStream; await video.play(); @@ -3515,9 +3795,7 @@ function retakeExpiry() { document.getElementById('expiry-scan-status').style.display = 'none'; // Restart camera - navigator.mediaDevices.getUserMedia({ - video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } - }).then(stream => { + navigator.mediaDevices.getUserMedia(getCameraConstraints()).then(stream => { expiryStream = stream; const video = document.getElementById('expiry-video'); video.srcObject = stream; @@ -3711,10 +3989,21 @@ function saveRecipeToArchive(recipe) { localStorage.setItem('dispensa_recipe_archive', JSON.stringify(archive)); } +function getTodayRecipeTitles() { + const archive = getRecipeArchive(); + const today = new Date().toISOString().slice(0, 10); + return archive + .filter(e => e.date === today && e.recipe && e.recipe.title) + .map(e => e.recipe.title); +} + +let _recipeArchiveEntries = []; + function loadRecipeArchive() { const container = document.getElementById('recipe-archive'); if (!container) return; const archive = getRecipeArchive(); + _recipeArchiveEntries = archive; if (archive.length === 0) { container.innerHTML = '
🍳

Nessuna ricetta salvata.
Genera la tua prima ricetta!

'; @@ -3729,6 +4018,7 @@ function loadRecipeArchive() { } let html = ''; + let flatIdx = 0; const today = new Date().toISOString().slice(0, 10); const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); @@ -3744,7 +4034,9 @@ function loadRecipeArchive() { const r = entry.recipe; const mealIcon = MEAL_LABELS[r.meal] || r.meal; const tags = (r.tags || []).slice(0, 3).join(', '); - html += `
`; + // Find this entry's index in the flat archive array + const archiveIdx = archive.indexOf(entry); + html += `
`; html += `
`; html += `${mealIcon}`; html += `${escapeHtml(r.title)}`; @@ -3755,6 +4047,7 @@ function loadRecipeArchive() { html += `👥 ${r.persons}`; if (tags) html += `${tags}`; html += `
`; + flatIdx++; } html += `
`; } @@ -3762,8 +4055,9 @@ function loadRecipeArchive() { container.innerHTML = html; } -function viewArchivedRecipe(entryJson) { - const entry = JSON.parse(entryJson); +function viewArchivedRecipe(idx) { + const entry = _recipeArchiveEntries[idx]; + if (!entry) return; renderRecipe(entry.recipe); document.getElementById('recipe-overlay').style.display = 'flex'; document.getElementById('recipe-ask').style.display = 'none'; @@ -3985,7 +4279,8 @@ async function generateRecipe() { persons, options, appliances: settings.appliances || [], - dietary_restrictions: settings.dietary_restrictions || '' + dietary_restrictions: settings.dietary_restrictions || '', + today_recipes: getTodayRecipeTitles() }); if (!result.success) { diff --git a/data/client_debug.log b/data/client_debug.log new file mode 100644 index 0000000..cf34243 --- /dev/null +++ b/data/client_debug.log @@ -0,0 +1,82 @@ +[2026-03-12 17:30:05] [desktop] Camera mode: environment +[2026-03-12 17:30:05] [desktop] BarcodeDetector: NO (Quagga fallback) +[2026-03-12 17:30:05] [desktop] Constraints: {"width":{"ideal":1280},"height":{"ideal":720},"facingMode":"environment"} +[2026-03-12 17:30:05] [desktop] Stream OK — track: HP TrueVision HD Camera (30c9:002c) +[2026-03-12 17:30:05] [desktop] Resolution: 1280x720, facing: user +[2026-03-12 17:30:05] [desktop] Video playing — videoWidth: 1280, videoHeight: 720 +[2026-03-12 17:30:05] [desktop] Quagga starting — frontCam: false +[2026-03-12 17:30:05] [desktop] Frame #1 — video: 1280x720 +[2026-03-12 17:30:11] [desktop] Scanning... f20, partials: 0, pass: 0 +[2026-03-12 17:30:15] [desktop] Scanning... f40, partials: 0, pass: 0 +[2026-03-12 17:30:20] [desktop] Scanning... f60, partials: 0, pass: 0 +[2026-03-12 17:30:21] [phone] Camera mode: user +[2026-03-12 17:30:21] [phone] BarcodeDetector: YES (native) +[2026-03-12 17:30:21] [phone] Constraints: {"width":{"ideal":640},"height":{"ideal":480},"facingMode":"user"} +[2026-03-12 17:30:21] [phone] Stream OK — track: camera 1, facing front +[2026-03-12 17:30:21] [phone] Resolution: 480x640, facing: user +[2026-03-12 17:30:21] [phone] Video playing — videoWidth: 480, videoHeight: 640 +[2026-03-12 17:30:21] [phone] Native BarcodeDetector started +[2026-03-12 17:30:23] [phone] Native detect #1 [f3]: 4003608024917 (ean_13) +[2026-03-12 17:30:23] [phone] Native detect #2 [f6]: 8003000024917 (ean_13) +[2026-03-12 17:30:23] [phone] Native detect #3 [f16]: 8003000024917 (ean_13) +[2026-03-12 17:30:23] [phone] CONFIRMED: 8003000024917 after 16 frames +[2026-03-12 17:30:25] [desktop] Scanning... f80, partials: 0, pass: 0 +[2026-03-12 17:30:30] [desktop] Scanning... f100, partials: 0, pass: 0 +[2026-03-12 17:30:35] [desktop] Scanning... f120, partials: 0, pass: 0 +[2026-03-12 17:30:40] [desktop] Scanning... f140, partials: 0, pass: 0 +[2026-03-12 17:30:44] [desktop] Scanning... f160, partials: 0, pass: 0 +[2026-03-12 17:30:49] [desktop] Scanning... f180, partials: 0, pass: 0 +[2026-03-12 17:30:54] [desktop] Scanning... f200, partials: 0, pass: 0 +[2026-03-12 17:30:59] [desktop] Scanning... f220, partials: 0, pass: 0 +[2026-03-12 17:31:04] [desktop] Scanning... f240, partials: 0, pass: 0 +[2026-03-12 17:31:07] [desktop] Camera mode: user +[2026-03-12 17:31:07] [desktop] BarcodeDetector: YES (native) +[2026-03-12 17:31:07] [desktop] Constraints: {"width":{"ideal":640},"height":{"ideal":480},"facingMode":"user"} +[2026-03-12 17:31:07] [desktop] Stream OK — track: camera 1, facing front +[2026-03-12 17:31:07] [desktop] Resolution: 480x640, facing: user +[2026-03-12 17:31:07] [desktop] Video playing — videoWidth: 480, videoHeight: 640 +[2026-03-12 17:31:07] [desktop] Native BarcodeDetector started +[2026-03-12 17:31:09] [desktop] Scanning... f260, partials: 0, pass: 0 +[2026-03-12 17:31:14] [desktop] Native scanning... f30, partials: 0 +[2026-03-12 17:31:14] [desktop] Native detect #1 [f31]: 8003000024917 (ean_13) +[2026-03-12 17:31:14] [desktop] Native detect #2 [f33]: 8003000024917 (ean_13) +[2026-03-12 17:31:14] [desktop] CONFIRMED: 8003000024917 after 33 frames +[2026-03-12 17:31:14] [desktop] Scanning... f280, partials: 0, pass: 0 +[2026-03-12 17:31:19] [desktop] Scanning... f300, partials: 0, pass: 0 +[2026-03-12 17:31:22] [desktop] Camera mode: user +[2026-03-12 17:31:22] [desktop] BarcodeDetector: YES (native) +[2026-03-12 17:31:22] [desktop] Constraints: {"width":{"ideal":640},"height":{"ideal":480},"facingMode":"user"} +[2026-03-12 17:31:22] [desktop] Stream OK — track: camera 1, facing front +[2026-03-12 17:31:22] [desktop] Resolution: 480x640, facing: user +[2026-03-12 17:31:22] [desktop] Video playing — videoWidth: 480, videoHeight: 640 +[2026-03-12 17:31:22] [desktop] Native BarcodeDetector started +[2026-03-12 17:31:24] [desktop] Scanning... f320, partials: 0, pass: 0 +[2026-03-12 17:31:26] [desktop] Native scanning... f30, partials: 0 +[2026-03-12 17:31:28] [desktop] Scanning... f340, partials: 0, pass: 0 +[2026-03-12 17:31:30] [desktop] Native detect #1 [f49]: 016418230816 (upc_a) +[2026-03-12 17:31:30] [desktop] Native detect #2 [f54]: 003418230816 (upc_a) +[2026-03-12 17:31:33] [desktop] Scanning... f360, partials: 0, pass: 0 +[2026-03-12 17:31:33] [desktop] Native scanning... f60, partials: 2 +[2026-03-12 17:31:33] [desktop] Native detect #3 [f63]: 9019489230816 (ean_13) +[2026-03-12 17:31:38] [desktop] Scanning... f380, partials: 0, pass: 0 +[2026-03-12 17:31:42] [desktop] Native scanning... f90, partials: 3 +[2026-03-12 17:31:43] [desktop] Scanning... f400, partials: 0, pass: 0 +[2026-03-12 17:31:48] [desktop] Scanning... f420, partials: 0, pass: 0 +[2026-03-12 17:31:49] [desktop] Native scanning... f120, partials: 3 +[2026-03-12 17:31:53] [desktop] Scanning... f440, partials: 0, pass: 0 +[2026-03-12 17:31:58] [desktop] Native scanning... f150, partials: 3 +[2026-03-12 17:31:58] [desktop] Scanning... f460, partials: 0, pass: 0 +[2026-03-12 17:32:03] [desktop] Native scanning... f180, partials: 3 +[2026-03-12 17:32:03] [desktop] Native detect #4 [f188]: 8000300337396 (ean_13) +[2026-03-12 17:32:03] [desktop] Native detect #5 [f189]: 8000300337396 (ean_13) +[2026-03-12 17:32:03] [desktop] CONFIRMED: 8000300337396 after 189 frames +[2026-03-12 17:32:03] [desktop] Scanning... f480, partials: 0, pass: 0 +[2026-03-12 17:32:08] [desktop] Scanning... f500, partials: 0, pass: 0 +[2026-03-12 17:32:11] [desktop] Camera mode: user +[2026-03-12 17:32:11] [desktop] BarcodeDetector: YES (native) +[2026-03-12 17:32:11] [desktop] Constraints: {"width":{"ideal":640},"height":{"ideal":480},"facingMode":"user"} +[2026-03-12 17:32:11] [desktop] Stream OK — track: camera 1, facing front +[2026-03-12 17:32:11] [desktop] Resolution: 480x640, facing: user +[2026-03-12 17:32:11] [desktop] Video playing — videoWidth: 480, videoHeight: 640 +[2026-03-12 17:32:11] [desktop] Native BarcodeDetector started +[2026-03-12 17:32:13] [desktop] Scanning... f520, partials: 0, pass: 0 diff --git a/data/dispensa.db b/data/dispensa.db index 7c437e1..e8de3f4 100644 Binary files a/data/dispensa.db and b/data/dispensa.db differ diff --git a/data/dupliclick_token.json b/data/dupliclick_token.json new file mode 100644 index 0000000..30996ca --- /dev/null +++ b/data/dupliclick_token.json @@ -0,0 +1,118 @@ +{ + "token": "eyJraWQiOiJzdGQiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJkYWRhbG9vcDgyQGdtYWlsLmNvbSIsImV4cCI6MTc3NDU5NjkzMywianRpIjoiMjM0NzIxNDUzVkJLR0hBSFlDUUxITjdIWFVNREJWIn0.97ZRFe3i0-0sCoVe_lG5Li2YVXkEyyPTfTr6tck984HKQryUf_12BidPe23aL_2eUeUiuMbd8HJfkyumlfOvmA", + "email": "dadaloop82@gmail.com", + "logged_at": "2026-03-12T06:35:33+00:00", + "user": { + "userId": 234721, + "firstName": "Daniel", + "lastName": "Stimpfl", + "fidelityCard": "0401011000247", + "email": "dadaloop82@gmail.com", + "login": "dadaloop82@gmail.com", + "phone": "+393490726254", + "userType": { + "userTypeId": "1" + }, + "companyId": 0, + "hasFavorites": true, + "defaultStoreAddress": { + "addressId": null, + "addressName": null + }, + "codInt": "2167695", + "profile": { + "level": 2, + "confirmed": true + }, + "hrAgent": { + "hrId": null, + "name": "", + "internalCode": "" + }, + "giftCertificatesReceived": [], + "legals": [ + { + "legalId": 1 + } + ], + "userPointsCounters": [ + { + "codInt": "0", + "name": "Fidelity", + "value": 0, + "pointsUsed": 0 + } + ], + "userPoints": [ + { + "codInt": "0", + "name": "Fidelity", + "value": 0, + "pointsUsed": 0 + } + ], + "person": { + "personId": "8116672", + "firstName": "Daniel", + "lastName": "Stimpfl", + "gender": "", + "birthDate": "1982-06-19", + "birthPlace": "", + "company": "", + "fiscalCode": "", + "vatCode": "", + "vatFiscalCode": "", + "codInt": "", + "active": "1", + "industryId": null, + "industryName": null, + "emailCertified": "", + "vatSdiCode": "", + "personTypeId": null, + "personInfos": [] + }, + "billingAddress": { + "addressId": 605113, + "addressTypeId": 1, + "addressType": "billing", + "zoneId": -1, + "active": 1, + "deliveryAddressId": 605113, + "shippingAddressId": 605113, + "addressName": "residenza", + "address1": "VIA PETER ROSEGGER", + "address2": "", + "addressNumber": "20\/C", + "city": "LAIVES", + "postalcode": "", + "floor": "", + "doorbellName": "", + "province": "BZ", + "apartmentNumber": "", + "maxDistance": 0.4, + "country": { + "countryId": 1, + "name": "Italia", + "iso": "IT", + "codInt": "" + }, + "doorbellNumber": "", + "referencePhoneNumber": "", + "referenceMobileNumber": "", + "referenceEMail": "", + "addressNote": "", + "ztl": null, + "elevator": null, + "countryId": "1" + }, + "contact": { + "contactId": "56730212", + "email1": "dadaloop82@gmail.com", + "email2": "", + "homePhone": "", + "workPhone": "" + }, + "crmUserSegments": [] + }, + "cart_id": 1476245 +} \ No newline at end of file diff --git a/index.html b/index.html index 8d94d19..b039bb1 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ Dispensa Manager - + @@ -134,6 +134,8 @@

Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo

+ + @@ -558,6 +560,7 @@ +
@@ -686,6 +689,22 @@
+ +
+
+

📷 Fotocamera

+

Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.

+
+ + +

Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.

+ +
+
+
@@ -839,6 +858,6 @@
- +