diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e2a3a..4fb9348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,29 @@ 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.39] - 2026-06-06 + +### Added +- **`resolve_barcode` API** — Single round-trip: local catalog lookup plus **parallel** external search (Open Food Facts IT/world, UPC Item DB, Open Products Facts, Open Beauty Facts via `curl_multi`). Results are stored in SQLite `barcode_cache` for instant repeat scans. +- **Spesa barcode fast path** — In shopping mode, a successful scan opens the **add form directly** (skips the intermediate action page). +- **Session barcode cache** — In-memory cache avoids duplicate API calls when scanning many items in one trip. +- **Manual expiry flag (`expiry_user_set`)** — User-entered expiry dates are kept when changing location, vacuum seal, or moving stock; only auto-estimated dates are recalculated. +- **Family sibling 24h dedup** — After confirming “Sì, tutto ok” on a similar in-stock product, the check prompt is suppressed for the same `shopping_name` family for 24 hours (synced via `family_sibling_confirmed` in app settings). +- **Family sibling stock line** — Spesa prompt shows readable stock (e.g. `4 conf (da 20g)`); new `family_sibling_check` / `family_sibling_stock` strings in IT/EN/DE/FR/ES. +- **Quick-edit product notes** — Notes field in the inline name/brand editor on the product action page. + +### Fixed +- **Kiosk / WebView stability** — Guard `$_SERVER['REQUEST_METHOD']` when null; fix JS temporal-dead-zone crashes (`setProgress`, `enriched` → `enrichedRaw`, `duplicateNames`); lazy-load ZBar WASM so kiosk startup no longer OOM-crashes. +- **Empty barcode SQL error** — Multiple products with `barcode = ''` violated SQLite UNIQUE; empty strings are normalized to `NULL` (migration included). +- **Spesa ghost products** — Finished/catalog AI candidates and scan recents no longer show zero-stock items in shopping mode; `family_sibling_suggest` requires live inventory quantity. +- **Insalata di riso misclassification** — Prepared rice salads (e.g. Ponti) map to `pasta` instead of fresh `verdura`; server and client rules aligned. +- **Family sibling prompt readability** — Quantity and question text use high-contrast colours on the dark overlay. +- **Move after use / recipe move** — Respects manually set expiry (`expiry_user_set`); purchased items marked on blocklist after spesa add. + +### Changed +- **Barcode lookup** — Replaced sequential API waterfall (up to ~15s) with parallel fetch (~1–2s first hit); 30-minute negative cache for unknown codes. +- **Local barcode search** — Automatically tries EAN-13 / UPC-A variant barcodes. + ## [1.7.38] - 2026-06-04 ### Fixed diff --git a/README.md b/README.md index 12f0dbf..49f16b3 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ [![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile) [![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/) -[![Version](https://img.shields.io/badge/version-1.7.38-brightgreen.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.39-brightgreen.svg)](CHANGELOG.md) [![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers) [![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main) [![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors) diff --git a/api/database.php b/api/database.php index ab02b0f..1d4b2db 100644 --- a/api/database.php +++ b/api/database.php @@ -148,6 +148,24 @@ function migrateDB(PDO $db): void { catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; } } + // Empty barcode strings break UNIQUE (only one '' allowed); normalize to NULL. + $db->exec("UPDATE products SET barcode = NULL WHERE barcode IS NOT NULL AND TRIM(barcode) = ''"); + + $invCols = $db->query("PRAGMA table_info(inventory)")->fetchAll(); + $invColNames = array_column($invCols, 'name'); + if (!in_array('expiry_user_set', $invColNames)) { + try { $db->exec("ALTER TABLE inventory ADD COLUMN expiry_user_set INTEGER DEFAULT 0"); } + catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; } + } + + $db->exec("CREATE TABLE IF NOT EXISTS barcode_cache ( + barcode TEXT PRIMARY KEY, + found INTEGER NOT NULL DEFAULT 0, + source TEXT, + payload TEXT, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + )"); + // Migrate transactions CHECK constraint to allow 'waste' type $sql = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='transactions'")->fetchColumn(); if ($sql && strpos($sql, "'waste'") === false) { @@ -443,6 +461,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc if (preg_match('/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/', $n)) return 2; if (preg_match('/salmone|tonno\s+fresco|pesce(?!\s+in)/', $n)) return 2; if (preg_match('/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/', $n)) return 5; + if (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/', $n)) return 7; if (preg_match('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 4; if (preg_match('/\b(succo|spremuta)\b/', $n)) return 3; if (preg_match('/\b(birra|beer)\b/', $n)) return 3; @@ -520,6 +539,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc elseif (preg_match('/uova/', $n)) $days = 28; elseif (preg_match('/pane\s+fresco|pane\s+in\s+cassetta/', $n)) $days = 5; elseif (preg_match('/pane\s+confezionato|pan\s+carr|pancarrè/', $n)) $days = 14; + elseif (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/', $n)) $days = 7; elseif (preg_match('/insalata|rucola|spinaci\s+freschi/', $n)) $days = 5; elseif (preg_match('/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/', $n)) $days = 3; elseif (preg_match('/salmone|tonno\s+fresco|pesce/', $n) && !preg_match('/tonno\s+in\s+scatola|tonno\s+rio/', $n)) $days = 2; diff --git a/api/index.php b/api/index.php index f16cd58..0b68ee4 100644 --- a/api/index.php +++ b/api/index.php @@ -44,7 +44,7 @@ if (!defined('CRON_MODE')) { header('Content-Type: application/json; charset=utf-8'); evershelfSendCorsHeaders(); -if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { +if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { http_response_code(200); exit; } @@ -682,8 +682,8 @@ try { exit; } -$method = $_SERVER['REQUEST_METHOD']; -$action = $_GET['action'] ?? ''; +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$action = (string)($_GET['action'] ?? ''); EverLog::request($action, $method); // API token auth (when API_TOKEN or SETTINGS_TOKEN is configured) @@ -709,6 +709,9 @@ try { case 'lookup_barcode': lookupBarcode(); break; + case 'resolve_barcode': + resolveBarcode($db); + break; case 'stock_for_name': stockForName($db); break; @@ -2180,15 +2183,13 @@ function getClientLog(): void { // ===== PRODUCT FUNCTIONS ===== function searchBarcode(PDO $db): void { - $barcode = $_GET['barcode'] ?? ''; - if (empty($barcode)) { + $barcode = barcodeNormalizeDigits($_GET['barcode'] ?? ''); + if ($barcode === '') { EverLog::info('searchBarcode'); echo json_encode(['found' => false]); return; } - $stmt = $db->prepare("SELECT * FROM products WHERE barcode = ?"); - $stmt->execute([$barcode]); - $product = $stmt->fetch(); + $product = barcodeFindLocalProduct($db, $barcode); if ($product) { echo json_encode(['found' => true, 'product' => $product]); } else { @@ -2196,6 +2197,327 @@ function searchBarcode(PDO $db): void { } } +/** Strip non-digits; used for lookup keys. */ +function barcodeNormalizeDigits(string $barcode): string { + return preg_replace('/\D/', '', trim($barcode)); +} + +/** EAN-13 / UPC-A variant barcodes to try against local DB and external APIs. */ +function barcodeLookupCandidates(string $barcode): array { + $barcode = barcodeNormalizeDigits($barcode); + if ($barcode === '') { + return []; + } + $candidates = [$barcode]; + if (strlen($barcode) === 12 && ctype_digit($barcode)) { + $candidates[] = '0' . $barcode; + } + if (strlen($barcode) === 13 && $barcode[0] === '0') { + $candidates[] = substr($barcode, 1); + } + return array_values(array_unique($candidates)); +} + +function barcodeFindLocalProduct(PDO $db, string $barcode): ?array { + $stmt = $db->prepare("SELECT * FROM products WHERE barcode = ?"); + foreach (barcodeLookupCandidates($barcode) as $bc) { + $stmt->execute([$bc]); + $product = $stmt->fetch(PDO::FETCH_ASSOC); + if ($product) { + return $product; + } + } + return null; +} + +function barcodeCacheGet(PDO $db, string $barcode): ?array { + $stmt = $db->prepare("SELECT found, source, payload, updated_at FROM barcode_cache WHERE barcode = ?"); + $stmt->execute([$barcode]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + $found = (int)$row['found'] === 1; + if (!$found) { + $age = time() - strtotime((string)$row['updated_at']); + if ($age > 1800) { // 30 min negative cache + return null; + } + return ['found' => false, 'source' => $row['source'] ?? 'cache']; + } + $payload = json_decode((string)$row['payload'], true); + if (!is_array($payload)) { + return null; + } + $payload['source'] = $row['source'] ?? ($payload['source'] ?? 'cache'); + return $payload; +} + +function barcodeCacheSet(PDO $db, string $barcode, array $payload, bool $found): void { + $stmt = $db->prepare("INSERT INTO barcode_cache (barcode, found, source, payload, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(barcode) DO UPDATE SET + found = excluded.found, + source = excluded.source, + payload = excluded.payload, + updated_at = excluded.updated_at"); + $stmt->execute([ + $barcode, + $found ? 1 : 0, + $payload['source'] ?? ($found ? 'external' : 'miss'), + json_encode($payload, JSON_UNESCAPED_UNICODE), + ]); +} + +/** Parallel HTTP GET — returns map key => body (or null). */ +function barcodeHttpParallel(array $requests, int $timeoutSec = 4): array { + if (empty($requests)) { + return []; + } + $mh = curl_multi_init(); + $handles = []; + foreach ($requests as $key => $url) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeoutSec, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_HTTPHEADER => ['User-Agent: EverShelf/1.0'], + CURLOPT_FOLLOWLOCATION => true, + ]); + curl_multi_add_handle($mh, $ch); + $handles[$key] = $ch; + } + $running = null; + do { + $status = curl_multi_exec($mh, $running); + if ($running && $status === CURLM_OK) { + curl_multi_select($mh, 0.15); + } + } while ($running > 0); + + $out = []; + foreach ($handles as $key => $ch) { + $body = curl_multi_getcontent($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $out[$key] = ($body !== false && $body !== '' && $code >= 200 && $code < 300) ? $body : null; + curl_multi_remove_handle($mh, $ch); + curl_close($ch); + } + curl_multi_close($mh); + return $out; +} + +function _parseOffProductJson(?string $json): ?array { + if (!$json) { + return null; + } + $data = json_decode($json, true); + if (!isset($data['status']) || (int)$data['status'] !== 1 || empty($data['product'])) { + return null; + } + $p = $data['product']; + + $name = ''; + foreach (['product_name_it', 'generic_name_it', 'product_name', 'generic_name'] as $f) { + if (!empty($p[$f])) { $name = $p[$f]; break; } + } + if ($name === '') { + return null; + } + + if (preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $name)) { + $latinName = ''; + foreach (['generic_name_it', 'generic_name', 'product_name_it', 'product_name'] as $f) { + if (!empty($p[$f]) && !preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $p[$f])) { + $latinName = $p[$f]; break; + } + } + $name = $latinName !== '' ? $latinName : (!empty($p['brands']) ? $p['brands'] : 'Prodotto sconosciuto'); + } + + $ingredients = $p['ingredients_text_it'] ?? $p['ingredients_text'] ?? ''; + $catHierarchy = $p['categories_hierarchy'] ?? []; + $category = $p['categories_tags'][0] ?? (empty($catHierarchy) ? null : end($catHierarchy)) ?? $p['categories'] ?? ''; + $allergens = ''; + if (!empty($p['allergens_tags'])) { + $allergens = implode(', ', array_map(fn($a) => str_replace('en:', '', $a), $p['allergens_tags'])); + } + + $nutriments = null; + if (!empty($p['nutriments']) && is_array($p['nutriments'])) { + $nm = $p['nutriments']; + $nutriments = [ + 'energy_kcal_100g' => isset($nm['energy-kcal_100g']) ? round((float)$nm['energy-kcal_100g'], 1) : (isset($nm['energy_100g']) ? round((float)$nm['energy_100g'] / 4.184, 1) : null), + 'proteins_100g' => isset($nm['proteins_100g']) ? round((float)$nm['proteins_100g'], 1) : null, + 'carbohydrates_100g' => isset($nm['carbohydrates_100g']) ? round((float)$nm['carbohydrates_100g'], 1) : null, + 'fat_100g' => isset($nm['fat_100g']) ? round((float)$nm['fat_100g'], 1) : null, + 'fiber_100g' => isset($nm['fiber_100g']) ? round((float)$nm['fiber_100g'], 1) : null, + 'salt_100g' => isset($nm['salt_100g']) ? round((float)$nm['salt_100g'], 1) : null, + ]; + if (!array_filter(array_values($nutriments))) { + $nutriments = null; + } + } + + return [ + 'name' => $name, + 'brand' => $p['brands'] ?? '', + 'category' => $category, + 'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '', + 'quantity_info' => $p['quantity'] ?? '', + 'nutriscore' => $p['nutriscore_grade'] ?? '', + 'ingredients' => $ingredients, + 'allergens' => $allergens, + 'conservation' => $p['conservation_conditions_it'] ?? $p['conservation_conditions'] ?? '', + 'origin' => $p['origins_it'] ?? $p['origins'] ?? $p['manufacturing_places'] ?? '', + 'nova_group' => $p['nova_group'] ?? '', + 'ecoscore' => $p['ecoscore_grade'] ?? '', + 'labels' => $p['labels'] ?? '', + 'stores' => $p['stores'] ?? '', + 'nutriments' => $nutriments, + ]; +} + +function _parseAltFactsProductJson(?string $json): ?array { + if (!$json) { + return null; + } + $data = json_decode($json, true); + if (!isset($data['status']) || (int)$data['status'] !== 1 || empty($data['product'])) { + return null; + } + $p = $data['product']; + $altName = $p['product_name_it'] ?? $p['product_name'] ?? ''; + if ($altName === '') { + return null; + } + $altCat = $p['categories_tags'][0] ?? end($p['categories_hierarchy'] ?? []) ?? ''; + return [ + 'name' => $altName, + 'brand' => $p['brands'] ?? '', + 'category' => $altCat, + 'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '', + 'quantity_info' => $p['quantity'] ?? '', + 'nutriscore' => '', 'ingredients' => '', 'allergens' => '', + 'conservation' => '', 'origin' => '', 'nova_group' => '', + 'ecoscore' => '', 'labels' => '', 'stores' => '', + ]; +} + +function _parseUpcItemDbJson(?string $json): ?array { + if (!$json) { + return null; + } + $data = json_decode($json, true); + if (empty($data['items'][0])) { + return null; + } + $item = $data['items'][0]; + if (empty($item['title'])) { + return null; + } + return [ + 'name' => $item['title'] ?? '', + 'brand' => $item['brand'] ?? '', + 'category' => $item['category'] ?? '', + 'image_url' => $item['images'][0] ?? '', + 'quantity_info' => '', + 'nutriscore' => '', 'ingredients' => '', 'allergens' => '', + 'conservation' => '', 'origin' => '', 'nova_group' => '', + 'ecoscore' => '', 'labels' => '', 'stores' => '', + ]; +} + +/** + * Query all external barcode DBs in parallel (first wave per candidate, then Gemini). + */ +function barcodeResolveExternal(PDO $db, string $barcode): ?array { + $barcode = barcodeNormalizeDigits($barcode); + if ($barcode === '') { + return null; + } + + $cached = barcodeCacheGet($db, $barcode); + if ($cached !== null) { + return $cached['found'] ? $cached : null; + } + + $offFields = 'product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores,nutriments'; + $altFields = 'product_name,product_name_it,brands,categories_tags,categories_hierarchy,image_front_small_url,image_url,quantity'; + $priority = ['off_it', 'off_world', 'opf', 'obf', 'upc']; + + foreach (barcodeLookupCandidates($barcode) as $bc) { + $requests = [ + 'off_it' => "https://world.openfoodfacts.org/api/v2/product/{$bc}.json?fields={$offFields}&lc=it", + 'off_world' => "https://world.openfoodfacts.org/api/v2/product/{$bc}.json?fields={$offFields}", + 'upc' => "https://api.upcitemdb.com/prod/trial/lookup?upc={$bc}", + 'opf' => "https://world.openproductsfacts.org/api/v2/product/{$bc}.json?fields={$altFields}", + 'obf' => "https://world.openbeautyfacts.org/api/v2/product/{$bc}.json?fields={$altFields}", + ]; + $bodies = barcodeHttpParallel($requests, 4); + foreach ($priority as $key) { + $body = $bodies[$key] ?? null; + $product = null; + $source = null; + if ($key === 'off_it' || $key === 'off_world') { + $product = _parseOffProductJson($body); + $source = $key === 'off_it' ? 'openfoodfacts_it' : 'openfoodfacts'; + } elseif ($key === 'opf') { + $product = _parseAltFactsProductJson($body); + $source = 'openproductsfacts'; + } elseif ($key === 'obf') { + $product = _parseAltFactsProductJson($body); + $source = 'openbeautyfacts'; + } elseif ($key === 'upc') { + $product = _parseUpcItemDbJson($body); + $source = 'upcitemdb'; + } + if ($product) { + $result = ['found' => true, 'source' => $source, 'product' => $product]; + barcodeCacheSet($db, $barcode, $result, true); + return $result; + } + } + } + + $apiKey = env('GEMINI_API_KEY'); + if ($apiKey) { + $geminiProduct = _barcodeLookupGemini($barcode, $apiKey); + if ($geminiProduct !== null) { + $result = ['found' => true, 'source' => 'gemini', 'product' => $geminiProduct]; + barcodeCacheSet($db, $barcode, $result, true); + return $result; + } + } + + barcodeCacheSet($db, $barcode, ['found' => false, 'source' => 'miss'], false); + return null; +} + +/** Local DB first, then parallel external lookup — single round-trip for the client. */ +function resolveBarcode(PDO $db): void { + $barcode = barcodeNormalizeDigits($_GET['barcode'] ?? ''); + if ($barcode === '') { + echo json_encode(['found' => false, 'error' => 'No barcode provided']); + return; + } + + $local = barcodeFindLocalProduct($db, $barcode); + if ($local) { + echo json_encode(['found' => true, 'source' => 'local', 'product' => $local], JSON_UNESCAPED_UNICODE); + return; + } + + $external = barcodeResolveExternal($db, $barcode); + if ($external) { + echo json_encode($external, JSON_UNESCAPED_UNICODE); + return; + } + + echo json_encode(['found' => false, 'source' => 'none']); +} + /** * Returns all in-stock inventory items whose product name shares the same first * significant token as the given name (e.g. "Carote" matches "Carote Bio", "Carote DOP"). @@ -2256,187 +2578,22 @@ function stockForName(PDO $db): void { echo json_encode(['items' => $matches], JSON_UNESCAPED_UNICODE); } -function _offFetchProduct(string $barcode): ?array { - $fields = 'product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores,nutriments'; - - // Try candidate barcodes: given barcode + EAN-13 (UPC-A → prepend 0) - $candidates = [$barcode]; - if (strlen($barcode) === 12 && ctype_digit($barcode)) { - $candidates[] = '0' . $barcode; - } - // Also try without leading zero if 13 digits starting with 0 - if (strlen($barcode) === 13 && $barcode[0] === '0') { - $candidates[] = substr($barcode, 1); - } - - // Locale preference: Italian first (better names), then world-neutral - $locales = ['lc=it', '']; - - foreach ($candidates as $bc) { - foreach ($locales as $lc) { - $lcParam = $lc ? "&{$lc}" : ''; - $url = "https://world.openfoodfacts.org/api/v2/product/{$bc}.json?fields={$fields}{$lcParam}"; - $ctx = stream_context_create(['http' => ['timeout' => 8, 'header' => "User-Agent: EverShelf/1.0\r\n"]]); - - $response = @file_get_contents($url, false, $ctx); - if ($response === false) { - // Network error: retry once after short delay - usleep(300000); // 0.3s - $response = @file_get_contents($url, false, $ctx); - } - if ($response === false) continue; - - $data = json_decode($response, true); - if (!isset($data['status']) || $data['status'] !== 1 || empty($data['product'])) continue; - - $p = $data['product']; - - // Prefer Italian name, fall back to generic / any locale - $name = ''; - foreach (['product_name_it', 'generic_name_it', 'product_name', 'generic_name'] as $f) { - if (!empty($p[$f])) { $name = $p[$f]; break; } - } - - // Non-Latin script fallback - if (!empty($name) && preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $name)) { - $latinName = ''; - foreach (['generic_name_it', 'generic_name', 'product_name_it', 'product_name'] as $f) { - if (!empty($p[$f]) && !preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $p[$f])) { - $latinName = $p[$f]; break; - } - } - if (empty($latinName)) $latinName = !empty($p['brands']) ? $p['brands'] : 'Prodotto sconosciuto'; - $name = $latinName; - } - - $ingredients = $p['ingredients_text_it'] ?? $p['ingredients_text'] ?? ''; - $catHierarchy = $p['categories_hierarchy'] ?? []; - $category = $p['categories_tags'][0] ?? (empty($catHierarchy) ? null : end($catHierarchy)) ?? $p['categories'] ?? ''; - $allergens = ''; - if (!empty($p['allergens_tags'])) { - $allergens = implode(', ', array_map(fn($a) => str_replace('en:', '', $a), $p['allergens_tags'])); - } - - // Extract macronutrients per 100g (from OFF 'nutriments' field) - $nutriments = null; - if (!empty($p['nutriments']) && is_array($p['nutriments'])) { - $nm = $p['nutriments']; - $nutriments = [ - 'energy_kcal_100g' => isset($nm['energy-kcal_100g']) ? round((float)$nm['energy-kcal_100g'], 1) : (isset($nm['energy_100g']) ? round((float)$nm['energy_100g'] / 4.184, 1) : null), - 'proteins_100g' => isset($nm['proteins_100g']) ? round((float)$nm['proteins_100g'], 1) : null, - 'carbohydrates_100g' => isset($nm['carbohydrates_100g']) ? round((float)$nm['carbohydrates_100g'], 1) : null, - 'fat_100g' => isset($nm['fat_100g']) ? round((float)$nm['fat_100g'], 1) : null, - 'fiber_100g' => isset($nm['fiber_100g']) ? round((float)$nm['fiber_100g'], 1) : null, - 'salt_100g' => isset($nm['salt_100g']) ? round((float)$nm['salt_100g'], 1) : null, - ]; - // Only keep if at least one macro is present - if (!array_filter(array_values($nutriments))) $nutriments = null; - } - - return [ - 'name' => $name, - 'brand' => $p['brands'] ?? '', - 'category' => $category, - 'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '', - 'quantity_info' => $p['quantity'] ?? '', - 'nutriscore' => $p['nutriscore_grade'] ?? '', - 'ingredients' => $ingredients, - 'allergens' => $allergens, - 'conservation' => $p['conservation_conditions_it'] ?? $p['conservation_conditions'] ?? '', - 'origin' => $p['origins_it'] ?? $p['origins'] ?? $p['manufacturing_places'] ?? '', - 'nova_group' => $p['nova_group'] ?? '', - 'ecoscore' => $p['ecoscore_grade'] ?? '', - 'labels' => $p['labels'] ?? '', - 'stores' => $p['stores'] ?? '', - 'nutriments' => $nutriments, - ]; - } - } - return null; -} - function lookupBarcode(): void { - $barcode = $_GET['barcode'] ?? ''; - if (empty($barcode)) { + $barcode = barcodeNormalizeDigits($_GET['barcode'] ?? ''); + if ($barcode === '') { EverLog::info('lookupBarcode'); echo json_encode(['found' => false, 'error' => 'No barcode provided']); return; } - // 1. Try Open Food Facts (multi-barcode, multi-locale, with auto-retry on network errors) - $offProduct = _offFetchProduct($barcode); - if ($offProduct !== null) { - echo json_encode(['found' => true, 'source' => 'openfoodfacts', 'product' => $offProduct]); + $db = getDB(); + $external = barcodeResolveExternal($db, $barcode); + if ($external) { + echo json_encode($external, JSON_UNESCAPED_UNICODE); return; } - // 2. Try UPC Item DB as fallback - $candidates = [$barcode]; - if (strlen($barcode) === 12 && ctype_digit($barcode)) $candidates[] = '0' . $barcode; - foreach ($candidates as $bc) { - $url2 = "https://api.upcitemdb.com/prod/trial/lookup?upc={$bc}"; - $ctx2 = stream_context_create(['http' => ['timeout' => 8, 'header' => "User-Agent: EverShelf/1.0\r\n"]]); - $r2 = @file_get_contents($url2, false, $ctx2); - if ($r2 !== false) { - $d2 = json_decode($r2, true); - if (!empty($d2['items'][0])) { - $item = $d2['items'][0]; - echo json_encode(['found' => true, 'source' => 'upcitemdb', 'product' => [ - 'name' => $item['title'] ?? '', - 'brand' => $item['brand'] ?? '', - 'category' => $item['category'] ?? '', - 'image_url' => $item['images'][0] ?? '', - ]]); - return; - } - } - } - - // 3. Try Open Products Facts (non-food household items) and Open Beauty Facts (cosmetics) - $altBases = [ - 'https://world.openproductsfacts.org', - 'https://world.openbeautyfacts.org', - ]; - $altFields = 'product_name,product_name_it,brands,categories_tags,categories_hierarchy,image_front_small_url,image_url,quantity'; - $altCandidates = [$barcode]; - if (strlen($barcode) === 12 && ctype_digit($barcode)) $altCandidates[] = '0' . $barcode; - foreach ($altBases as $altBase) { - foreach ($altCandidates as $bc) { - $altUrl = "{$altBase}/api/v2/product/{$bc}.json?fields={$altFields}"; - $altCtx = stream_context_create(['http' => ['timeout' => 6, 'header' => "User-Agent: EverShelf/1.0\r\n"]]); - $altR = @file_get_contents($altUrl, false, $altCtx); - if ($altR === false) continue; - $altD = json_decode($altR, true); - if (!isset($altD['status']) || $altD['status'] !== 1 || empty($altD['product'])) continue; - $p = $altD['product']; - $altName = $p['product_name_it'] ?? $p['product_name'] ?? ''; - if (empty($altName)) continue; - $altCat = $p['categories_tags'][0] ?? end($p['categories_hierarchy'] ?? []) ?? ''; - echo json_encode(['found' => true, 'source' => $altBase, 'product' => [ - 'name' => $altName, - 'brand' => $p['brands'] ?? '', - 'category' => $altCat, - 'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '', - 'quantity_info' => $p['quantity'] ?? '', - 'nutriscore' => '', 'ingredients' => '', 'allergens' => '', - 'conservation' => '', 'origin' => '', 'nova_group' => '', - 'ecoscore' => '', 'labels' => '', 'stores' => '', - ]]); - return; - } - } - - // 4. Gemini AI as last resort — works for well-known products not in any open DB - $apiKey = env('GEMINI_API_KEY'); - if ($apiKey) { - $geminiProduct = _barcodeLookupGemini($barcode, $apiKey); - if ($geminiProduct !== null) { - echo json_encode(['found' => true, 'source' => 'gemini', 'product' => $geminiProduct]); - return; - } - } - - echo json_encode(['found' => false, 'source' => 'openfoodfacts']); + echo json_encode(['found' => false, 'source' => 'none']); } /** @@ -2509,10 +2666,12 @@ function saveProduct(PDO $db): void { ? $input['shopping_name'] : computeShoppingName($input['name'], $input['category'] ?? '', $input['brand'] ?? ''); + $barcode = normalizeProductBarcode($input['barcode'] ?? null); + $id = !empty($input['id']) ? (int)$input['id'] : 0; $merged = false; if (!$id) { - $dupId = findDuplicateProductId($db, $input['name'], $input['brand'] ?? '', $input['barcode'] ?? null, null); + $dupId = findDuplicateProductId($db, $input['name'], $input['brand'] ?? '', $barcode, null); if ($dupId) { $id = $dupId; $merged = true; @@ -2532,7 +2691,7 @@ function saveProduct(PDO $db): void { $input['name'], $input['brand'] ?? '', $input['category'] ?? '', $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, $input['notes'] ?? '', - $input['barcode'] ?? null, $input['package_unit'] ?? '', + $barcode, $input['package_unit'] ?? '', $shoppingName, $nutriJson, $id ]); echo json_encode(['success' => true, 'id' => $id, 'merged' => $merged]); @@ -2542,7 +2701,6 @@ function saveProduct(PDO $db): void { INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit, shopping_name, nutriments_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); - $barcode = !empty($input['barcode']) ? $input['barcode'] : null; $nutriJson = isset($input['nutriments']) ? json_encode($input['nutriments']) : null; $stmt->execute([ $barcode, $input['name'], $input['brand'] ?? '', @@ -2850,6 +3008,7 @@ function addToInventory(PDO $db): void { } $vacuumSealed = (int)($input['vacuum_sealed'] ?? 0); + $expiryUserSet = (int)($input['expiry_user_set'] ?? 0); // Check if a SEALED (not yet opened) row exists for this product+location. // We merge new stock into a sealed row only — never into an already-opened @@ -2866,13 +3025,13 @@ function addToInventory(PDO $db): void { if ($existing) { // Merge into the existing sealed row $newQty = $existing['quantity'] + $quantity; - $stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), vacuum_sealed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); - $stmt->execute([$newQty, $expiry, $vacuumSealed, $existing['id']]); + $stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), vacuum_sealed = ?, expiry_user_set = CASE WHEN ? = 1 THEN 1 ELSE expiry_user_set END, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$newQty, $expiry, $vacuumSealed, $expiryUserSet, $existing['id']]); } else { $newQty = $quantity; // All existing rows (if any) are opened packs — insert a new sealed row - $stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)"); - $stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed]); + $stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, expiry_user_set) VALUES (?, ?, ?, ?, ?, ?)"); + $stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed, $expiryUserSet]); } // Get total across all locations @@ -3331,6 +3490,7 @@ function updateInventory(PDO $db): void { if (isset($input['quantity'])) { $fields[] = "quantity = ?"; $params[] = $input['quantity']; } if (isset($input['location'])) { $fields[] = "location = ?"; $params[] = $input['location']; } if (isset($input['expiry_date'])) { $fields[] = "expiry_date = ?"; $params[] = $input['expiry_date'] ?: null; } + if (array_key_exists('expiry_user_set', $input)) { $fields[] = "expiry_user_set = ?"; $params[] = (int)$input['expiry_user_set']; } if (isset($input['vacuum_sealed'])) { $fields[] = "vacuum_sealed = ?"; $params[] = (int)$input['vacuum_sealed']; } if (isset($input['opened_at_clear']) && $input['opened_at_clear']) { $fields[] = "opened_at = NULL"; } $fields[] = "updated_at = CURRENT_TIMESTAMP"; @@ -3432,6 +3592,14 @@ function productQtyThreshold(string $unit): float { return $thresholds[$unit] ?? 0.5; } +function normalizeProductBarcode($barcode): ?string { + if ($barcode === null) { + return null; + } + $barcode = trim((string)$barcode); + return $barcode === '' ? null : $barcode; +} + function normalizeProductName(string $name): string { return mb_strtolower(trim($name)); } @@ -7675,8 +7843,24 @@ function productMatchesShoppingFamily(string $productName, string $shoppingName) return $nameLower === $sn || str_starts_with($nameLower, $sn . ' '); } +/** Rice/pasta prepared salads (Ponti etc.) — not fresh leafy salad. */ +function isPreparedSaladProduct(string $name, string $brand = ''): bool { + $n = mb_strtolower(trim($name)); + $b = mb_strtolower(trim($brand)); + if (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous|quinoa|bulgur|cereali|legumi)\b/u', $n)) { + return true; + } + if (preg_match('/\binsalata\b/u', $n) && preg_match('/\b(ponti|rio mare|orogel|findus|star)\b/u', $b)) { + return true; + } + return false; +} + function computeShoppingName(string $name, string $category = '', string $brand = ''): string { $lower = mb_strtolower(trim($name)); + if (isPreparedSaladProduct($name, $brand) && !preg_match('/insalata\s+di\s+riso/u', $lower)) { + return 'Insalata di riso'; + } $stop = ['di','del','della','dei','degli','delle','da','in','con','per','su', 'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo', 'parzialmente','scremato','uht','bio','light','freschi','fresca','fresco']; @@ -7773,6 +7957,13 @@ function computeShoppingName(string $name, string $category = '', string $brand 'aroma limone' => 'Ingredienti Spezie', 'aroma rum' => 'Ingredienti Spezie', 'aroma arancia' => 'Ingredienti Spezie', + // Prepared salads (not fresh greens) + 'insalata di riso' => 'Insalata di riso', + 'insalata di pasta' => 'Insalata di pasta', + 'insalata di farro' => 'Insalata di farro', + 'insalata di orzo' => 'Insalata di orzo', + 'insalata di couscous' => 'Insalata di couscous', + 'insalata di quinoa' => 'Insalata di quinoa', ]; foreach ($phraseMap as $phrase => $canonical) { if (mb_strpos($lower, $phrase) !== false) { @@ -10916,17 +11107,21 @@ function familySiblingSuggest(PDO $db): void { return; } - $stockQty = (float)$sibling['stock_qty']; - $unit = $sibling['unit'] ?: 'pz'; - $displayQty = $stockQty; - $displayUnit = $unit; - $pkgUnit = strtolower($sibling['package_unit'] ?? ''); - $defQty = (float)($sibling['default_quantity'] ?? 0); - if ($unit === 'conf' && $defQty > 0 && in_array($pkgUnit, ['g', 'ml', 'kg', 'l', 'lt'], true)) { - $mult = in_array($pkgUnit, ['kg', 'l', 'lt'], true) ? 1000 : 1; - $displayQty = round($stockQty * $defQty * $mult, $pkgUnit === 'g' || $pkgUnit === 'ml' ? 0 : 2); - $displayUnit = in_array($pkgUnit, ['kg', 'l', 'lt'], true) ? ($pkgUnit === 'kg' ? 'g' : 'ml') : $pkgUnit; + $inventoryId = (int)($sibling['inventory_id'] ?? 0); + if ($inventoryId <= 0) { + echo json_encode(['success' => true, 'sibling' => null]); + return; } + $invChk = $db->prepare("SELECT quantity FROM inventory WHERE id = ? AND quantity > 0.001"); + $invChk->execute([$inventoryId]); + $liveQty = $invChk->fetchColumn(); + if ($liveQty === false) { + echo json_encode(['success' => true, 'sibling' => null]); + return; + } + + $stockQty = (float)$liveQty; + $unit = $sibling['unit'] ?: 'pz'; echo json_encode([ 'success' => true, @@ -10937,8 +11132,10 @@ function familySiblingSuggest(PDO $db): void { 'brand' => $sibling['brand'] ?? '', 'category' => $sibling['category'] ?? '', 'image_url' => $sibling['image_url'] ?? '', - 'stock_qty' => round($displayQty, 2), - 'unit' => $displayUnit, + 'stock_qty' => round($stockQty, 3), + 'unit' => $unit, + 'default_quantity' => (float)($sibling['default_quantity'] ?? 0), + 'package_unit' => $sibling['package_unit'] ?? '', 'family' => $sName, 'location' => $location, 'added_at' => $sibling['added_at'] ?? null, diff --git a/api/lib/security.php b/api/lib/security.php index d666be3..9f518c0 100644 --- a/api/lib/security.php +++ b/api/lib/security.php @@ -155,7 +155,7 @@ function evershelfSendCorsHeaders(): void { function evershelfDemoReadOnlyActions(): array { return [ 'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage', - 'search_barcode', 'lookup_barcode', 'stock_for_name', + 'search_barcode', 'lookup_barcode', 'resolve_barcode', 'stock_for_name', 'product_get', 'products_list', 'products_search', 'inventory_search', 'ai_product_suggest', 'inventory_list', 'inventory_summary', 'inventory_finished_items', 'transactions_list', 'stats', 'monthly_stats', 'macro_stats', diff --git a/assets/css/style.css b/assets/css/style.css index 957709f..e9c41e3 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -730,7 +730,7 @@ body.server-offline .bottom-nav { margin: 0; text-transform: uppercase; letter-spacing: 0.03em; - opacity: 0.8; + color: rgba(255, 255, 255, 0.85); } .family-sibling-prompt-name { font-size: 0.95rem; @@ -743,17 +743,25 @@ body.server-offline .bottom-nav { -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.family-sibling-prompt-stock { + font-size: 1.05rem; + font-weight: 800; + line-height: 1.3; + margin: 4px 0 0; + color: #bbf7d0; +} .family-sibling-prompt-meta { font-size: 0.8rem; line-height: 1.3; margin: 0; - opacity: 0.88; + color: rgba(255, 255, 255, 0.78); } .family-sibling-prompt-question { font-size: 0.82rem; line-height: 1.25; margin: 2px 0 0; - opacity: 0.9; + color: #f8fafc; + font-weight: 600; } .family-sibling-prompt-actions { display: flex; diff --git a/assets/js/app.js b/assets/js/app.js index 125cbe5..074965d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1108,7 +1108,7 @@ async function discoverScaleGateway() { } // ===== i18n TRANSLATION SYSTEM ===== -const _I18N_VERSION = '20260606b'; // bump when translations change +const _I18N_VERSION = '20260606n'; // bump when translations change let _i18nStrings = null; // current language translations (flat) let _i18nFallback = null; // Italian fallback (flat) let _i18nLoadedVersion = null; @@ -1382,9 +1382,9 @@ const URGENCY_BG = { }; // Map Open Food Facts categories to local categories -function mapToLocalCategory(ofCategory, productName) { +function mapToLocalCategory(ofCategory, productName, productBrand = '') { if (!ofCategory) { - return guessCategoryFromName(productName || ''); + return guessCategoryFromName(productName || '', productBrand || ''); } const cat = ofCategory.toLowerCase(); // Direct match with our local keys — but NOT 'altro': fall through to name guess @@ -1395,7 +1395,7 @@ function mapToLocalCategory(ofCategory, productName) { // Handle specific Open Food Facts tags FIRST (before generic regex) // "plant-based-foods-and-beverages" is a catch-all — use product name to decide if (/plant-based-foods/.test(cat)) { - return guessCategoryFromName(productName || ''); + return guessCategoryFromName(productName || '', productBrand || ''); } // "beverages-and-beverages-preparations" = actual beverages if (/^en:beverages/.test(cat)) return 'bevande'; @@ -1413,7 +1413,10 @@ function mapToLocalCategory(ofCategory, productName) { if (/meat|viande|carne|sausage|salum|prosciutt/.test(cat)) return 'carne'; if (/fish|poisson|pesce|seafood|tuna|tonno|salmone/.test(cat)) return 'pesce'; if (/fruit|frutta|juice|succo|apple|banana/.test(cat)) return 'frutta'; - if (/vegetable|verdur|legum|salad|insalat|tomato|pomodor/.test(cat)) return 'verdura'; + if (/salad|insalat/.test(cat)) { + return _isPreparedSaladName(productName, productBrand) ? 'pasta' : 'verdura'; + } + if (/vegetable|verdur|legum|tomato|pomodor/.test(cat)) return 'verdura'; if (/pasta|rice|riso|noodle|spaghetti|penne|grain/.test(cat)) return 'pasta'; if (/bread|pane|forno|biscott|toast|cracker|grissini|fette/.test(cat)) return 'pane'; if (/frozen|surgelé|surgel|gelat/.test(cat)) return 'surgelati'; @@ -1426,15 +1429,26 @@ function mapToLocalCategory(ofCategory, productName) { // Beverage check LAST (to avoid false matches on compound tags) if (/^(?!.*plant-based).*(beverage|drink|boisson|bevand|water|acqua|beer|birra|wine|vino|coffee|caffè|tea\b)/.test(cat)) return 'bevande'; // Last resort: try product name before giving up - const nameGuess = guessCategoryFromName(productName || ''); + const nameGuess = guessCategoryFromName(productName || '', productBrand || ''); if (nameGuess !== 'altro') return nameGuess; return 'altro'; } +/** Prepared rice/pasta salads — not fresh leafy salad (verdura). */ +function _isPreparedSaladName(name, brand = '') { + const n = (name || '').toLowerCase(); + const b = (brand || '').toLowerCase(); + if (/insalata\s+di\s+(riso|pasta|farro|orzo|couscous|quinoa|bulgur|cereali|legumi)\b/.test(n)) return true; + if (/\b(riso|pasta)\s+con\b/.test(n) && /\binsalata\b/.test(n)) return true; + if (/\binsalata\b/.test(n) && /\b(ponti|rio mare|orogel|findus|star)\b/.test(b)) return true; + return false; +} + // Guess a local category purely from product name -function guessCategoryFromName(name) { +function guessCategoryFromName(name, brand = '') { if (!name) return 'altro'; const n = name.toLowerCase(); + if (_isPreparedSaladName(n, brand)) return 'pasta'; // ── Known Italian brand names → direct category (fast-path before regex) // "Uno" only if it starts the name (Bahlsen biscuits, not the Italian word) if (/^uno\b/.test(n)) return 'snack'; @@ -1712,6 +1726,7 @@ function estimateExpiryDays(product, location) { else if (/uova/.test(name)) days = 28; else if (/pane\s+fresco|pane\s+in\s+cassetta/.test(name)) days = 5; else if (/pane\s+confezionato|pan\s+carr|pancarrè/.test(name)) days = 14; + else if (/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/.test(name)) days = 7; else if (/insalata|rucola|spinaci\s+freschi/.test(name)) days = 5; else if (/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/.test(name)) days = 3; else if (/salmone|tonno\s+fresco|pesce/.test(name) && !/tonno\s+in\s+scatola|tonno\s+rio/.test(name)) days = 2; @@ -1854,6 +1869,7 @@ function estimateOpenedExpiryDays(product, location) { if (/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/.test(name)) return 2; if (/salmone|tonno\s+fresco|pesce(?!\s+in)/.test(name)) return 2; if (/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/.test(name)) return 5; + if (/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/.test(name)) return 7; if (/insalata|rucola|spinaci|lattuga|crescione|germogli/.test(name)) return 4; if (/\b(succo|spremuta)\b/.test(name)) return 3; if (/\b(birra|beer)\b/.test(name)) return 3; @@ -2042,8 +2058,15 @@ function addToScanRecents(product) { _saveToServer('scan_history', list); } -function updateScanRecents() { - const list = (_scanHistoryCache || []).slice(0, 6); +async function updateScanRecents() { + let list = (_scanHistoryCache || []).slice(0, 6); + if (_spesaMode && list.length > 0) { + try { + const data = await api('inventory_list'); + const stocked = new Set((data.inventory || []).filter(i => parseFloat(i.quantity) > 0).map(i => i.product_id)); + list = list.filter(r => stocked.has(r.id)); + } catch (_) { /* keep list on error */ } + } const wrap = document.getElementById('scan-recents'); const chips = document.getElementById('scan-recents-chips'); if (!wrap || !chips) return; @@ -2058,9 +2081,23 @@ function updateScanRecents() { }).join(''); } +async function _productHasLiveStock(productId) { + try { + const data = await api('inventory_list'); + return (data.inventory || []).some(i => i.product_id == productId && parseFloat(i.quantity) > 0); + } catch (_) { + return true; + } +} + async function _selectRecentProduct(productId) { showLoading(true); try { + if (_spesaMode && !(await _productHasLiveStock(productId))) { + showLoading(false); + showToast(t('error.not_in_inventory'), 'error'); + return; + } const data = await api('product_get', { id: productId }); if (data.product) { currentProduct = data.product; @@ -2251,9 +2288,9 @@ function _showAiMatchChoices(aiProduct) { const aiName = aiProduct?.name || t('product.not_recognized'); const aiBrand = aiProduct?.brand || ''; const catIcon = CATEGORY_ICONS[mapToLocalCategory(aiProduct?.category || '', aiName)] || '📦'; - const inStock = _aiInventoryCandidates || []; - const finished = _aiFinishedCandidates || []; - const catalog = _aiCatalogCandidates || []; + const inStock = (_aiInventoryCandidates || []).filter(i => parseFloat(i.total_qty) > 0); + const finished = _spesaMode ? [] : (_aiFinishedCandidates || []); + const catalog = _spesaMode ? [] : (_aiCatalogCandidates || []); const hasMatches = inStock.length + finished.length + catalog.length > 0; const addLabel = t('scan.ai_match_add_btn').replace('{name}', aiName); @@ -2367,11 +2404,18 @@ async function _selectAiProductCandidate(kind, idx) { if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim(); } currentProduct._confCount = 0; + const hasStock = kind === 'stock' ? await _productHasLiveStock(p.id) : false; addToScanRecents(currentProduct); _clearAiMatchPanel(); showLoading(false); - if (kind === 'finished') { - showToast(t('scan.ai_match_finished_hint'), 'info'); + if (kind !== 'stock' || !hasStock) { + if (_spesaMode || kind === 'stock') { + showToast(t('error.not_in_inventory'), 'info'); + } else if (kind === 'finished') { + showToast(t('scan.ai_match_finished_hint'), 'info'); + } + setTimeout(() => showAddForm(), 250); + return; } setTimeout(() => showProductAction(), 250); } catch (err) { @@ -2613,6 +2657,7 @@ async function syncSettingsFromDB() { if (srv.auto_added_bring) _autoAddedBringCache = srv.auto_added_bring; if (srv.bring_blocklist) _bringBlocklistCache = srv.bring_blocklist; if (srv.no_expiry_dismissed) _noExpiryDismissedCache = srv.no_expiry_dismissed; + if (srv.family_sibling_confirmed) _familySiblingConfirmedCache = srv.family_sibling_confirmed; // ── One-time migration: if server has nothing yet, seed from old localStorage ── if (!srv.shopping_tags) { @@ -5309,6 +5354,7 @@ let _prefMoveLocCache = {}; let _autoAddedBringCache = {}; let _bringBlocklistCache = {}; let _noExpiryDismissedCache = {}; +let _familySiblingConfirmedCache = {}; let _scanHistoryCache = []; function _saveToServer(key, value) { api('app_settings_save', {}, 'POST', { settings: { [key]: value } }).catch(() => {}); @@ -6772,7 +6818,32 @@ async function _discardAllFromModal(inventoryId) { } } +/** Track manual expiry edits — auto-recalc on location/vacuum must not overwrite user dates. */ +function _initExpiryManualTracking(inputId, item) { + const el = document.getElementById(inputId); + if (!el) return; + if (item?.expiry_user_set) el.dataset.manuallySet = 'true'; + else delete el.dataset.manuallySet; + if (el.dataset.expiryTrackBound) return; + el.dataset.expiryTrackBound = '1'; + const mark = () => { + if (el.value) el.dataset.manuallySet = 'true'; + else delete el.dataset.manuallySet; + }; + el.addEventListener('input', mark); + el.addEventListener('change', mark); +} + +function _isExpiryManuallySet(inputId) { + return document.getElementById(inputId)?.dataset.manuallySet === 'true'; +} + +function _expiryUserSetPayload(inputId) { + return _isExpiryManuallySet(inputId) ? 1 : 0; +} + function recalcEditExpiry(locInputId, vacuumInputId, expiryInputId) { + if (_isExpiryManuallySet(expiryInputId)) return; const product = window._editingProduct; if (!product) return; const loc = document.getElementById(locInputId)?.value || ''; @@ -6879,6 +6950,7 @@ function editInventoryItem(id) { `; document.getElementById('modal-overlay').style.display = 'flex'; + _initExpiryManualTracking('edit-expiry', item); } function onEditUnitChange() { @@ -6929,7 +7001,8 @@ async function submitEditInventory(e, id, productId) { } const payload = { id, quantity: qty, location: loc, expiry_date: expiry, unit, product_id: productId, - vacuum_sealed: document.getElementById('edit-vacuum')?.checked ? 1 : 0 }; + vacuum_sealed: document.getElementById('edit-vacuum')?.checked ? 1 : 0, + expiry_user_set: _expiryUserSetPayload('edit-expiry') }; // Add package info if conf if (unit === 'conf') { @@ -7104,6 +7177,45 @@ function _setScanStatus(msg, state, method) { } // ===== BARCODE ENGINE INIT (Native + ZBar WASM) ===== +let _zbarVendorPromise = null; + +/** Lazy-load ZBar WASM (skipped at page load on kiosk WebView to reduce OOM risk). */ +function _loadZbarVendor() { + if (typeof barcodeDetectorPolyfill !== 'undefined') return Promise.resolve(); + if (_zbarVendorPromise) return _zbarVendorPromise; + _zbarVendorPromise = new Promise((resolve, reject) => { + const done = () => { + if (typeof barcodeDetectorPolyfill !== 'undefined') resolve(); + else reject(new Error('ZBar polyfill unavailable')); + }; + const loadPoly = () => { + const s2 = document.createElement('script'); + s2.src = 'assets/vendor/zbar/polyfill.js?v=20260606a'; + s2.onload = done; + s2.onerror = () => reject(new Error('ZBar polyfill load failed')); + document.head.appendChild(s2); + }; + if (window.zbarWasm) { + loadPoly(); + return; + } + const s1 = document.createElement('script'); + s1.src = 'assets/vendor/zbar/index.js?v=20260606a'; + s1.onload = () => { + if (window.zbarWasm && zbarWasm.setModuleArgs) { + zbarWasm.setModuleArgs({ locateFile: (file) => 'assets/vendor/zbar/' + file }); + } + loadPoly(); + }; + s1.onerror = () => reject(new Error('ZBar WASM load failed')); + document.head.appendChild(s1); + }).catch(err => { + _zbarVendorPromise = null; + throw err; + }); + return _zbarVendorPromise; +} + function _startBestScanner(videoEl) { if (_detectorNative || _detectorZbar) { startUnifiedScanner(videoEl); @@ -7133,6 +7245,11 @@ function _ensureBarcodeEngines() { scanLog(`Native BarcodeDetector init failed: ${e.message}`); } } + if (typeof barcodeDetectorPolyfill === 'undefined') { + try { await _loadZbarVendor(); } catch (e) { + scanLog(`ZBar vendor load failed: ${e.message}`); + } + } if (typeof barcodeDetectorPolyfill !== 'undefined') { try { _detectorZbar = new barcodeDetectorPolyfill.BarcodeDetectorPolyfill({ formats: _SCAN_FORMATS }); @@ -7628,135 +7745,158 @@ function stopScanner() { if (aiVideo) aiVideo.srcObject = null; } +const _barcodeSessionCache = new Map(); + +function _barcodeCacheKey(barcode) { + return String(barcode || '').replace(/\D/g, ''); +} + +/** Fix unit/qty from stored notes; fire-and-forget DB update when needed. */ +function _applyLocalBarcodeProductFixes(product) { + if (!product) return; + if (product.unit === 'pz' && product.default_quantity === 0 && product.notes) { + const pesoMatch = product.notes.match(/Peso:\s*([^·]+)/); + if (pesoMatch) { + const weightStr = pesoMatch[1].trim(); + const detected = detectUnitAndQuantity(weightStr); + if (detected.unit !== 'pz') { + product.unit = detected.unit; + product.default_quantity = detected.quantity; + product.weight_info = weightStr; + if (detected.packageUnit) product.package_unit = detected.packageUnit; + if (detected.confCount) product._confCount = detected.confCount; + api('product_save', {}, 'POST', { + id: product.id, + barcode: product.barcode, + name: product.name, + brand: product.brand || '', + category: product.category || '', + image_url: product.image_url || '', + unit: detected.unit, + default_quantity: detected.quantity, + package_unit: detected.packageUnit || '', + notes: product.notes, + }).catch(() => {}); + } + } + } + if (!product.weight_info && product.notes) { + const pesoMatch = product.notes.match(/Peso:\s*([^·]+)/); + if (pesoMatch) product.weight_info = pesoMatch[1].trim(); + } + if (product.weight_info && product.unit === 'conf' && !product._confCount) { + const detected = detectUnitAndQuantity(product.weight_info); + if (detected.confCount) product._confCount = detected.confCount; + } +} + +function _externalBarcodeNotes(p) { + const notesParts = []; + if (p.quantity_info) notesParts.push(`${t('product.weight_label')}: ${p.quantity_info}`); + if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`); + if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`); + if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`); + if (p.origin) notesParts.push(`${t('product.origin_label')}: ${p.origin}`); + if (p.labels) notesParts.push(`${t('product.labels_label')}: ${p.labels}`); + return notesParts.join(' · '); +} + +function _currentProductFromExternal(p, barcode, saveId) { + const detected = detectUnitAndQuantity(p.quantity_info); + return { + id: saveId, + barcode: barcode, + name: p.name || t('product.not_recognized'), + brand: p.brand || '', + category: p.category || '', + image_url: p.image_url || '', + unit: detected.unit, + default_quantity: detected.quantity, + package_unit: detected.packageUnit || '', + _confCount: detected.confCount || 0, + weight_info: p.quantity_info || '', + nutriscore: p.nutriscore || '', + ingredients: p.ingredients || '', + allergens: p.allergens || '', + conservation: p.conservation || '', + origin: p.origin || '', + nova_group: p.nova_group || '', + ecoscore: p.ecoscore || '', + labels: p.labels || '', + stores: p.stores || '', + }; +} + +function _finishBarcodeResolved(barcode) { + showLoading(false); + addToScanRecents(currentProduct); + _showScanConfirm(currentProduct.name); + stopScanner(); + const delay = _spesaMode ? 120 : 300; + const next = _spesaMode ? showAddForm : showProductAction; + setTimeout(() => next(), delay); +} + +async function _resolveBarcodeLookup(barcode) { + const key = _barcodeCacheKey(barcode); + if (_barcodeSessionCache.has(key)) { + return _barcodeSessionCache.get(key); + } + const result = await api('resolve_barcode', { barcode: key }); + _barcodeSessionCache.set(key, result); + return result; +} + +async function _handleBarcodeResolve(result, barcode) { + const code = _barcodeCacheKey(barcode); + if (!result?.found) return false; + + if (result.source === 'local' && result.product) { + currentProduct = result.product; + _applyLocalBarcodeProductFixes(currentProduct); + _finishBarcodeResolved(code); + return true; + } + + if (result.product) { + const p = result.product; + const detected = detectUnitAndQuantity(p.quantity_info); + const saveResult = await api('product_save', {}, 'POST', { + barcode: code, + name: p.name || t('product.not_recognized'), + brand: p.brand || '', + category: p.category || '', + image_url: p.image_url || '', + unit: detected.unit, + default_quantity: detected.quantity, + package_unit: detected.packageUnit || '', + notes: _externalBarcodeNotes(p), + }); + if (saveResult.id) { + currentProduct = _currentProductFromExternal(p, code, saveResult.id); + _finishBarcodeResolved(code); + return true; + } + } + return false; +} + async function onBarcodeDetected(barcode) { _dismissFamilySiblingPrompt(); _resetAiFallbackForNewScan(); showLoading(true); - - // Vibrate if available + if (navigator.vibrate) navigator.vibrate(100); - + try { - // First check local DB - const localResult = await api('search_barcode', { barcode }); - if (localResult.found) { - currentProduct = localResult.product; - // If product was saved with 'pz' but has weight info in notes, fix defaults. - // Only run if default_quantity === 0 (strictly unset): a value of 1 or higher - // means the user (or a previous auto-detect pass) already confirmed the unit, - // and re-running here would undo manual corrections. - if (currentProduct.unit === 'pz' && currentProduct.default_quantity === 0 && currentProduct.notes) { - const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/); - if (pesoMatch) { - const weightStr = pesoMatch[1].trim(); - const detected = detectUnitAndQuantity(weightStr); - if (detected.unit !== 'pz') { - currentProduct.unit = detected.unit; - currentProduct.default_quantity = detected.quantity; - currentProduct.weight_info = weightStr; - if (detected.packageUnit) currentProduct.package_unit = detected.packageUnit; - if (detected.confCount) currentProduct._confCount = detected.confCount; - // Update product in DB for future scans - api('product_save', {}, 'POST', { - id: currentProduct.id, - barcode: currentProduct.barcode, - name: currentProduct.name, - brand: currentProduct.brand || '', - category: currentProduct.category || '', - image_url: currentProduct.image_url || '', - unit: detected.unit, - default_quantity: detected.quantity, - package_unit: detected.packageUnit || '', - notes: currentProduct.notes, - }); - } - } - } - // Extract weight_info from notes if available (stored as "Peso: 500 g · ...") - if (!currentProduct.weight_info && currentProduct.notes) { - const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/); - if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim(); - } - // Detect confCount from weight_info for multipack pre-fill - if (currentProduct.weight_info && currentProduct.unit === 'conf' && !currentProduct._confCount) { - const detected = detectUnitAndQuantity(currentProduct.weight_info); - if (detected.confCount) currentProduct._confCount = detected.confCount; - } - showLoading(false); - addToScanRecents(currentProduct); - _showScanConfirm(currentProduct.name); - stopScanner(); - setTimeout(() => showProductAction(), 300); - return; - } - - // Lookup in external DB - const lookupResult = await api('lookup_barcode', { barcode }); - if (lookupResult.found && lookupResult.product) { - const p = lookupResult.product; - // Detect unit and quantity from quantity_info - const detected = detectUnitAndQuantity(p.quantity_info); - - // Build rich notes with all available info - const notesParts = []; - if (p.quantity_info) notesParts.push(`${t('product.weight_label')}: ${p.quantity_info}`); - if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`); - if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`); - if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`); - if (p.origin) notesParts.push(`${t('product.origin_label')}: ${p.origin}`); - if (p.labels) notesParts.push(`${t('product.labels_label')}: ${p.labels}`); - - // Save to local DB - const saveResult = await api('product_save', {}, 'POST', { - barcode: barcode, - name: p.name || t('product.not_recognized'), - brand: p.brand || '', - category: p.category || '', - image_url: p.image_url || '', - unit: detected.unit, - default_quantity: detected.quantity, - package_unit: detected.packageUnit || '', - notes: notesParts.join(' · '), - }); - - if (saveResult.id) { - currentProduct = { - id: saveResult.id, - barcode: barcode, - name: p.name || t('product.not_recognized'), - brand: p.brand || '', - category: p.category || '', - image_url: p.image_url || '', - unit: detected.unit, - default_quantity: detected.quantity, - package_unit: detected.packageUnit || '', - _confCount: detected.confCount || 0, - weight_info: p.quantity_info || '', - nutriscore: p.nutriscore || '', - ingredients: p.ingredients || '', - allergens: p.allergens || '', - conservation: p.conservation || '', - origin: p.origin || '', - nova_group: p.nova_group || '', - ecoscore: p.ecoscore || '', - labels: p.labels || '', - stores: p.stores || '', - }; - showLoading(false); - addToScanRecents(currentProduct); - _showScanConfirm(currentProduct.name); - stopScanner(); - setTimeout(() => showProductAction(), 300); - return; - } - } - - // Not found — keep camera running and let user scan again or add manually + const code = _barcodeCacheKey(barcode); + const result = await _resolveBarcodeLookup(code); + if (await _handleBarcodeResolve(result, code)) return; + showLoading(false); showToast(t('error.not_found_manual'), 'error'); _setScanStatus(t('scan.status_scanning'), '', ''); resumeScanner(); - } catch (err) { showLoading(false); console.error('Barcode lookup error:', err); @@ -8373,7 +8513,7 @@ function showProductAction() { // Always build the edit form, but only show it auto-opened for unknown products const categoryOptions = Object.entries(CATEGORY_LABELS).map(([key, label]) => - `` + `` ).join(''); editInfoEl.innerHTML = ` @@ -8396,6 +8536,10 @@ function showProductAction() { ${categoryOptions} +
+ + +
@@ -8621,7 +8765,7 @@ function editProductFromAction() { document.getElementById('pf-brand').setAttribute('list', 'common-brands'); // Set category - const cat = mapToLocalCategory(currentProduct.category, currentProduct.name); + const cat = mapToLocalCategory(currentProduct.category, currentProduct.name, currentProduct.brand); document.getElementById('pf-category').value = cat; document.getElementById('pf-category').dataset.manuallySet = 'true'; document.getElementById('pf-defqty').dataset.manuallySet = 'true'; @@ -8754,6 +8898,7 @@ function editActionInventoryItem(inventoryId) { `; document.getElementById('modal-overlay').style.display = 'flex'; + _initExpiryManualTracking('action-edit-expiry', item); } function onActionEditUnitChange() { @@ -8770,7 +8915,8 @@ async function submitActionEditInventory(e, id, productId) { const unit = document.getElementById('action-edit-unit').value; const payload = { id, quantity: qty, location: loc, expiry_date: expiry, unit, product_id: productId, - vacuum_sealed: document.getElementById('action-edit-vacuum')?.checked ? 1 : 0 }; + vacuum_sealed: document.getElementById('action-edit-vacuum')?.checked ? 1 : 0, + expiry_user_set: _expiryUserSetPayload('action-edit-expiry') }; if (unit === 'conf') { payload.package_unit = document.getElementById('action-edit-conf-unit')?.value || ''; @@ -9015,6 +9161,7 @@ async function saveEditedProductInfo() { } const brand = (document.getElementById('edit-action-brand')?.value || '').trim(); const category = document.getElementById('edit-action-category')?.value || ''; + const notes = (document.getElementById('edit-action-notes')?.value || '').trim(); showLoading(true); try { @@ -9027,13 +9174,14 @@ async function saveEditedProductInfo() { image_url: currentProduct.image_url || '', unit: currentProduct.unit || 'pz', default_quantity: currentProduct.default_quantity || 1, - notes: currentProduct.notes || '', + notes: notes, }); showLoading(false); if (result.success) { // Update current product in memory currentProduct.name = name; currentProduct.brand = brand; + currentProduct.notes = notes; if (category) currentProduct.category = category; showToast(t('toast.product_updated'), 'success'); // Refresh the action page with updated data @@ -9168,6 +9316,7 @@ function showAddForm() { showPage('add'); updateScaleReadButtons(); + _initExpiryManualTracking('add-expiry'); // History first (≥3 samples → average of last 3); AI only if history is insufficient (async () => { let hasHistory = false; @@ -9193,6 +9342,7 @@ function onVacuumSealedChange() { } function recalculateAddExpiry() { + if (_isExpiryManuallySet('add-expiry')) return; if (!currentProduct) return; const loc = document.getElementById('add-location')?.value || ''; const isVacuum = document.getElementById('add-vacuum-sealed')?.checked; @@ -9238,18 +9388,20 @@ async function _fetchExpiryHistoryAndUpdate(productId) { _aiProductHintController = null; } document.getElementById('ai-hint-loading')?.remove(); - const loc = document.getElementById('add-location')?.value || ''; - const isVacuum = document.getElementById('add-vacuum-sealed')?.checked; - let days = isVacuum ? getVacuumExpiryDays(data.avg_days) : data.avg_days; - const newDate = addDays(days); - const newLabel = formatEstimatedExpiry(days); - const suffix = ` ${t('product.history_badge')}`; - const expiryInput = document.getElementById('add-expiry'); - const estimateEl = document.querySelector('.expiry-estimate-label'); - const dateEl = document.querySelector('.expiry-estimate-date'); - if (expiryInput) expiryInput.value = newDate; - if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')} ${newLabel}${suffix}`; - if (dateEl) dateEl.textContent = formatDate(newDate); + if (!_isExpiryManuallySet('add-expiry')) { + const loc = document.getElementById('add-location')?.value || ''; + const isVacuum = document.getElementById('add-vacuum-sealed')?.checked; + let days = isVacuum ? getVacuumExpiryDays(data.avg_days) : data.avg_days; + const newDate = addDays(days); + const newLabel = formatEstimatedExpiry(days); + const suffix = ` ${t('product.history_badge')}`; + const expiryInput = document.getElementById('add-expiry'); + const estimateEl = document.querySelector('.expiry-estimate-label'); + const dateEl = document.querySelector('.expiry-estimate-date'); + if (expiryInput) expiryInput.value = newDate; + if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')} ${newLabel}${suffix}`; + if (dateEl) dateEl.textContent = formatDate(newDate); + } window._addBaseExpiryDays = data.avg_days; return true; } @@ -9305,7 +9457,7 @@ async function _applyAIProductHint() { } // Update expiry only if we have no historical data (history takes priority) - if (!window._historyExpiryDays) { + if (!window._historyExpiryDays && !_isExpiryManuallySet('add-expiry')) { window._addBaseExpiryDays = data.expiry_days; const newDate = addDays(data.expiry_days); const newLabel = formatEstimatedExpiry(data.expiry_days); @@ -9465,6 +9617,7 @@ function selectPurchaseType(btn, type) { `; // Restore quantity - switching purchase type should NOT change it document.getElementById('add-quantity').value = currentQty; + _initExpiryManualTracking('add-expiry'); // Show multi-batch section only in "new" mode (and only for conf unit) const mbSection = document.getElementById('multi-batch-section'); if (mbSection) mbSection.style.display = (document.getElementById('add-unit')?.value === 'conf') ? 'block' : 'none'; @@ -9489,6 +9642,7 @@ function selectPurchaseType(btn, type) { `; + _initExpiryManualTracking('add-expiry'); // DON'T auto-set remaining percentage - keep the quantity the user already entered // Hide multi-batch section in "existing" mode const mbSection = document.getElementById('multi-batch-section'); @@ -9651,6 +9805,7 @@ async function submitAdd(e) { quantity: parseFloat(document.getElementById('add-quantity').value) || 1, location: document.getElementById('add-location').value, expiry_date: document.getElementById('add-expiry').value || null, + expiry_user_set: _expiryUserSetPayload('add-expiry'), unit: selectedUnit !== productUnit ? selectedUnit : null, package_unit: selectedUnit === 'conf' ? (document.getElementById('add-conf-unit')?.value || null) : null, package_size: selectedUnit === 'conf' ? (parseFloat(document.getElementById('add-conf-size')?.value) || null) : null, @@ -10640,33 +10795,38 @@ async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId, forcedVa closeModal(); showLoading(true); try { + const invData = await api('inventory_list'); + const invRows = invData.inventory || []; if (openedId) { - // Move only the specific opened row — use opened shelf life - const product = { name: currentProduct?.name || '', category: currentProduct?.category || '' }; - let days = estimateOpenedExpiryDays(product, toLoc); - await api('inventory_update', {}, 'POST', { + const item = invRows.find(i => i.id == openedId); + const product = { name: currentProduct?.name || item?.name || '', category: currentProduct?.category || item?.category || '' }; + const payload = { id: openedId, location: toLoc, - expiry_date: addDays(days), product_id: productId, vacuum_sealed: newVacuum, - }); + }; + if (!item?.expiry_user_set) { + payload.expiry_date = addDays(estimateOpenedExpiryDays(product, toLoc)); + } + await api('inventory_update', {}, 'POST', payload); showToast(t('move.moved_toast').replace('{location}', LOCATIONS[toLoc]?.label || toLoc), 'success'); } else { - // Legacy: move whatever is at fromLoc - const data = await api('inventory_list'); - const item = (data.inventory || []).find(i => i.product_id == productId && i.location === fromLoc && parseFloat(i.quantity) > 0); + const item = invRows.find(i => i.product_id == productId && i.location === fromLoc && parseFloat(i.quantity) > 0); if (item) { const product = { name: item.name || '', category: item.category || '' }; - let days = estimateExpiryDays(product, toLoc); - if (newVacuum) days = getVacuumExpiryDays(days); - await api('inventory_update', {}, 'POST', { + const payload = { id: item.id, location: toLoc, - expiry_date: addDays(days), product_id: productId, vacuum_sealed: newVacuum, - }); + }; + if (!item.expiry_user_set) { + let days = estimateExpiryDays(product, toLoc); + if (newVacuum) days = getVacuumExpiryDays(days); + payload.expiry_date = addDays(days); + } + await api('inventory_update', {}, 'POST', payload); showToast(t('move.moved_simple', { location: LOCATIONS[toLoc]?.label || toLoc }), 'success'); } } @@ -13068,7 +13228,7 @@ async function renderShoppingItems() { html += `
${secDef.icon}${secDef.label}
`; - for (const { item, idx, smartData, urgency, duplicateNames } of group.items) { + for (const { item, idx, smartData, urgency, duplicateNames = [] } of group.items) { const catIcon = CATEGORY_ICONS[guessCategoryFromName(item.name)] || '🛒'; const bgStyle = urgency && URGENCY_BG[urgency] ? ` style="background:${URGENCY_BG[urgency]}"` : ''; const localTags = getShoppingTags(item.name); @@ -13543,10 +13703,11 @@ async function analyzeExpiryImage(dataUrl) { const result = await api('gemini_expiry', {}, 'POST', { image: base64 }); if (result.success && result.expiry_date) { - // Auto-fill the expiry date + // Auto-fill the expiry date (treat as user-provided) const expiryInput = document.getElementById('add-expiry'); if (expiryInput) { expiryInput.value = result.expiry_date; + expiryInput.dataset.manuallySet = 'true'; } statusDiv.innerHTML = `

✅ ${t('scanner.expiry_found')}: ${formatDate(result.expiry_date)}

`; @@ -14766,29 +14927,38 @@ async function confirmRecipeMove(productId, fromLoc, toLoc, openedId, forcedVacu const newVacuum = forcedVacuum !== undefined ? (forcedVacuum ? 1 : 0) : (document.getElementById('move-vacuum-check')?.checked ? 1 : 0); closeModal(); try { + const invData = await api('inventory_list'); + const invRows = invData.inventory || []; if (openedId) { - let days = estimateExpiryDays({ name: '', category: '' }, toLoc); - if (newVacuum) days = getVacuumExpiryDays(days); - await api('inventory_update', {}, 'POST', { + const item = invRows.find(i => i.id == openedId); + const product = { name: item?.name || '', category: item?.category || '' }; + const payload = { id: openedId, location: toLoc, - expiry_date: addDays(days), product_id: productId, vacuum_sealed: newVacuum, - }); - } else { - const data = await api('inventory_list'); - const item = (data.inventory || []).find(i => i.product_id == productId && i.location === fromLoc && parseFloat(i.quantity) > 0); - if (item) { - let days = estimateExpiryDays({ name: item.name || '', category: item.category || '' }, toLoc); + }; + if (!item?.expiry_user_set) { + let days = estimateExpiryDays(product, toLoc); if (newVacuum) days = getVacuumExpiryDays(days); - await api('inventory_update', {}, 'POST', { + payload.expiry_date = addDays(days); + } + await api('inventory_update', {}, 'POST', payload); + } else { + const item = invRows.find(i => i.product_id == productId && i.location === fromLoc && parseFloat(i.quantity) > 0); + if (item) { + const payload = { id: item.id, location: toLoc, - expiry_date: addDays(days), product_id: productId, vacuum_sealed: newVacuum, - }); + }; + if (!item.expiry_user_set) { + let days = estimateExpiryDays({ name: item.name || '', category: item.category || '' }, toLoc); + if (newVacuum) days = getVacuumExpiryDays(days); + payload.expiry_date = addDays(days); + } + await api('inventory_update', {}, 'POST', payload); } } showToast(t('move.moved_simple', { location: LOCATIONS[toLoc]?.label || toLoc }), 'success'); @@ -17169,8 +17339,10 @@ function _handleOfflineApi(action, params, body) { if (cached) return { ...cached, _offline: true }; return { success: false, _offline: true }; } - if (action === 'search_barcode') { - return _offlineSearchBarcode(params && params.barcode); + if (action === 'search_barcode' || action === 'resolve_barcode') { + const found = _offlineSearchBarcode(params && params.barcode); + if (found.found) return { ...found, source: 'local' }; + return { found: false, source: 'offline' }; } if (action === 'products_search') { const q = String((params && params.q) || '').trim().toLowerCase(); @@ -18138,6 +18310,7 @@ async function spesaModeAfterAdd() { product_id: currentProduct.id, }); updateSpesaBanner(); + _shoppingInventoryCache = null; await _spesaRemovePurchasedFromList(currentProduct); const addLoc = document.getElementById('add-location')?.value || 'dispensa'; _showFamilySiblingSuggest(currentProduct.id, addLoc); @@ -18170,6 +18343,41 @@ async function _spesaRemovePurchasedFromList(product) { _markBringPurchased(namesToMark); } +const _FAMILY_SIBLING_CONFIRM_TTL = 24 * 60 * 60 * 1000; + +function _familySiblingConfirmKey(family, location) { + return `${String(family || '').trim().toLowerCase()}|${location || 'dispensa'}`; +} + +function _getFamilySiblingConfirmed() { + const map = Object.assign({}, _familySiblingConfirmedCache || {}); + const now = Date.now(); + let changed = false; + for (const key of Object.keys(map)) { + if (now - map[key] > _FAMILY_SIBLING_CONFIRM_TTL) { delete map[key]; changed = true; } + } + if (changed) { + _familySiblingConfirmedCache = map; + _saveToServer('family_sibling_confirmed', map); + } + return map; +} + +function _isFamilySiblingRecentlyConfirmed(family, location) { + if (!family) return false; + const map = _getFamilySiblingConfirmed(); + const ts = map[_familySiblingConfirmKey(family, location)]; + return !!ts && (Date.now() - ts) < _FAMILY_SIBLING_CONFIRM_TTL; +} + +function _recordFamilySiblingConfirmed(family, location) { + if (!family) return; + const map = _getFamilySiblingConfirmed(); + map[_familySiblingConfirmKey(family, location)] = Date.now(); + _familySiblingConfirmedCache = map; + _saveToServer('family_sibling_confirmed', map); +} + let _familySiblingDismissTimer = null; function _dismissFamilySiblingPrompt() { @@ -18187,23 +18395,58 @@ function _formatFamilySiblingDate(dtStr) { return d.toLocaleDateString(loc, { day: '2-digit', month: 'long', year: 'numeric' }); } +/** Parse "20g" / "500 ml" from product name when package size missing in catalog. */ +function _inferPackageSizeFromName(name) { + const m = (name || '').match(/\b(\d+(?:[.,]\d+)?)\s*(g|ml|kg|l|lt)\b/i); + if (!m) return null; + let val = parseFloat(String(m[1]).replace(',', '.')); + const u = m[2].toLowerCase(); + if (u === 'kg') { val *= 1000; return { qty: val, unit: 'g' }; } + if (u === 'l' || u === 'lt') { val *= 1000; return { qty: val, unit: 'ml' }; } + return { qty: val, unit: u }; +} + +/** Human-readable stock for spesa family-sibling check (e.g. "4 conf (da 20g)"). */ +function _formatFamilySiblingStockLine(s) { + let defQty = parseFloat(s.default_quantity) || 0; + let pkgUnit = s.package_unit || ''; + const inferred = _inferPackageSizeFromName(s.name); + if (inferred && (!defQty || (s.unit === 'conf' && defQty < inferred.qty))) { + defQty = inferred.qty; + pkgUnit = inferred.unit; + } + const unit = s.unit || 'pz'; + const qty = parseFloat(s.stock_qty) || 0; + const parts = formatQuantityParts(qty, unit, defQty, pkgUnit); + if (parts.unitLabel) { + let line = `${parts.mainQty} ${parts.unitLabel}`; + if (parts.packageDetail) line += ` (${parts.packageDetail})`; + if (parts.fraction) line += ` ${parts.fraction}`; + return line; + } + return formatQuantity(qty, unit, defQty, pkgUnit).replace(/<[^>]*>/g, ''); +} + /** Optional hint: same-family product in the same location (non-blocking). */ function _showFamilySiblingSuggest(productId, location) { _dismissFamilySiblingPrompt(); const loc = location || 'dispensa'; - api('family_sibling_suggest', {}, 'POST', { product_id: productId, location: loc }).then(data => { + const earlyFamily = (currentProduct?.shopping_name || '').trim(); + if (earlyFamily && _isFamilySiblingRecentlyConfirmed(earlyFamily, loc)) return; + api('family_sibling_suggest', {}, 'POST', { product_id: productId, location: loc }).then(async data => { if (!data?.success || !data.sibling) return; const s = data.sibling; + if (_isFamilySiblingRecentlyConfirmed(s.family, loc)) return; + if (!(await _productHasLiveStock(s.product_id))) return; const locKey = s.location || loc; const locInfo = LOCATIONS[locKey] || LOCATIONS.altro; - const qtyStr = `${s.stock_qty} ${s.unit}`; + const stockLine = _formatFamilySiblingStockLine(s); const purchaseRaw = s.last_purchase_at || s.added_at; const purchaseDate = _formatFamilySiblingDate(purchaseRaw); const productLine = s.brand ? `${s.name} (${s.brand})` : s.name; const catIcon = CATEGORY_ICONS[mapToLocalCategory(s.category, s.name)] || '📦'; const metaParts = [ `${locInfo.icon} ${locInfo.label}`, - qtyStr, purchaseDate ? purchaseDate : '', ].filter(Boolean); const thumbHtml = s.image_url @@ -18217,8 +18460,8 @@ function _showFamilySiblingSuggest(productId, location) {
${thumbHtml}
-
${escapeHtml(t('shopping.family_sibling_title', { location: locInfo.label }))}
-
${escapeHtml(productLine)}
+
${escapeHtml(t('shopping.family_sibling_check', { name: productLine }))}
+
${escapeHtml(t('shopping.family_sibling_stock', { qty: stockLine }))}
${escapeHtml(metaParts.join(' · '))}
${escapeHtml(t('shopping.family_sibling_question'))}
@@ -18230,7 +18473,10 @@ function _showFamilySiblingSuggest(productId, location) { `; document.body.appendChild(bar); - bar.querySelector('#_fam-sib-yes').addEventListener('click', _dismissFamilySiblingPrompt); + bar.querySelector('#_fam-sib-yes').addEventListener('click', () => { + _recordFamilySiblingConfirmed(s.family, locKey); + _dismissFamilySiblingPrompt(); + }); bar.querySelector('#_fam-sib-no').addEventListener('click', () => { _dismissFamilySiblingPrompt(); if (s.inventory_id) editInventoryItem(s.inventory_id); @@ -18710,9 +18956,9 @@ async function _runStartupCheck() { if (spinnerEl) spinnerEl.style.display = 'none'; wrapEl.style.display = ''; - // Helper: set progress bar + crossfade status text + // Helper: set progress bar + crossfade status text (function decl avoids TDZ if called early) let _curPct = 0; - const setProgress = (pct, label, state) => { + function setProgress(pct, label, state) { _curPct = pct; if (barEl) { barEl.style.width = pct + '%'; @@ -18733,7 +18979,7 @@ async function _runStartupCheck() { // Direct update — checks fire every 40ms, any fade would hide most labels el.className = `preloader-status-text ${sc}`; el.textContent = cleanLabel; - }; + } // Auto-provision API token for same-origin browser sessions if (typeof ensureApiToken === 'function') { diff --git a/index.html b/index.html index 0983636..f7612d9 100644 --- a/index.html +++ b/index.html @@ -11,20 +11,20 @@ EverShelf - + - - + - @@ -94,7 +94,7 @@ - v1.7.38 + v1.7.39
@@ -107,7 +107,7 @@

- EverShelfv1.7.38 + EverShelfv1.7.39

@@ -1985,6 +1985,6 @@
- + diff --git a/manifest.json b/manifest.json index 4f5a63b..17cf52c 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "name": "EverShelf", "short_name": "EverShelf", "description": "Gestione completa della dispensa di casa con scansione barcode", - "version": "1.7.38", + "version": "1.7.39", "start_url": "/evershelf/", "display": "standalone", "background_color": "#f0f4e8", diff --git a/translations/de.json b/translations/de.json index c23260c..6ae9fb1 100644 --- a/translations/de.json +++ b/translations/de.json @@ -522,6 +522,8 @@ "urgency_spec_medium": "🟡 Bald", "urgency_spec_low": "🔵 Prognose", "family_sibling_title": "Ähnlich in {location}", + "family_sibling_check": "Prüfen: {name}", + "family_sibling_stock": "Du solltest haben: {qty}", "family_sibling_location": "Standort: {location}", "family_sibling_qty": "Menge: {qty}", "family_sibling_purchased": "Gekauft am {date}", diff --git a/translations/en.json b/translations/en.json index cbfdcc2..8d59c23 100644 --- a/translations/en.json +++ b/translations/en.json @@ -522,6 +522,8 @@ "urgency_spec_medium": "🟡 Soon", "urgency_spec_low": "🔵 Forecast", "family_sibling_title": "Similar in {location}", + "family_sibling_check": "Check: {name}", + "family_sibling_stock": "You should have: {qty}", "family_sibling_location": "Location: {location}", "family_sibling_qty": "Quantity: {qty}", "family_sibling_purchased": "Purchased on {date}", diff --git a/translations/es.json b/translations/es.json index a537a41..20e257d 100644 --- a/translations/es.json +++ b/translations/es.json @@ -522,6 +522,8 @@ "urgency_spec_medium": "🟡 Pronto", "urgency_spec_low": "🔵 Previsión", "family_sibling_title": "Similar en {location}", + "family_sibling_check": "Comprueba: {name}", + "family_sibling_stock": "Deberías tener: {qty}", "family_sibling_location": "Ubicación: {location}", "family_sibling_qty": "Cantidad: {qty}", "family_sibling_purchased": "Comprado el {date}", diff --git a/translations/fr.json b/translations/fr.json index 88d5305..7c2818f 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -522,6 +522,8 @@ "urgency_spec_medium": "🟡 Bientôt", "urgency_spec_low": "🔵 Prévision", "family_sibling_title": "Similaire dans {location}", + "family_sibling_check": "Vérifier : {name}", + "family_sibling_stock": "Vous devriez avoir : {qty}", "family_sibling_location": "Emplacement : {location}", "family_sibling_qty": "Quantité : {qty}", "family_sibling_purchased": "Acheté le {date}", diff --git a/translations/it.json b/translations/it.json index 9737db1..0d920c4 100644 --- a/translations/it.json +++ b/translations/it.json @@ -522,6 +522,8 @@ "urgency_spec_medium": "🟡 A breve", "urgency_spec_low": "🔵 Previsione", "family_sibling_title": "Simile in {location}", + "family_sibling_check": "Controlla: {name}", + "family_sibling_stock": "Dovresti avere: {qty}", "family_sibling_location": "Si trova in: {location}", "family_sibling_qty": "Quantità: {qty}", "family_sibling_purchased": "Acquistato il {date}",