Release v1.7.39: faster barcode lookup, spesa UX, and expiry control.

Parallel resolve_barcode with SQLite cache speeds bulk shopping scans; spesa mode skips to add form. Manual expiry dates persist across location moves; family sibling checks dedupe for 24h. Fixes kiosk crashes, empty barcode UNIQUE errors, and spesa ghost products.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dadaloop82
2026-06-06 10:19:39 +00:00
parent 34dcb05c05
commit 5dd3baea5d
14 changed files with 912 additions and 408 deletions
+23
View File
@@ -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 (~12s 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
+1 -1
View File
@@ -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)
+20
View File
@@ -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;
+396 -199
View File
@@ -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,
+1 -1
View File
@@ -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',
+11 -3
View File
@@ -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;
+434 -188
View File
@@ -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) {
</form>
`;
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;
}
const code = _barcodeCacheKey(barcode);
const result = await _resolveBarcodeLookup(code);
if (await _handleBarcodeResolve(result, code)) 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
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]) =>
`<option value="${key}" ${mapToLocalCategory(currentProduct.category, currentProduct.name) === key ? 'selected' : ''}>${label}</option>`
`<option value="${key}" ${mapToLocalCategory(currentProduct.category, currentProduct.name, currentProduct.brand) === key ? 'selected' : ''}>${label}</option>`
).join('');
editInfoEl.innerHTML = `
@@ -8396,6 +8536,10 @@ function showProductAction() {
${categoryOptions}
</select>
</div>
<div class="form-group">
<label>${t('product.notes_label')}</label>
<textarea id="edit-action-notes" class="form-input" rows="2" placeholder="${escapeHtml(t('product.notes_placeholder') || '')}">${escapeHtml(currentProduct.notes || '')}</textarea>
</div>
<button type="button" class="btn btn-primary full-width" onclick="saveEditedProductInfo()">${t('btn.save_info')}</button>
</div>
</div>
@@ -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) {
</form>
`;
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 = ` <span class="history-badge" title="${t('add.history_badge_tip').replace('{n}', String(data.count))}">${t('product.history_badge')}</span>`;
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')} <strong>${newLabel}${suffix}</strong>`;
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 = ` <span class="history-badge" title="${t('add.history_badge_tip').replace('{n}', String(data.count))}">${t('product.history_badge')}</span>`;
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')} <strong>${newLabel}${suffix}</strong>`;
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) {
</div>
</div>
`;
_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 += `<div class="shopping-section-divider"><span class="sec-icon">${secDef.icon}</span>${secDef.label}</div>`;
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 = `<p style="color:var(--success);font-weight:600">✅ ${t('scanner.expiry_found')}: ${formatDate(result.expiry_date)}</p>`;
@@ -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) {
<div class="family-sibling-prompt-body">
<div class="family-sibling-prompt-thumb">${thumbHtml}</div>
<div class="family-sibling-prompt-info">
<div class="family-sibling-prompt-title">${escapeHtml(t('shopping.family_sibling_title', { location: locInfo.label }))}</div>
<div class="family-sibling-prompt-name">${escapeHtml(productLine)}</div>
<div class="family-sibling-prompt-title">${escapeHtml(t('shopping.family_sibling_check', { name: productLine }))}</div>
<div class="family-sibling-prompt-stock">${escapeHtml(t('shopping.family_sibling_stock', { qty: stockLine }))}</div>
<div class="family-sibling-prompt-meta">${escapeHtml(metaParts.join(' · '))}</div>
<div class="family-sibling-prompt-question">${escapeHtml(t('shopping.family_sibling_question'))}</div>
</div>
@@ -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') {
+12 -12
View File
@@ -11,20 +11,20 @@
<title>EverShelf</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
<link rel="stylesheet" href="assets/css/style.css?v=20260606g">
<link rel="stylesheet" href="assets/css/style.css?v=20260606m">
<!-- Core modules (auth, DOM helpers) -->
<script src="assets/js/core/dom.js?v=20260603a"></script>
<script src="assets/js/core/auth.js?v=20260603b"></script>
<!-- ZBar WASM — fast barcode engine (primary fallback when native API missing/slow) -->
<script src="assets/vendor/zbar/index.js?v=20260606a"></script>
<!-- ZBar WASM — lazy on kiosk WebView (OOM); eager elsewhere -->
<script>
if (window.zbarWasm && zbarWasm.setModuleArgs) {
zbarWasm.setModuleArgs({
locateFile: (file) => 'assets/vendor/zbar/' + file
});
}
(function () {
var kioskWv = /; wv\)/.test(navigator.userAgent);
if (kioskWv) return;
document.write('<script src="assets/vendor/zbar/index.js?v=20260606a"><\/script>');
document.write('<script>if(window.zbarWasm&&zbarWasm.setModuleArgs){zbarWasm.setModuleArgs({locateFile:function(f){return"assets/vendor/zbar/"+f}});}<\/script>');
document.write('<script src="assets/vendor/zbar/polyfill.js?v=20260606a"><\/script>');
})();
</script>
<script src="assets/vendor/zbar/polyfill.js?v=20260606a"></script>
<!-- QuaggaJS — legacy last-resort only -->
<script src="assets/vendor/quagga/quagga.min.js?v=20260603a"></script>
<script>if(typeof Quagga==='undefined'){document.write('<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"><\\/script>');}</script>
@@ -94,7 +94,7 @@
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
<span class="app-preloader-version" id="preloader-version">v1.7.38</span>
<span class="app-preloader-version" id="preloader-version">v1.7.39</span>
</div>
</div>
@@ -107,7 +107,7 @@
<!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.38</span>
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.39</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -1985,6 +1985,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260606g"></script>
<script src="assets/js/app.js?v=20260606n"></script>
</body>
</html>
+1 -1
View File
@@ -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",
+2
View File
@@ -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}",
+2
View File
@@ -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}",
+2
View File
@@ -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}",
+2
View File
@@ -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}",
+2
View File
@@ -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}",