diff --git a/CHANGELOG.md b/CHANGELOG.md index d7aec84..bb341d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap. +## [1.7.35] - 2026-06-02 + +### Fixed +- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check. +- **Recipe persons +/− buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict. + +## [1.7.34] - 2026-05-30 + +### Added +- **AI visual barcode fallback** — When the barcode scanner fails to read a barcode within 5 seconds, EverShelf can now automatically capture a camera frame and send it to Gemini Vision to visually identify the product (name, brand, category). On success the product is saved and the inventory form opens just as if a barcode had been scanned. A new toggle in **Settings → Camera** (`AI visual identification (5s fallback)`) lets users enable or disable this feature at any time. Requires Gemini API key configured. Disabled by default. + ## [1.7.33] - 2026-05-29 ### Fixed diff --git a/api/index.php b/api/index.php index f28a7c4..775f626 100644 --- a/api/index.php +++ b/api/index.php @@ -604,7 +604,7 @@ function checkRateLimit(string $action): void { } // Determine limit based on action - $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr']; + $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr', 'gemini_barcode_visual']; $loginActions = []; $recipeActions = ['generate_recipe', 'generate_recipe_stream']; $errorActions = ['report_error', 'check_update']; @@ -1109,6 +1109,10 @@ try { geminiNumberOCR(); break; + case 'gemini_barcode_visual': + geminiBarcodeVisual(); + break; + case 'get_shopping_price': getShoppingPrice($db); break; @@ -4353,6 +4357,7 @@ function getServerSettings(): void { 'shopping_forecast' => env('SHOPPING_FORECAST', 'true') === 'true', 'shopping_auto_add_threshold' => (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'), 'dark_mode' => env('DARK_MODE', 'auto'), + 'barcode_ai_fallback' => env('BARCODE_AI_FALLBACK', 'false') === 'true', // Home Assistant Integration 'ha_enabled' => env('HA_ENABLED', 'false') === 'true', 'ha_url' => env('HA_URL', ''), @@ -4455,6 +4460,7 @@ function saveSettings(): void { 'shopping_enabled' => 'SHOPPING_ENABLED', 'shopping_smart_suggestions' => 'SHOPPING_SMART_SUGGESTIONS', 'shopping_forecast' => 'SHOPPING_FORECAST', + 'barcode_ai_fallback' => 'BARCODE_AI_FALLBACK', // Home Assistant 'ha_enabled' => 'HA_ENABLED', ]; @@ -10494,6 +10500,101 @@ function geminiNumberOCR(): void { } } +// ============================================================================= +// ===== GEMINI AI: BARCODE VISUAL FALLBACK ==================================== +// ============================================================================= +/** + * POST /api/?action=gemini_barcode_visual + * Body: { image: base64-jpeg, lang: 'it'|'en'|'de'|... } + * Returns: { found, source, product } or { found: false, error } + * Uses Gemini vision to visually identify a product from a camera frame + * when the barcode scanner fails to read the barcode after 5 seconds. + */ +function geminiBarcodeVisual(): void { + EverLog::info('geminiBarcodeVisual'); + $apiKey = env('GEMINI_API_KEY'); + if (empty($apiKey)) { + echo json_encode(['found' => false, 'error' => 'no_api_key']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $imageBase64 = $input['image'] ?? ''; + $lang = $input['lang'] ?? 'it'; + if (empty($imageBase64)) { + echo json_encode(['found' => false, 'error' => 'no_image']); + return; + } + + $langNote = match($lang) { + 'de' => 'Use the German product name if known.', + 'fr' => 'Use the French product name if known.', + 'es' => 'Use the Spanish product name if known.', + default => 'Use the Italian product name if known.', + }; + + $payload = [ + 'contents' => [[ + 'parts' => [ + ['text' => "Identify the product shown in this image. {$langNote}\n" . + "Respond with ONLY valid JSON (no markdown, no backticks):\n" . + "{\"name\":\"...\",\"brand\":\"...\",\"category\":\"...\"}\n" . + "- name: the product name (as specific as possible, not just the brand)\n" . + "- brand: the brand/manufacturer, or empty string if not visible\n" . + "- category: one of: latticini, pasta, bevande, snack, carne, pesce, " . + "frutta, verdura, surgelati, condimenti, conserve, cereali, pane, " . + "igiene, pulizia, altro\n" . + "If you cannot identify the product at all, respond with: {\"unknown\":true}"], + ['inline_data' => ['mime_type' => 'image/jpeg', 'data' => $imageBase64]], + ], + ]], + 'generationConfig' => [ + 'temperature' => 0, + 'maxOutputTokens' => 200, + 'responseMimeType' => 'application/json', + 'thinkingConfig' => ['thinkingBudget' => 0], + ], + ]; + + $result = callGeminiWithFallback($apiKey, $payload, 15, 'barcode_visual'); + if ($result['http_code'] !== 200) { + echo json_encode(['found' => false, 'error' => 'gemini_error_' . $result['http_code']]); + return; + } + + $text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''); + // Strip accidental markdown fences + $text = preg_replace('/^```json\s*/i', '', $text); + $text = preg_replace('/\s*```$/i', '', trim($text)); + + $data = json_decode($text, true); + if (!$data || !empty($data['unknown']) || empty($data['name'])) { + echo json_encode(['found' => false]); + return; + } + + echo json_encode([ + 'found' => true, + 'source' => 'gemini_visual', + 'product' => [ + 'name' => $data['name'] ?? '', + 'brand' => $data['brand'] ?? '', + 'category' => $data['category'] ?? '', + 'image_url' => '', + 'quantity_info' => '', + 'nutriscore' => '', + 'ingredients' => '', + 'allergens' => '', + 'conservation' => '', + 'origin' => '', + 'nova_group' => '', + 'ecoscore' => '', + 'labels' => '', + 'stores' => '', + ], + ], JSON_UNESCAPED_UNICODE); +} + // ============================================================================= // ===== GEMINI AI: ANOMALY EXPLANATION ======================================= // ============================================================================= diff --git a/assets/js/app.js b/assets/js/app.js index 34d03d3..c0749ca 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1943,6 +1943,8 @@ let quaggaRunning = false; let aiStream = null; let _scanZoomLevel = 2; // always 2x let _torchActive = false; +let _aiFallbackTimer = null; +let _aiFallbackExhausted = false; // true after one failed AI visual attempt — reset when scanner is closed // Apply fixed 2x zoom (hardware if available, CSS fallback) async function _applyFixedZoom() { @@ -2083,13 +2085,15 @@ function _showScanConfirm(name) { // ===== AI NUMBER OCR (Gemini reads printed barcode digits) ===== let _numOcrRunning = false; -async function _tryGeminiNumberOCR() { +async function _tryGeminiNumberOCR(options = {}) { + const { chainToVisual = false } = options; if (_numOcrRunning || !_requireGemini()) return; const video = document.getElementById('scanner-video'); if (!video || !video.videoWidth) { showToast(t('error.camera'), 'error'); return; } _numOcrRunning = true; const btn = document.getElementById('scan-num-ocr-btn'); if (btn) { btn.disabled = true; btn.textContent = t('scan.num_ocr_searching'); } + _setScanStatus(t('scan.status_ocr_searching'), 'retry', t('scan.method_ai_ocr')); try { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; @@ -2098,12 +2102,28 @@ async function _tryGeminiNumberOCR() { const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1]; const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 }); if (result.barcode) { + scanLog(`AI OCR: found barcode ${result.barcode}`); showToast(t('scan.num_ocr_found').replace('{code}', result.barcode), 'success'); onBarcodeDetected(result.barcode); } else { - showToast(t('scan.num_ocr_not_found'), 'warning'); + scanLog('AI OCR: barcode digits not found'); + if (chainToVisual && scannerStream && !_aiFallbackExhausted) { + scanLog('AI OCR failed — switching to visual product identification'); + _setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision')); + await _tryGeminiVisualBarcode(); + } else { + showToast(t('scan.num_ocr_not_found'), 'warning'); + _setScanStatus(t('scan.status_scanning'), '', ''); + } } } catch(e) { + scanLog(`AI OCR error: ${e.message}`); + if (chainToVisual && scannerStream && !_aiFallbackExhausted) { + _setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision')); + await _tryGeminiVisualBarcode(); + } else { + _setScanStatus(t('scan.status_scanning'), '', ''); + } showToast(t('error.connection'), 'error'); } finally { _numOcrRunning = false; @@ -2111,6 +2131,225 @@ async function _tryGeminiNumberOCR() { } } +// ===== AI VISUAL PRODUCT IDENTIFICATION (auto-fallback after 5s) ===== +let _aiBarcodeVisualRunning = false; +let _aiDetectedProductDraft = null; +let _aiInventoryCandidates = []; + +function _showScanAiOverlay(msg) { + const el = document.getElementById('scan-ai-overlay'); + const msgEl = document.getElementById('scan-ai-overlay-msg'); + if (el) el.style.display = 'flex'; + if (msgEl) msgEl.textContent = msg || ''; +} +function _hideScanAiOverlay() { + const el = document.getElementById('scan-ai-overlay'); + if (el) el.style.display = 'none'; +} +function _showAiRetryButton() { + const btn = document.getElementById('scan-ai-retry-btn'); + if (btn) btn.style.display = ''; +} +function _clearAiMatchPanel() { + const result = document.getElementById('scan-result'); + if (!result) return; + result.style.display = 'none'; + result.innerHTML = ''; + _aiDetectedProductDraft = null; + _aiInventoryCandidates = []; +} +function _renderAiCandidateRow(item, idx) { + const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦'; + const qty = (item.total_qty !== null && item.total_qty !== undefined) + ? `${parseFloat(item.total_qty)} ${item.unit || ''}`.trim() + : ''; + return ` + + `; +} +function _showAiMatchChoices(aiProduct, candidates) { + const result = document.getElementById('scan-result'); + if (!result) return; + const aiName = aiProduct?.name || t('product.not_recognized'); + const aiBrand = aiProduct?.brand || ''; + const catIcon = CATEGORY_ICONS[mapToLocalCategory(aiProduct?.category || '', aiName)] || '📦'; + const itemsHtml = (candidates || []).slice(0, 3).map((it, i) => _renderAiCandidateRow(it, i)).join(''); + + result.innerHTML = ` +
+
+
${t('scan.ai_match_title')}
+
${t('scan.ai_match_subtitle')}
+
+ + ${itemsHtml ? ` +
+
${t('scan.ai_match_existing')}
+
${itemsHtml}
+
+ ` : ` +
${t('scan.ai_match_none')}
+ `} + + +
${t('scan.ai_detected_label')}
+
${catIcon} ${escapeHtml(aiName)}${aiBrand ? ' · ' + escapeHtml(aiBrand) : ''}
+
+ `; + result.style.display = 'block'; +} +async function _confirmAiDetectedProduct() { + const p = _aiDetectedProductDraft; + if (!p) return; + showLoading(true); + try { + const saveResult = await api('product_save', {}, 'POST', { + barcode: '', + name: p.name || t('product.not_recognized'), + brand: p.brand || '', + category: p.category || '', + image_url: '', + unit: 'pz', + default_quantity: 1, + package_unit: '', + notes: '', + }); + if (saveResult.id) { + currentProduct = { + id: saveResult.id, + barcode: '', + name: p.name || t('product.not_recognized'), + brand: p.brand || '', + category: p.category || '', + image_url: '', + unit: 'pz', + default_quantity: 1, + package_unit: '', + _confCount: 0, + weight_info: '', + }; + addToScanRecents(currentProduct); + _clearAiMatchPanel(); + showLoading(false); + setTimeout(() => showProductAction(), 250); + } else { + showLoading(false); + showToast(t('error.connection'), 'error'); + } + } catch (_) { + showLoading(false); + showToast(t('error.connection'), 'error'); + } +} +function _selectAiInventoryCandidate(idx) { + const p = _aiInventoryCandidates[idx]; + if (!p) return; + currentProduct = { + id: p.id, + barcode: p.barcode || '', + name: p.name || '', + brand: p.brand || '', + category: p.category || '', + image_url: p.image_url || '', + unit: p.unit || 'pz', + default_quantity: p.default_quantity || 1, + package_unit: p.package_unit || '', + _confCount: 0, + weight_info: '', + }; + if (p.notes) { + const pesoMatch = p.notes.match(/Peso:\s*([^·]+)/); + if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim(); + } + addToScanRecents(currentProduct); + _clearAiMatchPanel(); + setTimeout(() => showProductAction(), 250); +} +async function _retryAiScan() { + const btn = document.getElementById('scan-ai-retry-btn'); + if (btn) btn.style.display = 'none'; + _aiFallbackExhausted = false; + _clearAiMatchPanel(); + await _tryGeminiNumberOCR({ chainToVisual: true }); +} + +async function _tryGeminiVisualBarcode() { + if (_aiBarcodeVisualRunning || !_requireGemini()) return; + const video = document.getElementById('scanner-video'); + if (!video || !video.videoWidth) return; + + // ★ Capture the frame BEFORE stopping the stream — after stopScanner() the + // video element is blanked and drawImage would send a black image to Gemini. + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext('2d').drawImage(video, 0, 0); + const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1]; + if (!imageBase64) { scanLog('AI visual: failed to capture frame'); return; } + + _aiBarcodeVisualRunning = true; + stopScanner(); // stop scanner loop while AI processes (stream already captured above) + _setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision')); + _showScanAiOverlay(t('scan.ai_overlay_msg')); + + try { + const result = await api('gemini_barcode_visual', {}, 'POST', { + image: imageBase64, + lang: _currentLang || 'it', + }); + + if (result.found && result.product) { + const p = result.product; + scanLog(`AI visual: found "${p.name}" (${p.brand})`); + _hideScanAiOverlay(); + showToast(t('scan.ai_fallback_found'), 'success'); + _aiDetectedProductDraft = { + name: p.name || t('product.not_recognized'), + brand: p.brand || '', + category: p.category || '', + }; + let candidates = []; + try { + const invRes = await api('inventory_search', { + q: _aiDetectedProductDraft.name, + limit: 3, + }); + candidates = (invRes.items || []).slice(0, 3); + } catch (_) { + candidates = []; + } + _aiInventoryCandidates = candidates; + _showAiMatchChoices(_aiDetectedProductDraft, candidates); + } else { + scanLog('AI visual: product not identified — exhausted for this session'); + _aiFallbackExhausted = true; + _hideScanAiOverlay(); + _setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', ''); + _showAiRetryButton(); + // Restart barcode scanner — AI timer won't fire again (_aiFallbackExhausted=true). + setTimeout(() => initScanner(), 300); + } + } catch (e) { + scanLog(`AI visual error: ${e.message}`); + _aiFallbackExhausted = true; + _hideScanAiOverlay(); + _setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', ''); + _showAiRetryButton(); + setTimeout(() => initScanner(), 300); + } finally { + _aiBarcodeVisualRunning = false; + } +} + // ===== CAMERA HELPER ===== function getCameraConstraints(extraVideo = {}) { const s = getSettings(); @@ -2296,6 +2535,7 @@ function _applySyncedSettings(serverSettings) { 'shopping_enabled','shopping_mode','shopping_smart_suggestions', 'shopping_forecast','shopping_auto_add_threshold', 'dark_mode', + 'barcode_ai_fallback', // Home Assistant 'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events', 'ha_notify_service','ha_expiry_days']; @@ -2887,6 +3127,8 @@ async function loadSettingsUI() { const cameraSelect = document.getElementById('setting-camera-facing'); if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment'; loadCameraDevices(); + const baifEl = document.getElementById('setting-barcode-ai-fallback'); + if (baifEl) baifEl.checked = s.barcode_ai_fallback === true; renderAppliances(s.appliances || []); const mealPlanEnabled = s.meal_plan_enabled !== false; const mpEnabledEl = document.getElementById('setting-meal-plan-enabled'); @@ -3465,6 +3707,8 @@ async function saveSettings() { s.dietary = document.getElementById('setting-dietary').value.trim(); // Camera s.camera_facing = document.getElementById('setting-camera-facing').value; + const baifSave = document.getElementById('setting-barcode-ai-fallback'); + if (baifSave) s.barcode_ai_fallback = baifSave.checked; // Screensaver const ssEl = document.getElementById('setting-screensaver-enabled'); if (ssEl) s.screensaver_enabled = ssEl.checked; @@ -3608,6 +3852,7 @@ async function saveSettings() { shopping_forecast: s.shopping_forecast !== false, shopping_auto_add_threshold: s.shopping_auto_add_threshold || 0, dark_mode: s.dark_mode || 'auto', + barcode_ai_fallback: !!s.barcode_ai_fallback, // Home Assistant ha_enabled: !!s.ha_enabled, ha_url: s.ha_url || '', @@ -3748,6 +3993,18 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader // Track current page for auto-refresh let _currentPageId = 'dashboard'; let _currentPageParam = null; +let _pageHistory = [{ pageId: 'dashboard', param: null }]; + +function goBack(fallbackPage = 'dashboard') { + if (_pageHistory.length > 1) { + // Drop current page and navigate to the previous entry without re-adding history. + _pageHistory.pop(); + const prev = _pageHistory[_pageHistory.length - 1] || { pageId: fallbackPage, param: null }; + showPage(prev.pageId, prev.param, { skipHistory: true }); + return; + } + showPage(fallbackPage, null, { skipHistory: true }); +} // Refresh current page data without full navigation function refreshCurrentPage() { @@ -3765,7 +4022,17 @@ function refreshCurrentPage() { } } -function showPage(pageId, param = null) { +function showPage(pageId, param = null, options = {}) { + const skipHistory = !!options.skipHistory; + if (!skipHistory) { + const last = _pageHistory[_pageHistory.length - 1]; + const sameAsLast = !!last && last.pageId === pageId && (last.param ?? null) === (param ?? null); + if (!sameAsLast) { + _pageHistory.push({ pageId, param }); + if (_pageHistory.length > 80) _pageHistory.shift(); + } + } + _currentPageId = pageId; _currentPageParam = param; // Hide all pages @@ -3797,7 +4064,7 @@ function showPage(pageId, param = null) { } loadInventory(); break; - case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode'); + case 'scan': _aiFallbackExhausted = false; _hideScanAiOverlay(); { const _rb = document.getElementById('scan-ai-retry-btn'); if (_rb) _rb.style.display = 'none'; } initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode'); // Pre-warm the embedding model the first time user visits scan page if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) { window._getCategoryPipeline(); // fire-and-forget @@ -4942,10 +5209,11 @@ async function loadBannerAlerts() { if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; } try { - const [invData, predData, anomalyData, finishedData, statsData] = await Promise.all([ + const [invData, predData, anomalyData, dupLossData, finishedData, statsData] = await Promise.all([ api('inventory_list'), api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }), api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }), + api('inventory_duplicate_loss_checks').catch(err => { console.warn('[Banner] duplicate loss checks fetch failed:', err); return { checks: [] }; }), api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }), api('stats').catch(() => ({ opened: [] })), ]); @@ -5069,14 +5337,21 @@ async function loadBannerAlerts() { _bannerQueue.push({ type: 'anomaly', data: an }); }); - // 6. Finished products: inventory hit 0, waiting for user confirmation + // 6. Potentially lost products due to rapid duplicate "out" events + const dupChecks = dupLossData.checks || []; + dupChecks.forEach(ch => { + if (confirmed['dup_' + ch.dismiss_key]) return; + _bannerQueue.push({ type: 'dup_loss_check', data: ch }); + }); + + // 7. Finished products: inventory hit 0, waiting for user confirmation const finished = finishedData.finished || []; finished.forEach(fin => { if (confirmed['fin_' + fin.product_id]) return; _bannerQueue.push({ type: 'finished', data: fin }); }); - // 7. Products with no expiry date set (and not permanently dismissed) + // 8. Products with no expiry date set (and not permanently dismissed) // Warn for ALL food/drink items — only skip igiene/pulizia (non-food). // Items are capped at 8 per load (opened packages first) to avoid banner overflow. const noExpiryDismissed = _getNoExpiryDismissed(); @@ -5157,6 +5432,8 @@ function _bannerPriority(entry) { // Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong) return entry.data.direction === 'missing' ? 260 : 250; } + case 'dup_loss_check': + return 700; // high-priority check: likely double-consume loss case 'finished': return 600; // product ran out — confirm before removing from DB case 'no_expiry': @@ -5358,6 +5635,30 @@ function renderBannerItem() { } actionsEl.innerHTML = btns; + } else if (entry.type === 'dup_loss_check') { + const ch = entry.data; + banner.className = 'alert-banner banner-dup-loss'; + iconEl.textContent = '🧪'; + + const locInfo = LOCATIONS[ch.location] || { icon: '📦', label: ch.location || '—' }; + const locText = `${locInfo.icon} ${locInfo.label}`; + const qtyPair = `${ch.q1} + ${ch.q2}`; + + titleEl.textContent = t('dashboard.banner_dup_loss_title').replace('{name}', ch.name); + detailEl.textContent = t('dashboard.banner_dup_loss_detail') + .replace('{location}', locText) + .replace('{seconds}', Math.round(ch.dt_sec || 0)) + .replace('{qty_pair}', qtyPair); + + let btns = ''; + if (ch.inventory_id && ch.inventory_id > 0) { + btns += ``; + } else { + btns += ``; + } + btns += ``; + actionsEl.innerHTML = btns; + } else if (entry.type === 'no_expiry') { const item = entry.data; banner.className = 'alert-banner banner-no-expiry'; @@ -5495,6 +5796,32 @@ function dismissBannerAnomaly() { dismissBannerItem(); } +function dismissDuplicateLossCheck() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry || entry.type !== 'dup_loss_check') return; + const key = entry.data.dismiss_key; + setReviewConfirmed('dup_' + key); + showToast(t('dashboard.banner_dup_loss_toast_done'), 'success'); + dismissBannerItem(); +} + +async function openDuplicateLossCheck(productId) { + showLoading(true); + try { + const data = await api('product_get', { id: productId }); + if (data.product) { + currentProduct = data.product; + showProductAction(); + } else { + showToast(t('error.not_found'), 'error'); + } + } catch (e) { + showToast(t('error.connection'), 'error'); + } finally { + showLoading(false); + } +} + function weighBannerItem() { const entry = _bannerQueue[_bannerIndex]; if (!entry) return; @@ -6489,11 +6816,14 @@ async function initScanner() { const constraints = getCameraConstraints(); scanLog(`Camera mode: ${getSettings().camera_facing || 'environment'}`); - scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (Quagga fallback)'}`); + scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (fallback)'}`); + scanLog(`Gemini available: ${_geminiAvailable}`); + scanLog(`AI fallback enabled: ${getSettings().barcode_ai_fallback === true}`); scanLog(`Constraints: ${JSON.stringify(constraints.video)}`); try { stopScanner(); + _clearAiMatchPanel(); const stream = await navigator.mediaDevices.getUserMedia(constraints); const track = stream.getVideoTracks()[0]; @@ -6527,6 +6857,17 @@ async function initScanner() { } }, 4000); } + + // After 5s without a scan, auto-trigger AI visual identification (if enabled) + if (_geminiAvailable && getSettings().barcode_ai_fallback && !_aiFallbackExhausted) { + clearTimeout(_aiFallbackTimer); + _aiFallbackTimer = setTimeout(() => { + if (scannerStream && !_aiFallbackExhausted) { // still scanning — no barcode found yet + scanLog('5s elapsed without barcode — triggering AI OCR fallback'); + _tryGeminiNumberOCR({ chainToVisual: true }); + } + }, 5000); + } } catch (err) { scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`); @@ -6598,14 +6939,14 @@ async function startNativeScanner(videoEl) { if (frameCount === 1) { updateFeedback('scanning'); - _setScanStatus(t('scan.status_scanning'), '', 'Native API'); + _setScanStatus(t('scan.status_scanning'), '', ''); } // After 2s without detection, also start Quagga in parallel as backup if (!quaggaParallelStarted && (Date.now() - startTime) > 2000) { quaggaParallelStarted = true; - scanLog('Native: 2s elapsed, spawning Quagga in parallel'); - _setScanStatus(t('scan.status_parallel'), 'retry', 'Native + Quagga'); + scanLog('Native: 2s elapsed, spawning fallback scanner in parallel'); + _setScanStatus(t('scan.status_parallel'), 'retry', ''); quaggaRunning = false; // temporarily release so Quagga can start startQuaggaScanner(videoEl, false); quaggaRunning = true; // re-take ownership (Quagga will share) @@ -6637,6 +6978,13 @@ async function startNativeScanner(videoEl) { // For other formats (code_128, code_39) require 2 to avoid false reads. const highConfidence = ['ean_13','ean_8','upc_a','upc_e'].includes(format); if (highConfidence || detectCount >= 2 || detectionHistory[code].count >= 2) { + if (highConfidence && !validateEANChecksum(code)) { + _invalidBarcodeCount++; + scanLog(`Invalid EAN checksum (native): ${code} (retry #${_invalidBarcodeCount})`); + _setScanStatus(t('scan.status_invalid').replace('{code}', code), 'invalid', 'Native'); + lastDetected = ''; detectCount = 0; + return; + } scanning = false; quaggaRunning = false; updateFeedback(null); @@ -6674,7 +7022,7 @@ function startQuaggaScanner(videoEl, isPrimary = true) { let frameCount = 0; let partialCount = 0; - scanLog(`Quagga starting — frontCam: ${frontCam}`); + scanLog(`Fallback scanner starting — frontCam: ${frontCam}`); let scanning = true; quaggaRunning = true; @@ -6850,6 +7198,10 @@ function stopScanner() { quaggaRunning = false; _scanZoomLevel = 2; // always 2x on next start _torchActive = false; + clearTimeout(_aiFallbackTimer); _aiFallbackTimer = null; + // NOTE: _aiFallbackExhausted is intentionally NOT reset here. + // It is only reset in showPage('scan') so that internal stop/restart + // cycles (e.g. initScanner calling stopScanner) don't re-arm the AI timer. if (scannerStream) { scannerStream.getTracks().forEach(t => t.stop()); scannerStream = null; @@ -6861,6 +7213,7 @@ function stopScanner() { if (tb) tb.classList.remove('torch-on'); // Hide live code _hideScanLiveCode(); + _clearAiMatchPanel(); // Also stop AI camera if (aiStream) { aiStream.getTracks().forEach(t => t.stop()); @@ -6871,6 +7224,7 @@ function stopScanner() { } async function onBarcodeDetected(barcode) { + clearTimeout(_aiFallbackTimer); _aiFallbackTimer = null; showLoading(true); // Vibrate if available @@ -7026,7 +7380,7 @@ function autoSubmitEAN(inputEl, force = false) { if (!raw) { showToast(t('error.barcode_empty'), 'error'); inputEl.focus(); return; } if (!/^\d{4,14}$/.test(raw)) { showToast(t('error.barcode_format'), 'error'); inputEl.focus(); return; } if (isComplete && !isValid) { - showToast('⚠️ Checksum EAN errato — verifica le cifre', 'warning'); + showToast(t('error.barcode_checksum'), 'error'); inputEl.focus(); return; } stopScanner(); onBarcodeDetected(raw); @@ -7781,7 +8135,7 @@ function showProductAction() { // Update back button: go back to shopping if came from shopping list scan const backBtn = document.getElementById('action-back-btn'); - if (backBtn) backBtn.onclick = _spesaScanTarget ? () => { _spesaScanTarget = null; showPage('shopping'); } : () => showPage('scan'); + if (backBtn) backBtn.onclick = () => goBack(); // Show "shopping target" banner if we came from the shopping list const banner = document.getElementById('shopping-scan-target-banner'); @@ -7795,7 +8149,7 @@ function showProductAction() {
- +
`; } else if (banner) { @@ -13559,7 +13913,7 @@ async function toggleRecipeFavorite(btn) { * Scale recipe ingredient quantities (#123). * Delta: +1 or -1. Min 1, max 20 persons. */ -function adjustRecipePersons(delta) { +function scaleRecipePersons(delta) { const newPersons = Math.max(1, Math.min(20, _recipeCurrentPersons + delta)); if (newPersons === _recipeCurrentPersons) return; _recipeCurrentPersons = newPersons; @@ -13604,9 +13958,9 @@ function renderRecipe(r) { html += '
'; if (r.meal) html += `${_mealLabel(r.meal)}`; html += ` - + 👥 ${r.persons} ${t('recipes.persons_short')} - + `; if (r.prep_time) html += `🔪 ${r.prep_time}`; if (r.cook_time) html += `🔥 ${r.cook_time}`; diff --git a/index.html b/index.html index 74cb570..489e6b9 100644 --- a/index.html +++ b/index.html @@ -64,7 +64,7 @@ - v1.7.25 + v1.7.35
@@ -77,7 +77,7 @@

- EverShelfv1.7.25 + EverShelfv1.7.35

@@ -194,7 +194,7 @@
@@ -225,7 +225,7 @@
@@ -256,6 +256,14 @@
+ +