Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51f55071fa | |||
| 3a4e843334 | |||
| 7104483dac | |||
| 94e98bc79f | |||
| fd039d743e | |||
| b1bcf9e714 | |||
| 98c38f017e | |||
| 7947f47e6d | |||
| 758eb93e20 | |||
| ff1175451a | |||
| 42630c3e3e | |||
| 637eaa20d6 | |||
| 5e307f79b8 | |||
| a6478b20e1 | |||
| 223457bbdf |
@@ -206,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: "EverShelf ${{ steps.version.outputs.version }}"
|
||||
|
||||
@@ -11,6 +11,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [1.7.35] - 2026-06-02
|
||||
|
||||
### Fixed
|
||||
- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check.
|
||||
- **Recipe persons +/− buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict.
|
||||
|
||||
## [1.7.34] - 2026-05-30
|
||||
|
||||
### Added
|
||||
- **AI visual barcode fallback** — When the barcode scanner fails to read a barcode within 5 seconds, EverShelf can now automatically capture a camera frame and send it to Gemini Vision to visually identify the product (name, brand, category). On success the product is saved and the inventory form opens just as if a barcode had been scanned. A new toggle in **Settings → Camera** (`AI visual identification (5s fallback)`) lets users enable or disable this feature at any time. Requires Gemini API key configured. Disabled by default.
|
||||
|
||||
## [1.7.33] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **HA sensor `shopping_total` always null** — `haInventorySensor` was reading `shopping_total_cache.json` with a 1-hour TTL (cache populated only by the JS frontend, so it was often empty). Extended TTL to 24 hours and added an inline fallback: when the cache is absent or stale, the sensor now computes the total directly from `shopping_price_cache.json` without any AI calls. Queries `shopping_list` joined to `products` for the canonical `shopping_name`, then looks up both v3 and legacy v0 cache key formats to maximise hit rate. Works in both internal and Bring shopping modes.
|
||||
- **HA `ha_refresh_prices` using non-existent columns** — `haInventorySensor` and `haRefreshPrices` were querying `quantity`, `unit`, `checked` from `shopping_list` — columns that do not exist in that table (schema: `id, name, raw_name, specification, added_at, sort_order`). Changed to `SELECT name` with `shopping_name` join and default `qty=1 / unit=pz`.
|
||||
|
||||
|
||||
## [1.7.32] - 2026-05-29
|
||||
|
||||
### Changed
|
||||
- **Smarter expiry u2192 shopping list logic** — The "expiring soon" threshold is now 7 days (was 3), giving enough time to plan the next shopping trip. Items expiring soon are only flagged for restocking when the user is a **regular buyer** (`isRegular`) and either stock is low (<50%) or the consumption rate predicts the item will expire before being used. Non-regular products keep the old 3-day safety-net. Expired items are now only added to the shopping list when `isRegular || buyCount >= 2` — products that expired unused without ever being a staple no longer pollute the list; the expiry banner handles them.
|
||||
|
||||
|
||||
## [1.7.31] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **New pack merges into opened pack on add** — `addToInventory` was looking for ANY existing row for the same product+location and adding the new quantity to it. This caused a newly purchased sealed pack to be silently merged with an already-opened pack, collapsing two physically distinct containers into one row and corrupting the `opened_at` timestamp. The fix now searches only for a **sealed** (unopened) row (`opened_at IS NULL`) to merge into. If only opened rows exist, a new sealed row is created instead — keeping the two packs separate and allowing the anomaly model and shelf-life tracker to work correctly.
|
||||
|
||||
|
||||
## [1.7.30] - 2026-05-29
|
||||
|
||||
### 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)
|
||||
|
||||
+203
-26
@@ -604,7 +604,7 @@ function checkRateLimit(string $action): void {
|
||||
}
|
||||
|
||||
// Determine limit based on action
|
||||
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr'];
|
||||
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr', 'gemini_barcode_visual'];
|
||||
$loginActions = [];
|
||||
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
||||
$errorActions = ['report_error', 'check_update'];
|
||||
@@ -1109,6 +1109,10 @@ try {
|
||||
geminiNumberOCR();
|
||||
break;
|
||||
|
||||
case 'gemini_barcode_visual':
|
||||
geminiBarcodeVisual();
|
||||
break;
|
||||
|
||||
case 'get_shopping_price':
|
||||
getShoppingPrice($db);
|
||||
break;
|
||||
@@ -1570,7 +1574,9 @@ function haInventorySensor(PDO $db): void {
|
||||
$daysToNextExpiry = (int)$diff->format('%r%a');
|
||||
}
|
||||
|
||||
// Shopping total from server-side total cache (max 1 hour old)
|
||||
// Shopping total from server-side total cache (max 24 hours old).
|
||||
// The cache is populated by the JS frontend or by the ha_refresh_prices action.
|
||||
// 24h TTL is sufficient: the total changes slowly and HA polls frequently.
|
||||
$priceEnabled = env('PRICE_ENABLED', 'false') === 'true';
|
||||
$priceCurrency = env('PRICE_CURRENCY', 'EUR');
|
||||
$shoppingTotal = null;
|
||||
@@ -1578,19 +1584,58 @@ function haInventorySensor(PDO $db): void {
|
||||
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
|
||||
if (file_exists($totalCachePath)) {
|
||||
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
|
||||
// Find the most recent entry not older than 1 hour
|
||||
$best = null;
|
||||
$bestTs = 0;
|
||||
$best = null; $bestTs = 0;
|
||||
foreach ($tc as $entry) {
|
||||
if (isset($entry['ts']) && $entry['ts'] > $bestTs) {
|
||||
$bestTs = $entry['ts'];
|
||||
$best = $entry;
|
||||
}
|
||||
}
|
||||
if ($best && (time() - $bestTs) < 3600) {
|
||||
if ($best && (time() - $bestTs) < 86400) {
|
||||
$shoppingTotal = round((float)($best['result']['total'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
// If cache is absent or older than 24h, compute inline from the existing
|
||||
// per-item price cache (no AI calls — fast, uses already-stored prices).
|
||||
if ($shoppingTotal === null) {
|
||||
$country = env('PRICE_COUNTRY', 'Italia');
|
||||
$priceCache = _loadPriceCache();
|
||||
if (!empty($priceCache)) {
|
||||
$shopRows = $db->query("
|
||||
SELECT sl.name, COALESCE(p.shopping_name, sl.name) AS sname
|
||||
FROM shopping_list sl
|
||||
LEFT JOIN products p ON lower(p.name) = lower(sl.name)
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$inlineTotal = 0.0;
|
||||
$inlinePriced = 0;
|
||||
$seenNames = [];
|
||||
foreach ($shopRows as $r) {
|
||||
$sname = $r['sname'] ?? $r['name'];
|
||||
if (isset($seenNames[$sname])) continue; // deduplicate
|
||||
$seenNames[$sname] = true;
|
||||
$pk = _priceKey($sname, $country);
|
||||
$pk0 = md5(mb_strtolower(trim($sname)) . '|' . mb_strtolower(trim($country)));
|
||||
$e = $priceCache[$pk] ?? $priceCache[$pk0] ?? null;
|
||||
if ($e !== null) {
|
||||
$est = _calcEstimatedTotal(
|
||||
$e['price_per_unit'], $e['unit_label'] ?? '',
|
||||
1, 'pz', 0, ''
|
||||
);
|
||||
$inlineTotal += $est ?? 0;
|
||||
$inlinePriced++;
|
||||
}
|
||||
}
|
||||
if ($inlinePriced > 0) {
|
||||
$shoppingTotal = round($inlineTotal, 2);
|
||||
// Persist so next call is instant
|
||||
$tc2 = file_exists($totalCachePath) ? (json_decode(file_get_contents($totalCachePath), true) ?? []) : [];
|
||||
$ckey2 = 'ha_inline_' . date('Ymd_His');
|
||||
$tc2[$ckey2] = ['ts' => time(), 'result' => ['total' => $shoppingTotal, 'priced_items' => $inlinePriced]];
|
||||
if (count($tc2) >= 10) $tc2 = array_slice($tc2, -9, null, true);
|
||||
file_put_contents($totalCachePath, json_encode($tc2, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stateValue = match($sensor) {
|
||||
@@ -1786,9 +1831,17 @@ function haRefreshPrices(PDO $db): void {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rows = $db->query("SELECT name, quantity, unit FROM shopping_list WHERE checked = 0")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$rows = $db->query("
|
||||
SELECT sl.name, COALESCE(p.shopping_name, sl.name) AS sname
|
||||
FROM shopping_list sl
|
||||
LEFT JOIN products p ON lower(p.name) = lower(sl.name)
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$seenSnamesHa = [];
|
||||
foreach ($rows as $r) {
|
||||
$shoppingItems[] = ['name' => $r['name'], 'quantity' => (float)($r['quantity'] ?? 1), 'unit' => $r['unit'] ?? 'pz', 'default_quantity' => 0, 'package_unit' => ''];
|
||||
$sname = $r['sname'] ?? $r['name'];
|
||||
if (isset($seenSnamesHa[$sname])) continue;
|
||||
$seenSnamesHa[$sname] = true;
|
||||
$shoppingItems[] = ['name' => $sname, 'quantity' => 1, 'unit' => 'pz', 'default_quantity' => 0, 'package_unit' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1798,9 +1851,10 @@ function haRefreshPrices(PDO $db): void {
|
||||
$missing = [];
|
||||
|
||||
foreach ($shoppingItems as $item) {
|
||||
$key = _priceKey($item['name'], $country);
|
||||
if (isset($priceCache[$key])) {
|
||||
$entry = $priceCache[$key];
|
||||
$key = _priceKey($item['name'], $country);
|
||||
$key0 = md5(mb_strtolower(trim($item['name'])) . '|' . mb_strtolower(trim($country)));
|
||||
$entry = $priceCache[$key] ?? $priceCache[$key0] ?? null;
|
||||
if ($entry !== null) {
|
||||
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
|
||||
$total += $est ?? 0;
|
||||
$priced++;
|
||||
@@ -2634,19 +2688,26 @@ function addToInventory(PDO $db): void {
|
||||
|
||||
$vacuumSealed = (int)($input['vacuum_sealed'] ?? 0);
|
||||
|
||||
// Check if product already exists in this location
|
||||
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ?");
|
||||
// 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
|
||||
// pack, because that would conflate two physically distinct containers and
|
||||
// corrupt the opened_at timestamp tracking.
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, quantity FROM inventory
|
||||
WHERE product_id = ? AND location = ? AND opened_at IS NULL
|
||||
ORDER BY added_at ASC LIMIT 1
|
||||
");
|
||||
$stmt->execute([$productId, $location]);
|
||||
$existing = $stmt->fetch();
|
||||
|
||||
if ($existing) {
|
||||
// Update quantity
|
||||
// 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']]);
|
||||
} else {
|
||||
$newQty = $quantity;
|
||||
// Insert new inventory entry
|
||||
// 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]);
|
||||
}
|
||||
@@ -4296,6 +4357,7 @@ function getServerSettings(): void {
|
||||
'shopping_forecast' => env('SHOPPING_FORECAST', 'true') === 'true',
|
||||
'shopping_auto_add_threshold' => (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'),
|
||||
'dark_mode' => env('DARK_MODE', 'auto'),
|
||||
'barcode_ai_fallback' => env('BARCODE_AI_FALLBACK', 'false') === 'true',
|
||||
// Home Assistant Integration
|
||||
'ha_enabled' => env('HA_ENABLED', 'false') === 'true',
|
||||
'ha_url' => env('HA_URL', ''),
|
||||
@@ -4398,6 +4460,7 @@ function saveSettings(): void {
|
||||
'shopping_enabled' => 'SHOPPING_ENABLED',
|
||||
'shopping_smart_suggestions' => 'SHOPPING_SMART_SUGGESTIONS',
|
||||
'shopping_forecast' => 'SHOPPING_FORECAST',
|
||||
'barcode_ai_fallback' => 'BARCODE_AI_FALLBACK',
|
||||
// Home Assistant
|
||||
'ha_enabled' => 'HA_ENABLED',
|
||||
];
|
||||
@@ -8861,7 +8924,9 @@ function smartShopping(PDO $db): void {
|
||||
$expiryDate = $inv ? $inv['nearest_expiry'] : null;
|
||||
$daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999;
|
||||
$isExpired = $daysToExpiry < 0;
|
||||
$isExpiringSoon = !$isExpired && $daysToExpiry <= 3;
|
||||
// 7-day warning window: enough to plan the next shopping trip.
|
||||
// The tighter 3-day threshold was often too late for staple products.
|
||||
$isExpiringSoon = !$isExpired && $daysToExpiry <= 7;
|
||||
|
||||
// Fresh (non-expired) quantity — used for suppression when only part of stock is expired
|
||||
$freshQty = $inv ? (float)($inv['fresh_qty'] ?? $qty) : 0;
|
||||
@@ -8994,31 +9059,48 @@ function smartShopping(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Expiring soon or expired (needs replacement) — valid regardless of frequency
|
||||
// Expiring soon or expired (needs replacement)
|
||||
if ($isExpired && $qty > 0) {
|
||||
// Check if the product's shopping_name FAMILY has adequate FRESH stock
|
||||
// from other (non-expired) products. If so, no need to buy more.
|
||||
$sNameKey = strtolower(trim($p['shopping_name'] ?? ''));
|
||||
$familyFreshQty = $sNameKey !== '' ? ($freshStockByShoppingName[$sNameKey] ?? 0) : 0;
|
||||
// Subtract this product's own qty (it is expired, so fresh_qty=0 for it anyway)
|
||||
$refQtyLocal = $refQty > 0 ? $refQty : 1;
|
||||
$familyFreshPct = min(200, ($familyFreshQty / $refQtyLocal) * 100);
|
||||
|
||||
if (($justRestocked && $freshPctLeft >= 50) || $familyFreshPct >= 50) {
|
||||
// Fresh stock from this product or same-family products is adequate.
|
||||
// The expired batch will show in the dashboard expiry banner — don't add to shopping list.
|
||||
} else {
|
||||
} elseif ($isRegular || $buyCount >= 2) {
|
||||
// Only suggest restocking if this is a product the user buys regularly.
|
||||
// If it expired without ever being a staple, the expiry banner is enough.
|
||||
$urgency = 'critical';
|
||||
$reasons[] = 'Scaduto!';
|
||||
$score += 90;
|
||||
}
|
||||
} elseif ($isExpiringSoon && $qty > 0 && $pctLeft < 50) {
|
||||
// Only flag "expiring soon" if stock is also low (<50%). If you have plenty of
|
||||
// stock (e.g. just bought fresh produce that naturally expires in 3 days), the
|
||||
// shopping list is not the right place — the expiry banner handles it.
|
||||
if ($urgency === 'none') $urgency = 'medium';
|
||||
$reasons[] = 'Scade tra ' . max(0, round($daysToExpiry)) . 'gg';
|
||||
$score += 40;
|
||||
// else: one-off product expired unused → expiry banner handles it, no shopping noise
|
||||
} elseif ($isExpiringSoon && $qty > 0) {
|
||||
// Flag if:
|
||||
// (a) regular consumer + stock low (<50%) → needs restock soon
|
||||
// (b) regular consumer + will expire before finishing it
|
||||
// (daysLeft based on consumption rate > days to expiry)
|
||||
// (c) non-regular + within 3 days + low stock → minimal safety net
|
||||
$willExpireBeforeUsed = $dailyRate > 0 && $daysToExpiry < $daysLeft;
|
||||
if ($isRegular && ($pctLeft < 50 || $willExpireBeforeUsed)) {
|
||||
if ($urgency === 'none') $urgency = 'medium';
|
||||
if ($willExpireBeforeUsed && $pctLeft >= 50) {
|
||||
// Has stock but won't finish it in time → buy fresh and use this one now
|
||||
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg — ricompra';
|
||||
} else {
|
||||
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg';
|
||||
}
|
||||
$score += 40;
|
||||
} elseif (!$isRegular && $daysToExpiry <= 3 && $pctLeft < 50) {
|
||||
// Non-regular product: only flag when very close and running low
|
||||
if ($urgency === 'none') $urgency = 'low';
|
||||
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg';
|
||||
$score += 20;
|
||||
}
|
||||
}
|
||||
|
||||
// Frequently used but stock getting low (predictive) — scale urgency by imminence
|
||||
@@ -10418,6 +10500,101 @@ function geminiNumberOCR(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ===== GEMINI AI: BARCODE VISUAL FALLBACK ====================================
|
||||
// =============================================================================
|
||||
/**
|
||||
* POST /api/?action=gemini_barcode_visual
|
||||
* Body: { image: base64-jpeg, lang: 'it'|'en'|'de'|... }
|
||||
* Returns: { found, source, product } or { found: false, error }
|
||||
* Uses Gemini vision to visually identify a product from a camera frame
|
||||
* when the barcode scanner fails to read the barcode after 5 seconds.
|
||||
*/
|
||||
function geminiBarcodeVisual(): void {
|
||||
EverLog::info('geminiBarcodeVisual');
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if (empty($apiKey)) {
|
||||
echo json_encode(['found' => false, 'error' => 'no_api_key']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$imageBase64 = $input['image'] ?? '';
|
||||
$lang = $input['lang'] ?? 'it';
|
||||
if (empty($imageBase64)) {
|
||||
echo json_encode(['found' => false, 'error' => 'no_image']);
|
||||
return;
|
||||
}
|
||||
|
||||
$langNote = match($lang) {
|
||||
'de' => 'Use the German product name if known.',
|
||||
'fr' => 'Use the French product name if known.',
|
||||
'es' => 'Use the Spanish product name if known.',
|
||||
default => 'Use the Italian product name if known.',
|
||||
};
|
||||
|
||||
$payload = [
|
||||
'contents' => [[
|
||||
'parts' => [
|
||||
['text' => "Identify the product shown in this image. {$langNote}\n" .
|
||||
"Respond with ONLY valid JSON (no markdown, no backticks):\n" .
|
||||
"{\"name\":\"...\",\"brand\":\"...\",\"category\":\"...\"}\n" .
|
||||
"- name: the product name (as specific as possible, not just the brand)\n" .
|
||||
"- brand: the brand/manufacturer, or empty string if not visible\n" .
|
||||
"- category: one of: latticini, pasta, bevande, snack, carne, pesce, " .
|
||||
"frutta, verdura, surgelati, condimenti, conserve, cereali, pane, " .
|
||||
"igiene, pulizia, altro\n" .
|
||||
"If you cannot identify the product at all, respond with: {\"unknown\":true}"],
|
||||
['inline_data' => ['mime_type' => 'image/jpeg', 'data' => $imageBase64]],
|
||||
],
|
||||
]],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0,
|
||||
'maxOutputTokens' => 200,
|
||||
'responseMimeType' => 'application/json',
|
||||
'thinkingConfig' => ['thinkingBudget' => 0],
|
||||
],
|
||||
];
|
||||
|
||||
$result = callGeminiWithFallback($apiKey, $payload, 15, 'barcode_visual');
|
||||
if ($result['http_code'] !== 200) {
|
||||
echo json_encode(['found' => false, 'error' => 'gemini_error_' . $result['http_code']]);
|
||||
return;
|
||||
}
|
||||
|
||||
$text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||
// Strip accidental markdown fences
|
||||
$text = preg_replace('/^```json\s*/i', '', $text);
|
||||
$text = preg_replace('/\s*```$/i', '', trim($text));
|
||||
|
||||
$data = json_decode($text, true);
|
||||
if (!$data || !empty($data['unknown']) || empty($data['name'])) {
|
||||
echo json_encode(['found' => false]);
|
||||
return;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'found' => true,
|
||||
'source' => 'gemini_visual',
|
||||
'product' => [
|
||||
'name' => $data['name'] ?? '',
|
||||
'brand' => $data['brand'] ?? '',
|
||||
'category' => $data['category'] ?? '',
|
||||
'image_url' => '',
|
||||
'quantity_info' => '',
|
||||
'nutriscore' => '',
|
||||
'ingredients' => '',
|
||||
'allergens' => '',
|
||||
'conservation' => '',
|
||||
'origin' => '',
|
||||
'nova_group' => '',
|
||||
'ecoscore' => '',
|
||||
'labels' => '',
|
||||
'stores' => '',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ===== GEMINI AI: ANOMALY EXPLANATION =======================================
|
||||
// =============================================================================
|
||||
|
||||
+372
-18
@@ -1943,6 +1943,8 @@ let quaggaRunning = false;
|
||||
let aiStream = null;
|
||||
let _scanZoomLevel = 2; // always 2x
|
||||
let _torchActive = false;
|
||||
let _aiFallbackTimer = null;
|
||||
let _aiFallbackExhausted = false; // true after one failed AI visual attempt — reset when scanner is closed
|
||||
|
||||
// Apply fixed 2x zoom (hardware if available, CSS fallback)
|
||||
async function _applyFixedZoom() {
|
||||
@@ -2083,13 +2085,15 @@ function _showScanConfirm(name) {
|
||||
|
||||
// ===== AI NUMBER OCR (Gemini reads printed barcode digits) =====
|
||||
let _numOcrRunning = false;
|
||||
async function _tryGeminiNumberOCR() {
|
||||
async function _tryGeminiNumberOCR(options = {}) {
|
||||
const { chainToVisual = false } = options;
|
||||
if (_numOcrRunning || !_requireGemini()) return;
|
||||
const video = document.getElementById('scanner-video');
|
||||
if (!video || !video.videoWidth) { showToast(t('error.camera'), 'error'); return; }
|
||||
_numOcrRunning = true;
|
||||
const btn = document.getElementById('scan-num-ocr-btn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = t('scan.num_ocr_searching'); }
|
||||
_setScanStatus(t('scan.status_ocr_searching'), 'retry', t('scan.method_ai_ocr'));
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
@@ -2098,12 +2102,28 @@ async function _tryGeminiNumberOCR() {
|
||||
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
|
||||
const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 });
|
||||
if (result.barcode) {
|
||||
scanLog(`AI OCR: found barcode ${result.barcode}`);
|
||||
showToast(t('scan.num_ocr_found').replace('{code}', result.barcode), 'success');
|
||||
onBarcodeDetected(result.barcode);
|
||||
} else {
|
||||
showToast(t('scan.num_ocr_not_found'), 'warning');
|
||||
scanLog('AI OCR: barcode digits not found');
|
||||
if (chainToVisual && scannerStream && !_aiFallbackExhausted) {
|
||||
scanLog('AI OCR failed — switching to visual product identification');
|
||||
_setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
|
||||
await _tryGeminiVisualBarcode();
|
||||
} else {
|
||||
showToast(t('scan.num_ocr_not_found'), 'warning');
|
||||
_setScanStatus(t('scan.status_scanning'), '', '');
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
scanLog(`AI OCR error: ${e.message}`);
|
||||
if (chainToVisual && scannerStream && !_aiFallbackExhausted) {
|
||||
_setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
|
||||
await _tryGeminiVisualBarcode();
|
||||
} else {
|
||||
_setScanStatus(t('scan.status_scanning'), '', '');
|
||||
}
|
||||
showToast(t('error.connection'), 'error');
|
||||
} finally {
|
||||
_numOcrRunning = false;
|
||||
@@ -2111,6 +2131,225 @@ async function _tryGeminiNumberOCR() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== AI VISUAL PRODUCT IDENTIFICATION (auto-fallback after 5s) =====
|
||||
let _aiBarcodeVisualRunning = false;
|
||||
let _aiDetectedProductDraft = null;
|
||||
let _aiInventoryCandidates = [];
|
||||
|
||||
function _showScanAiOverlay(msg) {
|
||||
const el = document.getElementById('scan-ai-overlay');
|
||||
const msgEl = document.getElementById('scan-ai-overlay-msg');
|
||||
if (el) el.style.display = 'flex';
|
||||
if (msgEl) msgEl.textContent = msg || '';
|
||||
}
|
||||
function _hideScanAiOverlay() {
|
||||
const el = document.getElementById('scan-ai-overlay');
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
function _showAiRetryButton() {
|
||||
const btn = document.getElementById('scan-ai-retry-btn');
|
||||
if (btn) btn.style.display = '';
|
||||
}
|
||||
function _clearAiMatchPanel() {
|
||||
const result = document.getElementById('scan-result');
|
||||
if (!result) return;
|
||||
result.style.display = 'none';
|
||||
result.innerHTML = '';
|
||||
_aiDetectedProductDraft = null;
|
||||
_aiInventoryCandidates = [];
|
||||
}
|
||||
function _renderAiCandidateRow(item, idx) {
|
||||
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
|
||||
const qty = (item.total_qty !== null && item.total_qty !== undefined)
|
||||
? `${parseFloat(item.total_qty)} ${item.unit || ''}`.trim()
|
||||
: '';
|
||||
return `
|
||||
<button class="scan-ai-candidate-item" type="button" onclick="_selectAiInventoryCandidate(${idx})">
|
||||
<span class="scan-ai-candidate-icon">${catIcon}</span>
|
||||
<span class="scan-ai-candidate-info">
|
||||
<span class="scan-ai-candidate-name">${escapeHtml(item.name || '')}</span>
|
||||
<span class="scan-ai-candidate-meta">${escapeHtml((item.brand || '') + (qty ? ' · ' + qty : ''))}</span>
|
||||
</span>
|
||||
<span class="scan-ai-candidate-cta">${t('scan.ai_match_use_btn')}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
function _showAiMatchChoices(aiProduct, candidates) {
|
||||
const result = document.getElementById('scan-result');
|
||||
if (!result) return;
|
||||
const aiName = aiProduct?.name || t('product.not_recognized');
|
||||
const aiBrand = aiProduct?.brand || '';
|
||||
const catIcon = CATEGORY_ICONS[mapToLocalCategory(aiProduct?.category || '', aiName)] || '📦';
|
||||
const itemsHtml = (candidates || []).slice(0, 3).map((it, i) => _renderAiCandidateRow(it, i)).join('');
|
||||
|
||||
result.innerHTML = `
|
||||
<div class="scan-ai-match-box">
|
||||
<div class="scan-ai-match-head">
|
||||
<div class="scan-ai-match-title">${t('scan.ai_match_title')}</div>
|
||||
<div class="scan-ai-match-subtitle">${t('scan.ai_match_subtitle')}</div>
|
||||
</div>
|
||||
|
||||
${itemsHtml ? `
|
||||
<div class="scan-ai-match-list-wrap">
|
||||
<div class="scan-ai-match-list-title">${t('scan.ai_match_existing')}</div>
|
||||
<div class="scan-ai-match-list">${itemsHtml}</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="scan-ai-match-empty">${t('scan.ai_match_none')}</div>
|
||||
`}
|
||||
|
||||
<button class="btn btn-primary scan-ai-add-btn" type="button" onclick="_confirmAiDetectedProduct()">
|
||||
${t('scan.ai_match_add_btn').replace('{name}', escapeHtml(aiName))}
|
||||
</button>
|
||||
<div class="scan-ai-detected-label">${t('scan.ai_detected_label')}</div>
|
||||
<div class="scan-ai-detected-pill">${catIcon} ${escapeHtml(aiName)}${aiBrand ? ' · ' + escapeHtml(aiBrand) : ''}</div>
|
||||
</div>
|
||||
`;
|
||||
result.style.display = 'block';
|
||||
}
|
||||
async function _confirmAiDetectedProduct() {
|
||||
const p = _aiDetectedProductDraft;
|
||||
if (!p) return;
|
||||
showLoading(true);
|
||||
try {
|
||||
const saveResult = await api('product_save', {}, 'POST', {
|
||||
barcode: '',
|
||||
name: p.name || t('product.not_recognized'),
|
||||
brand: p.brand || '',
|
||||
category: p.category || '',
|
||||
image_url: '',
|
||||
unit: 'pz',
|
||||
default_quantity: 1,
|
||||
package_unit: '',
|
||||
notes: '',
|
||||
});
|
||||
if (saveResult.id) {
|
||||
currentProduct = {
|
||||
id: saveResult.id,
|
||||
barcode: '',
|
||||
name: p.name || t('product.not_recognized'),
|
||||
brand: p.brand || '',
|
||||
category: p.category || '',
|
||||
image_url: '',
|
||||
unit: 'pz',
|
||||
default_quantity: 1,
|
||||
package_unit: '',
|
||||
_confCount: 0,
|
||||
weight_info: '',
|
||||
};
|
||||
addToScanRecents(currentProduct);
|
||||
_clearAiMatchPanel();
|
||||
showLoading(false);
|
||||
setTimeout(() => showProductAction(), 250);
|
||||
} else {
|
||||
showLoading(false);
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
} catch (_) {
|
||||
showLoading(false);
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
function _selectAiInventoryCandidate(idx) {
|
||||
const p = _aiInventoryCandidates[idx];
|
||||
if (!p) return;
|
||||
currentProduct = {
|
||||
id: p.id,
|
||||
barcode: p.barcode || '',
|
||||
name: p.name || '',
|
||||
brand: p.brand || '',
|
||||
category: p.category || '',
|
||||
image_url: p.image_url || '',
|
||||
unit: p.unit || 'pz',
|
||||
default_quantity: p.default_quantity || 1,
|
||||
package_unit: p.package_unit || '',
|
||||
_confCount: 0,
|
||||
weight_info: '',
|
||||
};
|
||||
if (p.notes) {
|
||||
const pesoMatch = p.notes.match(/Peso:\s*([^·]+)/);
|
||||
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
|
||||
}
|
||||
addToScanRecents(currentProduct);
|
||||
_clearAiMatchPanel();
|
||||
setTimeout(() => showProductAction(), 250);
|
||||
}
|
||||
async function _retryAiScan() {
|
||||
const btn = document.getElementById('scan-ai-retry-btn');
|
||||
if (btn) btn.style.display = 'none';
|
||||
_aiFallbackExhausted = false;
|
||||
_clearAiMatchPanel();
|
||||
await _tryGeminiNumberOCR({ chainToVisual: true });
|
||||
}
|
||||
|
||||
async function _tryGeminiVisualBarcode() {
|
||||
if (_aiBarcodeVisualRunning || !_requireGemini()) return;
|
||||
const video = document.getElementById('scanner-video');
|
||||
if (!video || !video.videoWidth) return;
|
||||
|
||||
// ★ Capture the frame BEFORE stopping the stream — after stopScanner() the
|
||||
// video element is blanked and drawImage would send a black image to Gemini.
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
|
||||
if (!imageBase64) { scanLog('AI visual: failed to capture frame'); return; }
|
||||
|
||||
_aiBarcodeVisualRunning = true;
|
||||
stopScanner(); // stop scanner loop while AI processes (stream already captured above)
|
||||
_setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
|
||||
_showScanAiOverlay(t('scan.ai_overlay_msg'));
|
||||
|
||||
try {
|
||||
const result = await api('gemini_barcode_visual', {}, 'POST', {
|
||||
image: imageBase64,
|
||||
lang: _currentLang || 'it',
|
||||
});
|
||||
|
||||
if (result.found && result.product) {
|
||||
const p = result.product;
|
||||
scanLog(`AI visual: found "${p.name}" (${p.brand})`);
|
||||
_hideScanAiOverlay();
|
||||
showToast(t('scan.ai_fallback_found'), 'success');
|
||||
_aiDetectedProductDraft = {
|
||||
name: p.name || t('product.not_recognized'),
|
||||
brand: p.brand || '',
|
||||
category: p.category || '',
|
||||
};
|
||||
let candidates = [];
|
||||
try {
|
||||
const invRes = await api('inventory_search', {
|
||||
q: _aiDetectedProductDraft.name,
|
||||
limit: 3,
|
||||
});
|
||||
candidates = (invRes.items || []).slice(0, 3);
|
||||
} catch (_) {
|
||||
candidates = [];
|
||||
}
|
||||
_aiInventoryCandidates = candidates;
|
||||
_showAiMatchChoices(_aiDetectedProductDraft, candidates);
|
||||
} else {
|
||||
scanLog('AI visual: product not identified — exhausted for this session');
|
||||
_aiFallbackExhausted = true;
|
||||
_hideScanAiOverlay();
|
||||
_setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', '');
|
||||
_showAiRetryButton();
|
||||
// Restart barcode scanner — AI timer won't fire again (_aiFallbackExhausted=true).
|
||||
setTimeout(() => initScanner(), 300);
|
||||
}
|
||||
} catch (e) {
|
||||
scanLog(`AI visual error: ${e.message}`);
|
||||
_aiFallbackExhausted = true;
|
||||
_hideScanAiOverlay();
|
||||
_setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', '');
|
||||
_showAiRetryButton();
|
||||
setTimeout(() => initScanner(), 300);
|
||||
} finally {
|
||||
_aiBarcodeVisualRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CAMERA HELPER =====
|
||||
function getCameraConstraints(extraVideo = {}) {
|
||||
const s = getSettings();
|
||||
@@ -2296,6 +2535,7 @@ function _applySyncedSettings(serverSettings) {
|
||||
'shopping_enabled','shopping_mode','shopping_smart_suggestions',
|
||||
'shopping_forecast','shopping_auto_add_threshold',
|
||||
'dark_mode',
|
||||
'barcode_ai_fallback',
|
||||
// Home Assistant
|
||||
'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events',
|
||||
'ha_notify_service','ha_expiry_days'];
|
||||
@@ -2887,6 +3127,8 @@ async function loadSettingsUI() {
|
||||
const cameraSelect = document.getElementById('setting-camera-facing');
|
||||
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
|
||||
loadCameraDevices();
|
||||
const baifEl = document.getElementById('setting-barcode-ai-fallback');
|
||||
if (baifEl) baifEl.checked = s.barcode_ai_fallback === true;
|
||||
renderAppliances(s.appliances || []);
|
||||
const mealPlanEnabled = s.meal_plan_enabled !== false;
|
||||
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
|
||||
@@ -3465,6 +3707,8 @@ async function saveSettings() {
|
||||
s.dietary = document.getElementById('setting-dietary').value.trim();
|
||||
// Camera
|
||||
s.camera_facing = document.getElementById('setting-camera-facing').value;
|
||||
const baifSave = document.getElementById('setting-barcode-ai-fallback');
|
||||
if (baifSave) s.barcode_ai_fallback = baifSave.checked;
|
||||
// Screensaver
|
||||
const ssEl = document.getElementById('setting-screensaver-enabled');
|
||||
if (ssEl) s.screensaver_enabled = ssEl.checked;
|
||||
@@ -3608,6 +3852,7 @@ async function saveSettings() {
|
||||
shopping_forecast: s.shopping_forecast !== false,
|
||||
shopping_auto_add_threshold: s.shopping_auto_add_threshold || 0,
|
||||
dark_mode: s.dark_mode || 'auto',
|
||||
barcode_ai_fallback: !!s.barcode_ai_fallback,
|
||||
// Home Assistant
|
||||
ha_enabled: !!s.ha_enabled,
|
||||
ha_url: s.ha_url || '',
|
||||
@@ -3748,6 +3993,18 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
|
||||
// Track current page for auto-refresh
|
||||
let _currentPageId = 'dashboard';
|
||||
let _currentPageParam = null;
|
||||
let _pageHistory = [{ pageId: 'dashboard', param: null }];
|
||||
|
||||
function goBack(fallbackPage = 'dashboard') {
|
||||
if (_pageHistory.length > 1) {
|
||||
// Drop current page and navigate to the previous entry without re-adding history.
|
||||
_pageHistory.pop();
|
||||
const prev = _pageHistory[_pageHistory.length - 1] || { pageId: fallbackPage, param: null };
|
||||
showPage(prev.pageId, prev.param, { skipHistory: true });
|
||||
return;
|
||||
}
|
||||
showPage(fallbackPage, null, { skipHistory: true });
|
||||
}
|
||||
|
||||
// Refresh current page data without full navigation
|
||||
function refreshCurrentPage() {
|
||||
@@ -3765,7 +4022,17 @@ function refreshCurrentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function showPage(pageId, param = null) {
|
||||
function showPage(pageId, param = null, options = {}) {
|
||||
const skipHistory = !!options.skipHistory;
|
||||
if (!skipHistory) {
|
||||
const last = _pageHistory[_pageHistory.length - 1];
|
||||
const sameAsLast = !!last && last.pageId === pageId && (last.param ?? null) === (param ?? null);
|
||||
if (!sameAsLast) {
|
||||
_pageHistory.push({ pageId, param });
|
||||
if (_pageHistory.length > 80) _pageHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
_currentPageId = pageId;
|
||||
_currentPageParam = param;
|
||||
// Hide all pages
|
||||
@@ -3797,7 +4064,7 @@ function showPage(pageId, param = null) {
|
||||
}
|
||||
loadInventory();
|
||||
break;
|
||||
case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode');
|
||||
case 'scan': _aiFallbackExhausted = false; _hideScanAiOverlay(); { const _rb = document.getElementById('scan-ai-retry-btn'); if (_rb) _rb.style.display = 'none'; } initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode');
|
||||
// Pre-warm the embedding model the first time user visits scan page
|
||||
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
|
||||
window._getCategoryPipeline(); // fire-and-forget
|
||||
@@ -4942,10 +5209,11 @@ async function loadBannerAlerts() {
|
||||
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
|
||||
|
||||
try {
|
||||
const [invData, predData, anomalyData, finishedData, statsData] = await Promise.all([
|
||||
const [invData, predData, anomalyData, dupLossData, finishedData, statsData] = await Promise.all([
|
||||
api('inventory_list'),
|
||||
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
|
||||
api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }),
|
||||
api('inventory_duplicate_loss_checks').catch(err => { console.warn('[Banner] duplicate loss checks fetch failed:', err); return { checks: [] }; }),
|
||||
api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }),
|
||||
api('stats').catch(() => ({ opened: [] })),
|
||||
]);
|
||||
@@ -5069,14 +5337,21 @@ async function loadBannerAlerts() {
|
||||
_bannerQueue.push({ type: 'anomaly', data: an });
|
||||
});
|
||||
|
||||
// 6. Finished products: inventory hit 0, waiting for user confirmation
|
||||
// 6. Potentially lost products due to rapid duplicate "out" events
|
||||
const dupChecks = dupLossData.checks || [];
|
||||
dupChecks.forEach(ch => {
|
||||
if (confirmed['dup_' + ch.dismiss_key]) return;
|
||||
_bannerQueue.push({ type: 'dup_loss_check', data: ch });
|
||||
});
|
||||
|
||||
// 7. Finished products: inventory hit 0, waiting for user confirmation
|
||||
const finished = finishedData.finished || [];
|
||||
finished.forEach(fin => {
|
||||
if (confirmed['fin_' + fin.product_id]) return;
|
||||
_bannerQueue.push({ type: 'finished', data: fin });
|
||||
});
|
||||
|
||||
// 7. Products with no expiry date set (and not permanently dismissed)
|
||||
// 8. Products with no expiry date set (and not permanently dismissed)
|
||||
// Warn for ALL food/drink items — only skip igiene/pulizia (non-food).
|
||||
// Items are capped at 8 per load (opened packages first) to avoid banner overflow.
|
||||
const noExpiryDismissed = _getNoExpiryDismissed();
|
||||
@@ -5157,6 +5432,8 @@ function _bannerPriority(entry) {
|
||||
// Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong)
|
||||
return entry.data.direction === 'missing' ? 260 : 250;
|
||||
}
|
||||
case 'dup_loss_check':
|
||||
return 700; // high-priority check: likely double-consume loss
|
||||
case 'finished':
|
||||
return 600; // product ran out — confirm before removing from DB
|
||||
case 'no_expiry':
|
||||
@@ -5358,6 +5635,30 @@ function renderBannerItem() {
|
||||
}
|
||||
actionsEl.innerHTML = btns;
|
||||
|
||||
} else if (entry.type === 'dup_loss_check') {
|
||||
const ch = entry.data;
|
||||
banner.className = 'alert-banner banner-dup-loss';
|
||||
iconEl.textContent = '🧪';
|
||||
|
||||
const locInfo = LOCATIONS[ch.location] || { icon: '📦', label: ch.location || '—' };
|
||||
const locText = `${locInfo.icon} ${locInfo.label}`;
|
||||
const qtyPair = `${ch.q1} + ${ch.q2}`;
|
||||
|
||||
titleEl.textContent = t('dashboard.banner_dup_loss_title').replace('{name}', ch.name);
|
||||
detailEl.textContent = t('dashboard.banner_dup_loss_detail')
|
||||
.replace('{location}', locText)
|
||||
.replace('{seconds}', Math.round(ch.dt_sec || 0))
|
||||
.replace('{qty_pair}', qtyPair);
|
||||
|
||||
let btns = '';
|
||||
if (ch.inventory_id && ch.inventory_id > 0) {
|
||||
btns += `<button class="btn-banner btn-banner-edit" onclick="editReviewItem(${ch.inventory_id}, ${ch.product_id})">${t('dashboard.banner_dup_loss_action_fix')}</button>`;
|
||||
} else {
|
||||
btns += `<button class="btn-banner btn-banner-edit" onclick="openDuplicateLossCheck(${ch.product_id})">${t('dashboard.banner_dup_loss_action_open')}</button>`;
|
||||
}
|
||||
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissDuplicateLossCheck()">${t('dashboard.banner_dup_loss_action_done')}</button>`;
|
||||
actionsEl.innerHTML = btns;
|
||||
|
||||
} else if (entry.type === 'no_expiry') {
|
||||
const item = entry.data;
|
||||
banner.className = 'alert-banner banner-no-expiry';
|
||||
@@ -5495,6 +5796,32 @@ function dismissBannerAnomaly() {
|
||||
dismissBannerItem();
|
||||
}
|
||||
|
||||
function dismissDuplicateLossCheck() {
|
||||
const entry = _bannerQueue[_bannerIndex];
|
||||
if (!entry || entry.type !== 'dup_loss_check') return;
|
||||
const key = entry.data.dismiss_key;
|
||||
setReviewConfirmed('dup_' + key);
|
||||
showToast(t('dashboard.banner_dup_loss_toast_done'), 'success');
|
||||
dismissBannerItem();
|
||||
}
|
||||
|
||||
async function openDuplicateLossCheck(productId) {
|
||||
showLoading(true);
|
||||
try {
|
||||
const data = await api('product_get', { id: productId });
|
||||
if (data.product) {
|
||||
currentProduct = data.product;
|
||||
showProductAction();
|
||||
} else {
|
||||
showToast(t('error.not_found'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t('error.connection'), 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function weighBannerItem() {
|
||||
const entry = _bannerQueue[_bannerIndex];
|
||||
if (!entry) return;
|
||||
@@ -6489,11 +6816,14 @@ async function initScanner() {
|
||||
|
||||
const constraints = getCameraConstraints();
|
||||
scanLog(`Camera mode: ${getSettings().camera_facing || 'environment'}`);
|
||||
scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (Quagga fallback)'}`);
|
||||
scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (fallback)'}`);
|
||||
scanLog(`Gemini available: ${_geminiAvailable}`);
|
||||
scanLog(`AI fallback enabled: ${getSettings().barcode_ai_fallback === true}`);
|
||||
scanLog(`Constraints: ${JSON.stringify(constraints.video)}`);
|
||||
|
||||
try {
|
||||
stopScanner();
|
||||
_clearAiMatchPanel();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const track = stream.getVideoTracks()[0];
|
||||
@@ -6528,6 +6858,17 @@ async function initScanner() {
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// After 5s without a scan, auto-trigger AI visual identification (if enabled)
|
||||
if (_geminiAvailable && getSettings().barcode_ai_fallback && !_aiFallbackExhausted) {
|
||||
clearTimeout(_aiFallbackTimer);
|
||||
_aiFallbackTimer = setTimeout(() => {
|
||||
if (scannerStream && !_aiFallbackExhausted) { // still scanning — no barcode found yet
|
||||
scanLog('5s elapsed without barcode — triggering AI OCR fallback');
|
||||
_tryGeminiNumberOCR({ chainToVisual: true });
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
|
||||
console.error('Camera error:', err);
|
||||
@@ -6598,14 +6939,14 @@ async function startNativeScanner(videoEl) {
|
||||
|
||||
if (frameCount === 1) {
|
||||
updateFeedback('scanning');
|
||||
_setScanStatus(t('scan.status_scanning'), '', 'Native API');
|
||||
_setScanStatus(t('scan.status_scanning'), '', '');
|
||||
}
|
||||
|
||||
// After 2s without detection, also start Quagga in parallel as backup
|
||||
if (!quaggaParallelStarted && (Date.now() - startTime) > 2000) {
|
||||
quaggaParallelStarted = true;
|
||||
scanLog('Native: 2s elapsed, spawning Quagga in parallel');
|
||||
_setScanStatus(t('scan.status_parallel'), 'retry', 'Native + Quagga');
|
||||
scanLog('Native: 2s elapsed, spawning fallback scanner in parallel');
|
||||
_setScanStatus(t('scan.status_parallel'), 'retry', '');
|
||||
quaggaRunning = false; // temporarily release so Quagga can start
|
||||
startQuaggaScanner(videoEl, false);
|
||||
quaggaRunning = true; // re-take ownership (Quagga will share)
|
||||
@@ -6637,6 +6978,13 @@ async function startNativeScanner(videoEl) {
|
||||
// For other formats (code_128, code_39) require 2 to avoid false reads.
|
||||
const highConfidence = ['ean_13','ean_8','upc_a','upc_e'].includes(format);
|
||||
if (highConfidence || detectCount >= 2 || detectionHistory[code].count >= 2) {
|
||||
if (highConfidence && !validateEANChecksum(code)) {
|
||||
_invalidBarcodeCount++;
|
||||
scanLog(`Invalid EAN checksum (native): ${code} (retry #${_invalidBarcodeCount})`);
|
||||
_setScanStatus(t('scan.status_invalid').replace('{code}', code), 'invalid', 'Native');
|
||||
lastDetected = ''; detectCount = 0;
|
||||
return;
|
||||
}
|
||||
scanning = false;
|
||||
quaggaRunning = false;
|
||||
updateFeedback(null);
|
||||
@@ -6674,7 +7022,7 @@ function startQuaggaScanner(videoEl, isPrimary = true) {
|
||||
let frameCount = 0;
|
||||
let partialCount = 0;
|
||||
|
||||
scanLog(`Quagga starting — frontCam: ${frontCam}`);
|
||||
scanLog(`Fallback scanner starting — frontCam: ${frontCam}`);
|
||||
|
||||
let scanning = true;
|
||||
quaggaRunning = true;
|
||||
@@ -6850,6 +7198,10 @@ function stopScanner() {
|
||||
quaggaRunning = false;
|
||||
_scanZoomLevel = 2; // always 2x on next start
|
||||
_torchActive = false;
|
||||
clearTimeout(_aiFallbackTimer); _aiFallbackTimer = null;
|
||||
// NOTE: _aiFallbackExhausted is intentionally NOT reset here.
|
||||
// It is only reset in showPage('scan') so that internal stop/restart
|
||||
// cycles (e.g. initScanner calling stopScanner) don't re-arm the AI timer.
|
||||
if (scannerStream) {
|
||||
scannerStream.getTracks().forEach(t => t.stop());
|
||||
scannerStream = null;
|
||||
@@ -6861,6 +7213,7 @@ function stopScanner() {
|
||||
if (tb) tb.classList.remove('torch-on');
|
||||
// Hide live code
|
||||
_hideScanLiveCode();
|
||||
_clearAiMatchPanel();
|
||||
// Also stop AI camera
|
||||
if (aiStream) {
|
||||
aiStream.getTracks().forEach(t => t.stop());
|
||||
@@ -6871,6 +7224,7 @@ function stopScanner() {
|
||||
}
|
||||
|
||||
async function onBarcodeDetected(barcode) {
|
||||
clearTimeout(_aiFallbackTimer); _aiFallbackTimer = null;
|
||||
showLoading(true);
|
||||
|
||||
// Vibrate if available
|
||||
@@ -7026,7 +7380,7 @@ function autoSubmitEAN(inputEl, force = false) {
|
||||
if (!raw) { showToast(t('error.barcode_empty'), 'error'); inputEl.focus(); return; }
|
||||
if (!/^\d{4,14}$/.test(raw)) { showToast(t('error.barcode_format'), 'error'); inputEl.focus(); return; }
|
||||
if (isComplete && !isValid) {
|
||||
showToast('⚠️ Checksum EAN errato — verifica le cifre', 'warning');
|
||||
showToast(t('error.barcode_checksum'), 'error'); inputEl.focus(); return;
|
||||
}
|
||||
stopScanner();
|
||||
onBarcodeDetected(raw);
|
||||
@@ -7781,7 +8135,7 @@ function showProductAction() {
|
||||
|
||||
// Update back button: go back to shopping if came from shopping list scan
|
||||
const backBtn = document.getElementById('action-back-btn');
|
||||
if (backBtn) backBtn.onclick = _spesaScanTarget ? () => { _spesaScanTarget = null; showPage('shopping'); } : () => showPage('scan');
|
||||
if (backBtn) backBtn.onclick = () => goBack();
|
||||
|
||||
// Show "shopping target" banner if we came from the shopping list
|
||||
const banner = document.getElementById('shopping-scan-target-banner');
|
||||
@@ -7795,7 +8149,7 @@ function showProductAction() {
|
||||
</div>
|
||||
<div class="shopping-scan-target-actions">
|
||||
<button class="btn btn-success stb-btn" onclick="confirmShoppingItemFound()">✅ ${t('shopping.scan_target_found')}</button>
|
||||
<button class="btn btn-secondary stb-btn" onclick="_spesaScanTarget=null; document.getElementById('shopping-scan-target-banner').style.display='none'; document.getElementById('action-back-btn').onclick=()=>showPage('scan')">✕ ${t('btn.cancel')}</button>
|
||||
<button class="btn btn-secondary stb-btn" onclick="_spesaScanTarget=null; document.getElementById('shopping-scan-target-banner').style.display='none'; document.getElementById('action-back-btn').onclick=()=>goBack()">✕ ${t('btn.cancel')}</button>
|
||||
</div>
|
||||
`;
|
||||
} else if (banner) {
|
||||
@@ -13559,7 +13913,7 @@ async function toggleRecipeFavorite(btn) {
|
||||
* Scale recipe ingredient quantities (#123).
|
||||
* Delta: +1 or -1. Min 1, max 20 persons.
|
||||
*/
|
||||
function adjustRecipePersons(delta) {
|
||||
function scaleRecipePersons(delta) {
|
||||
const newPersons = Math.max(1, Math.min(20, _recipeCurrentPersons + delta));
|
||||
if (newPersons === _recipeCurrentPersons) return;
|
||||
_recipeCurrentPersons = newPersons;
|
||||
@@ -13604,9 +13958,9 @@ function renderRecipe(r) {
|
||||
html += '<div class="recipe-meta">';
|
||||
if (r.meal) html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
|
||||
html += `<span class="recipe-tag recipe-persons-ctrl">
|
||||
<button class="btn-persons-adj" onclick="adjustRecipePersons(-1)">−</button>
|
||||
<button class="btn-persons-adj" onclick="scaleRecipePersons(-1)">−</button>
|
||||
<span id="recipe-persons-display">👥 ${r.persons} ${t('recipes.persons_short')}</span>
|
||||
<button class="btn-persons-adj" onclick="adjustRecipePersons(+1)">+</button>
|
||||
<button class="btn-persons-adj" onclick="scaleRecipePersons(+1)">+</button>
|
||||
</span>`;
|
||||
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
|
||||
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
|
||||
|
||||
+34
-13
@@ -64,7 +64,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.25</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.35</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,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.25</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.35</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -194,7 +194,7 @@
|
||||
<!-- ===== INVENTORY LIST ===== -->
|
||||
<section class="page" id="page-inventory">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
|
||||
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
|
||||
</div>
|
||||
@@ -225,7 +225,7 @@
|
||||
<!-- ===== SCAN PAGE ===== -->
|
||||
<section class="page" id="page-scan">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="scan.title">Scansiona</h2>
|
||||
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
|
||||
</div>
|
||||
@@ -256,6 +256,14 @@
|
||||
<span id="scan-status-method" class="scan-status-method"></span>
|
||||
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
|
||||
</div>
|
||||
<!-- AI processing overlay (shown when Gemini Vision is analyzing) -->
|
||||
<div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none">
|
||||
<div class="scan-ai-overlay-inner">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="scan-ai-overlay-label">Gemini Vision</span>
|
||||
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Success flash overlay -->
|
||||
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
||||
<div class="scan-confirm-check">✓</div>
|
||||
@@ -274,6 +282,9 @@
|
||||
<!-- Scan errors -->
|
||||
<div class="scan-result" id="scan-result" style="display:none"></div>
|
||||
|
||||
<!-- AI retry button (shown after visual identification fails) -->
|
||||
<button class="btn btn-accent scan-ai-retry-btn" id="scan-ai-retry-btn" style="display:none" onclick="_retryAiScan()" data-i18n="scan.ai_retry_btn">🤖 Riprova con AI</button>
|
||||
|
||||
<!-- Recent scans -->
|
||||
<div class="scan-recents" id="scan-recents" style="display:none">
|
||||
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
||||
@@ -333,7 +344,7 @@
|
||||
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
||||
<section class="page" id="page-action">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" id="action-back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" id="action-back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
|
||||
</div>
|
||||
<!-- Banner: shopping list scan context -->
|
||||
@@ -356,7 +367,7 @@
|
||||
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
||||
<section class="page" id="page-add">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
||||
</div>
|
||||
<div class="product-preview-small" id="add-product-preview"></div>
|
||||
@@ -419,7 +430,7 @@
|
||||
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
||||
<section class="page" id="page-use">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
||||
</div>
|
||||
<div class="product-preview-small" id="use-product-preview"></div>
|
||||
@@ -475,7 +486,7 @@
|
||||
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
||||
<section class="page" id="page-product-form">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
||||
</div>
|
||||
<form class="form" onsubmit="submitProduct(event)">
|
||||
@@ -663,7 +674,7 @@
|
||||
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
||||
<section class="page" id="page-products">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
@@ -675,7 +686,7 @@
|
||||
<!-- ===== RECIPE PAGE ===== -->
|
||||
<section class="page" id="page-recipe">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
||||
</div>
|
||||
<div class="recipe-page-container">
|
||||
@@ -689,7 +700,7 @@
|
||||
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
||||
<section class="page" id="page-shopping">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
|
||||
</div>
|
||||
<div class="shopping-container">
|
||||
@@ -797,7 +808,7 @@
|
||||
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||
<section class="page" id="page-ai">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
||||
</div>
|
||||
<div class="ai-container">
|
||||
@@ -835,7 +846,7 @@
|
||||
<!-- ===== SETTINGS PAGE ===== -->
|
||||
<section class="page" id="page-settings">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
||||
</div>
|
||||
<div class="settings-tabs">
|
||||
@@ -1183,6 +1194,16 @@
|
||||
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:14px">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.camera.ai_fallback_label">Identificazione visiva AI (fallback 5s)</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-barcode-ai-fallback">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
<p class="settings-hint mt-2" data-i18n="settings.camera.ai_fallback_hint">Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare visivamente il prodotto. Richiede Gemini configurato.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Security Tab -->
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.25",
|
||||
"version": "1.7.35",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
+1474
-1448
File diff suppressed because it is too large
Load Diff
+1474
-1448
File diff suppressed because it is too large
Load Diff
+1417
-1391
File diff suppressed because it is too large
Load Diff
+1417
-1391
File diff suppressed because it is too large
Load Diff
+1473
-1447
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user