chore: auto-merge develop → main
Triggered by: 5dd3bae Release v1.7.39: faster barcode lookup, spesa UX, and expiry control.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
+379
-133
@@ -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,12 +2404,19 @@ 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') {
|
||||
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) {
|
||||
showLoading(false);
|
||||
@@ -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,76 +7745,52 @@ function stopScanner() {
|
||||
if (aiVideo) aiVideo.srcObject = null;
|
||||
}
|
||||
|
||||
async function onBarcodeDetected(barcode) {
|
||||
_dismissFamilySiblingPrompt();
|
||||
_resetAiFallbackForNewScan();
|
||||
showLoading(true);
|
||||
const _barcodeSessionCache = new Map();
|
||||
|
||||
// Vibrate if available
|
||||
if (navigator.vibrate) navigator.vibrate(100);
|
||||
function _barcodeCacheKey(barcode) {
|
||||
return String(barcode || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
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*([^·]+)/);
|
||||
/** 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') {
|
||||
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
|
||||
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: currentProduct.id,
|
||||
barcode: currentProduct.barcode,
|
||||
name: currentProduct.name,
|
||||
brand: currentProduct.brand || '',
|
||||
category: currentProduct.category || '',
|
||||
image_url: currentProduct.image_url || '',
|
||||
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: currentProduct.notes,
|
||||
});
|
||||
notes: product.notes,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
if (!product.weight_info && product.notes) {
|
||||
const pesoMatch = product.notes.match(/Peso:\s*([^·]+)/);
|
||||
if (pesoMatch) product.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;
|
||||
if (product.weight_info && product.unit === 'conf' && !product._confCount) {
|
||||
const detected = detectUnitAndQuantity(product.weight_info);
|
||||
if (detected.confCount) product._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
|
||||
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()}`);
|
||||
@@ -7705,23 +7798,13 @@ async function onBarcodeDetected(barcode) {
|
||||
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(' · ');
|
||||
}
|
||||
|
||||
// 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,
|
||||
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 || '',
|
||||
@@ -7742,21 +7825,78 @@ async function onBarcodeDetected(barcode) {
|
||||
labels: p.labels || '',
|
||||
stores: p.stores || '',
|
||||
};
|
||||
}
|
||||
|
||||
function _finishBarcodeResolved(barcode) {
|
||||
showLoading(false);
|
||||
addToScanRecents(currentProduct);
|
||||
_showScanConfirm(currentProduct.name);
|
||||
stopScanner();
|
||||
setTimeout(() => showProductAction(), 300);
|
||||
return;
|
||||
}
|
||||
const delay = _spesaMode ? 120 : 300;
|
||||
const next = _spesaMode ? showAddForm : showProductAction;
|
||||
setTimeout(() => next(), delay);
|
||||
}
|
||||
|
||||
// Not found — keep camera running and let user scan again or add manually
|
||||
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);
|
||||
|
||||
if (navigator.vibrate) navigator.vibrate(100);
|
||||
|
||||
try {
|
||||
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]) =>
|
||||
`<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,6 +9388,7 @@ async function _fetchExpiryHistoryAndUpdate(productId) {
|
||||
_aiProductHintController = null;
|
||||
}
|
||||
document.getElementById('ai-hint-loading')?.remove();
|
||||
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;
|
||||
@@ -9250,6 +9401,7 @@ async function _fetchExpiryHistoryAndUpdate(productId) {
|
||||
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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user