Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51f55071fa | |||
| 3a4e843334 | |||
| 7104483dac | |||
| 94e98bc79f | |||
| fd039d743e | |||
| b1bcf9e714 | |||
| 98c38f017e | |||
| 7947f47e6d | |||
| 758eb93e20 | |||
| ff1175451a | |||
| 42630c3e3e | |||
| 637eaa20d6 | |||
| 5e307f79b8 | |||
| a6478b20e1 | |||
| 223457bbdf | |||
| 12c6a8977a | |||
| c7a69d8379 | |||
| c7f3c95d75 | |||
| a6f90a07e5 | |||
| 2d07001c5b | |||
| faa55eda93 | |||
| 0b902d7c19 |
@@ -206,7 +206,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
if: steps.tag_check.outputs.exists == 'false'
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.version.outputs.version }}
|
tag_name: ${{ steps.version.outputs.version }}
|
||||||
name: "EverShelf ${{ steps.version.outputs.version }}"
|
name: "EverShelf ${{ steps.version.outputs.version }}"
|
||||||
|
|||||||
@@ -11,6 +11,53 @@ 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.
|
- **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
|
||||||
|
- **False consumption anomaly with multi-row stock** — The anomaly detection banner was evaluating each inventory row in isolation. Products split across multiple rows (e.g. one opened pack with 1 pz + one sealed pack with 6 pz) incorrectly triggered a "consumed faster than expected" warning because only the opened row (1 pz) was compared against the model. The check now aggregates the total quantity across all rows for the same product before deciding to flag an anomaly. If the combined total ≥ expected remaining, the anomaly is suppressed.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.29] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Buy-cycle consumption prediction** — Products that are never tracked per-use (salt, spices, cleaning supplies, etc.) now use the average time between restocks as a proxy for consumption rate. When a product has ≥ 3 purchase events and no individual `out` events, EverShelf calculates the average buy cycle (`(lastBuy - firstBuy) / (buyCount - 1)`) and estimates how many days of stock remain in the current cycle. The product appears in the smart shopping list with a reason like "Finisce tra ~12gg (ciclo medio 75gg)" before it runs out, rather than only after. These products are now also treated as `isRegular` so all stock-level urgency checks apply correctly.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.28] - 2026-05-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Duplicate auto-reported issues** — The GitHub issue reporter was relying solely on the GitHub Search API for deduplication. Because search indexing has a several-minutes lag, rapid error recurrences each created a new issue before the previous one was indexed, producing ~50 duplicate issues. The reporter now uses a local file cache (`data/reported_issue_fps.json`, with `/tmp/` fallback when `data/` is not writable) as the primary deduplication store. A 30-minute per-fingerprint comment throttle is also applied to prevent flooding an existing issue. GitHub Search is used only on first run or after a cache miss. Closes [#134](https://github.com/dadaloop82/EverShelf/issues/134) (and all duplicates #135–#183).
|
||||||
|
|
||||||
## [1.7.27] - 2026-05-29
|
## [1.7.27] - 2026-05-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
[](https://www.sqlite.org/)
|
[](https://www.sqlite.org/)
|
||||||
[](Dockerfile)
|
[](Dockerfile)
|
||||||
[](translations/)
|
[](translations/)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||||
|
|||||||
+322
-34
@@ -604,7 +604,7 @@ function checkRateLimit(string $action): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine limit based on action
|
// 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 = [];
|
$loginActions = [];
|
||||||
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
||||||
$errorActions = ['report_error', 'check_update'];
|
$errorActions = ['report_error', 'check_update'];
|
||||||
@@ -1109,6 +1109,10 @@ try {
|
|||||||
geminiNumberOCR();
|
geminiNumberOCR();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'gemini_barcode_visual':
|
||||||
|
geminiBarcodeVisual();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'get_shopping_price':
|
case 'get_shopping_price':
|
||||||
getShoppingPrice($db);
|
getShoppingPrice($db);
|
||||||
break;
|
break;
|
||||||
@@ -1570,7 +1574,9 @@ function haInventorySensor(PDO $db): void {
|
|||||||
$daysToNextExpiry = (int)$diff->format('%r%a');
|
$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';
|
$priceEnabled = env('PRICE_ENABLED', 'false') === 'true';
|
||||||
$priceCurrency = env('PRICE_CURRENCY', 'EUR');
|
$priceCurrency = env('PRICE_CURRENCY', 'EUR');
|
||||||
$shoppingTotal = null;
|
$shoppingTotal = null;
|
||||||
@@ -1578,19 +1584,58 @@ function haInventorySensor(PDO $db): void {
|
|||||||
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
|
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
|
||||||
if (file_exists($totalCachePath)) {
|
if (file_exists($totalCachePath)) {
|
||||||
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
|
$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) {
|
foreach ($tc as $entry) {
|
||||||
if (isset($entry['ts']) && $entry['ts'] > $bestTs) {
|
if (isset($entry['ts']) && $entry['ts'] > $bestTs) {
|
||||||
$bestTs = $entry['ts'];
|
$bestTs = $entry['ts'];
|
||||||
$best = $entry;
|
$best = $entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($best && (time() - $bestTs) < 3600) {
|
if ($best && (time() - $bestTs) < 86400) {
|
||||||
$shoppingTotal = round((float)($best['result']['total'] ?? 0), 2);
|
$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) {
|
$stateValue = match($sensor) {
|
||||||
@@ -1786,9 +1831,17 @@ function haRefreshPrices(PDO $db): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
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' => ''];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1799,8 +1852,9 @@ function haRefreshPrices(PDO $db): void {
|
|||||||
|
|
||||||
foreach ($shoppingItems as $item) {
|
foreach ($shoppingItems as $item) {
|
||||||
$key = _priceKey($item['name'], $country);
|
$key = _priceKey($item['name'], $country);
|
||||||
if (isset($priceCache[$key])) {
|
$key0 = md5(mb_strtolower(trim($item['name'])) . '|' . mb_strtolower(trim($country)));
|
||||||
$entry = $priceCache[$key];
|
$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']);
|
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
|
||||||
$total += $est ?? 0;
|
$total += $est ?? 0;
|
||||||
$priced++;
|
$priced++;
|
||||||
@@ -2634,19 +2688,26 @@ function addToInventory(PDO $db): void {
|
|||||||
|
|
||||||
$vacuumSealed = (int)($input['vacuum_sealed'] ?? 0);
|
$vacuumSealed = (int)($input['vacuum_sealed'] ?? 0);
|
||||||
|
|
||||||
// Check if product already exists in this location
|
// Check if a SEALED (not yet opened) row exists for this product+location.
|
||||||
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND 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]);
|
$stmt->execute([$productId, $location]);
|
||||||
$existing = $stmt->fetch();
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
// Update quantity
|
// Merge into the existing sealed row
|
||||||
$newQty = $existing['quantity'] + $quantity;
|
$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 = $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->execute([$newQty, $expiry, $vacuumSealed, $existing['id']]);
|
||||||
} else {
|
} else {
|
||||||
$newQty = $quantity;
|
$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 = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)");
|
||||||
$stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed]);
|
$stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed]);
|
||||||
}
|
}
|
||||||
@@ -4155,6 +4216,24 @@ function getConsumptionPredictions(PDO $db): void {
|
|||||||
$expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock));
|
$expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock));
|
||||||
$actualQty = floatval($item['quantity']);
|
$actualQty = floatval($item['quantity']);
|
||||||
|
|
||||||
|
// Aggregate total stock for this product across ALL inventory rows.
|
||||||
|
// A product may be split into multiple rows (e.g. one opened pack + one
|
||||||
|
// sealed pack at a different location). The opened row alone may look
|
||||||
|
// depleted while the total is healthy — do not flag in that case.
|
||||||
|
$totalQtyStmt = $db->prepare("
|
||||||
|
SELECT COALESCE(SUM(quantity), 0)
|
||||||
|
FROM inventory
|
||||||
|
WHERE product_id = ? AND quantity > 0
|
||||||
|
");
|
||||||
|
$totalQtyStmt->execute([$pid]);
|
||||||
|
$totalQtyAllRows = floatval($totalQtyStmt->fetchColumn() ?: 0);
|
||||||
|
// If the aggregate total is above the expected remaining, the "depletion"
|
||||||
|
// is just stock spread across rows — suppress the anomaly.
|
||||||
|
if ($totalQtyAllRows >= $expectedQty) continue;
|
||||||
|
// Use the aggregate total as the visible actual qty so the banner shows
|
||||||
|
// the real combined stock, not just the single opened row.
|
||||||
|
$actualQty = $totalQtyAllRows;
|
||||||
|
|
||||||
// Need at least some post-restock usage observations before warning.
|
// Need at least some post-restock usage observations before warning.
|
||||||
if ($txSinceRestock < 2) continue;
|
if ($txSinceRestock < 2) continue;
|
||||||
|
|
||||||
@@ -4278,6 +4357,7 @@ function getServerSettings(): void {
|
|||||||
'shopping_forecast' => env('SHOPPING_FORECAST', 'true') === 'true',
|
'shopping_forecast' => env('SHOPPING_FORECAST', 'true') === 'true',
|
||||||
'shopping_auto_add_threshold' => (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'),
|
'shopping_auto_add_threshold' => (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'),
|
||||||
'dark_mode' => env('DARK_MODE', 'auto'),
|
'dark_mode' => env('DARK_MODE', 'auto'),
|
||||||
|
'barcode_ai_fallback' => env('BARCODE_AI_FALLBACK', 'false') === 'true',
|
||||||
// Home Assistant Integration
|
// Home Assistant Integration
|
||||||
'ha_enabled' => env('HA_ENABLED', 'false') === 'true',
|
'ha_enabled' => env('HA_ENABLED', 'false') === 'true',
|
||||||
'ha_url' => env('HA_URL', ''),
|
'ha_url' => env('HA_URL', ''),
|
||||||
@@ -4380,6 +4460,7 @@ function saveSettings(): void {
|
|||||||
'shopping_enabled' => 'SHOPPING_ENABLED',
|
'shopping_enabled' => 'SHOPPING_ENABLED',
|
||||||
'shopping_smart_suggestions' => 'SHOPPING_SMART_SUGGESTIONS',
|
'shopping_smart_suggestions' => 'SHOPPING_SMART_SUGGESTIONS',
|
||||||
'shopping_forecast' => 'SHOPPING_FORECAST',
|
'shopping_forecast' => 'SHOPPING_FORECAST',
|
||||||
|
'barcode_ai_fallback' => 'BARCODE_AI_FALLBACK',
|
||||||
// Home Assistant
|
// Home Assistant
|
||||||
'ha_enabled' => 'HA_ENABLED',
|
'ha_enabled' => 'HA_ENABLED',
|
||||||
];
|
];
|
||||||
@@ -8813,6 +8894,29 @@ function smartShopping(PDO $db): void {
|
|||||||
$dailyRate = $effectiveDays < 999 && $totalUsed > 0 ? $totalUsed / $effectiveDays : 0;
|
$dailyRate = $effectiveDays < 999 && $totalUsed > 0 ? $totalUsed / $effectiveDays : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Buy-cycle proxy (for products tracked without individual 'out' events) ---
|
||||||
|
// Products like salt, spices, cleaning products are never logged per-use.
|
||||||
|
// When the user buys them again it implicitly means the previous pack ran out.
|
||||||
|
// If we have ≥ 3 buy events and no (or very few) out events, we estimate
|
||||||
|
// the average cycle duration = (lastIn - firstIn) / (buyCount - 1) and
|
||||||
|
// project how many days of stock are likely left in the current cycle.
|
||||||
|
// estimatedDaysLeft = avgCycleDays − daysSinceLastBuy
|
||||||
|
// This dailyRate proxy is ONLY used when the regular out-based rate is 0.
|
||||||
|
$buyCycleDays = null; // avg days per buy cycle
|
||||||
|
$buyCycleDaysLeft = null; // estimated days remaining in current cycle
|
||||||
|
if ($dailyRate == 0 && $buyCount >= 3 && $firstIn && $lastIn && $lastIn > $firstIn) {
|
||||||
|
$buyCycleDays = ($lastIn - $firstIn) / 86400 / ($buyCount - 1);
|
||||||
|
if ($buyCycleDays >= 7) { // ignore implausible < 1-week cycles
|
||||||
|
$daysSinceLastBuyFloat = ($now - $lastIn) / 86400;
|
||||||
|
$buyCycleDaysLeft = max(0, $buyCycleDays - $daysSinceLastBuyFloat);
|
||||||
|
// Derive a synthetic dailyRate so existing daysLeft / pctLeft logic works naturally
|
||||||
|
// 1 restock event ≈ consuming 1 "average package" over avgCycleDays
|
||||||
|
if ($qty > 0 && $buyCycleDays > 0) {
|
||||||
|
$dailyRate = $qty / max(1, $buyCycleDaysLeft > 0 ? $buyCycleDaysLeft : $buyCycleDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Days of stock remaining
|
// Days of stock remaining
|
||||||
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0);
|
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0);
|
||||||
|
|
||||||
@@ -8820,7 +8924,9 @@ function smartShopping(PDO $db): void {
|
|||||||
$expiryDate = $inv ? $inv['nearest_expiry'] : null;
|
$expiryDate = $inv ? $inv['nearest_expiry'] : null;
|
||||||
$daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999;
|
$daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999;
|
||||||
$isExpired = $daysToExpiry < 0;
|
$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
|
// Fresh (non-expired) quantity — used for suppression when only part of stock is expired
|
||||||
$freshQty = $inv ? (float)($inv['fresh_qty'] ?? $qty) : 0;
|
$freshQty = $inv ? (float)($inv['fresh_qty'] ?? $qty) : 0;
|
||||||
@@ -8853,7 +8959,9 @@ function smartShopping(PDO $db): void {
|
|||||||
// Is this a frequently used product? (≥ 1.5 uses/month)
|
// Is this a frequently used product? (≥ 1.5 uses/month)
|
||||||
$isFrequent = $usesPerMonth >= 1.5;
|
$isFrequent = $usesPerMonth >= 1.5;
|
||||||
// Is it a regular product? (≥ 0.5 uses/month = at least once every 2 months)
|
// Is it a regular product? (≥ 0.5 uses/month = at least once every 2 months)
|
||||||
$isRegular = $usesPerMonth >= 0.5;
|
// Also treat buy-cycle products (≥3 buys, no out events) as regular — they are
|
||||||
|
// by definition products the user buys periodically.
|
||||||
|
$isRegular = $usesPerMonth >= 0.5 || ($buyCycleDays !== null && $buyCount >= 3);
|
||||||
// Is it recently relevant? (used/bought in last 60 days)
|
// Is it recently relevant? (used/bought in last 60 days)
|
||||||
$isRecent = $daysSinceLastUse <= 60;
|
$isRecent = $daysSinceLastUse <= 60;
|
||||||
|
|
||||||
@@ -8951,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) {
|
if ($isExpired && $qty > 0) {
|
||||||
// Check if the product's shopping_name FAMILY has adequate FRESH stock
|
// Check if the product's shopping_name FAMILY has adequate FRESH stock
|
||||||
// from other (non-expired) products. If so, no need to buy more.
|
// from other (non-expired) products. If so, no need to buy more.
|
||||||
$sNameKey = strtolower(trim($p['shopping_name'] ?? ''));
|
$sNameKey = strtolower(trim($p['shopping_name'] ?? ''));
|
||||||
$familyFreshQty = $sNameKey !== '' ? ($freshStockByShoppingName[$sNameKey] ?? 0) : 0;
|
$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;
|
$refQtyLocal = $refQty > 0 ? $refQty : 1;
|
||||||
$familyFreshPct = min(200, ($familyFreshQty / $refQtyLocal) * 100);
|
$familyFreshPct = min(200, ($familyFreshQty / $refQtyLocal) * 100);
|
||||||
|
|
||||||
if (($justRestocked && $freshPctLeft >= 50) || $familyFreshPct >= 50) {
|
if (($justRestocked && $freshPctLeft >= 50) || $familyFreshPct >= 50) {
|
||||||
// Fresh stock from this product or same-family products is adequate.
|
// 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.
|
// 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';
|
$urgency = 'critical';
|
||||||
$reasons[] = 'Scaduto!';
|
$reasons[] = 'Scaduto!';
|
||||||
$score += 90;
|
$score += 90;
|
||||||
}
|
}
|
||||||
} elseif ($isExpiringSoon && $qty > 0 && $pctLeft < 50) {
|
// else: one-off product expired unused → expiry banner handles it, no shopping noise
|
||||||
// Only flag "expiring soon" if stock is also low (<50%). If you have plenty of
|
} elseif ($isExpiringSoon && $qty > 0) {
|
||||||
// stock (e.g. just bought fresh produce that naturally expires in 3 days), the
|
// Flag if:
|
||||||
// shopping list is not the right place — the expiry banner handles it.
|
// (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 ($urgency === 'none') $urgency = 'medium';
|
||||||
$reasons[] = 'Scade tra ' . max(0, round($daysToExpiry)) . 'gg';
|
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;
|
$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
|
// Frequently used but stock getting low (predictive) — scale urgency by imminence
|
||||||
@@ -8983,11 +9108,24 @@ function smartShopping(PDO $db): void {
|
|||||||
$daysLeftDisplay = (int)round($daysLeft);
|
$daysLeftDisplay = (int)round($daysLeft);
|
||||||
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg';
|
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg';
|
||||||
if ($daysLeftDisplay <= 3) {
|
if ($daysLeftDisplay <= 3) {
|
||||||
// Running out within 3 days for a frequent product → high urgency
|
|
||||||
$urgency = 'high';
|
$urgency = 'high';
|
||||||
$score += 70;
|
$score += 70;
|
||||||
} elseif ($daysLeftDisplay <= 7) {
|
} elseif ($daysLeftDisplay <= 7) {
|
||||||
// Running out within a week → medium
|
$urgency = 'medium';
|
||||||
|
$score += 45;
|
||||||
|
} else {
|
||||||
|
$urgency = 'low';
|
||||||
|
$score += 25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Buy-cycle prediction for products not tracked per-use (e.g. salt, spices):
|
||||||
|
// if daily rate was derived from buy cycles and we have < 21 days left → flag.
|
||||||
|
if ($urgency === 'none' && $buyCycleDays !== null && $dailyRate > 0
|
||||||
|
&& $daysLeft <= 21 && $isRegular && !$justRestocked) {
|
||||||
|
$daysLeftDisplay = (int)round($daysLeft);
|
||||||
|
$cycleDisplay = (int)round($buyCycleDays);
|
||||||
|
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg (ciclo medio ' . $cycleDisplay . 'gg)';
|
||||||
|
if ($daysLeftDisplay <= 7) {
|
||||||
$urgency = 'medium';
|
$urgency = 'medium';
|
||||||
$score += 45;
|
$score += 45;
|
||||||
} else {
|
} else {
|
||||||
@@ -9931,9 +10069,41 @@ function checkUpdate(): void {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return path to the local fingerprint deduplication cache.
|
||||||
|
* Falls back to /tmp when data/ is not writable (e.g. fresh install with wrong perms).
|
||||||
|
*/
|
||||||
|
function _getFpCachePath(): string {
|
||||||
|
$primary = __DIR__ . '/../data/reported_issue_fps.json';
|
||||||
|
return is_writable(dirname($primary)) ? $primary : (sys_get_temp_dir() . '/evershelf_fps.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load & prune (> 30 days) the local FP cache. */
|
||||||
|
function _loadFpCache(): array {
|
||||||
|
$path = _getFpCachePath();
|
||||||
|
if (!file_exists($path)) return [];
|
||||||
|
$data = @json_decode(@file_get_contents($path), true) ?: [];
|
||||||
|
$cutoff = time() - 30 * 86400;
|
||||||
|
return array_filter($data, fn($v) => ($v['ts'] ?? 0) > $cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the local FP cache. */
|
||||||
|
function _saveFpCache(array $cache): void {
|
||||||
|
@file_put_contents(_getFpCachePath(), json_encode($cache), LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a GitHub issue, or add a comment to an existing open issue with the
|
* Create a GitHub issue, or add a comment to an existing open issue with the
|
||||||
* same fingerprint. Uses the REST API v3 directly (no library needed).
|
* same fingerprint. Uses the REST API v3 directly (no library needed).
|
||||||
|
*
|
||||||
|
* Deduplication strategy (two-layer):
|
||||||
|
* 1. Local file cache (data/reported_issue_fps.json or /tmp fallback) — checked
|
||||||
|
* first to avoid the GitHub Search API indexing delay that caused duplicate
|
||||||
|
* issues to be created in rapid succession.
|
||||||
|
* 2. GitHub Search API — used only on first occurrence (cache miss) as backup.
|
||||||
|
*
|
||||||
|
* Comment throttle: at most one recurrence comment per 30 minutes per fingerprint,
|
||||||
|
* to avoid flooding an issue when an error fires on every request.
|
||||||
*/
|
*/
|
||||||
function _createOrCommentGithubIssue(
|
function _createOrCommentGithubIssue(
|
||||||
string $token, string $repo,
|
string $token, string $repo,
|
||||||
@@ -9944,13 +10114,27 @@ function _createOrCommentGithubIssue(
|
|||||||
$fp = _errorFingerprint($source, $type, $message);
|
$fp = _errorFingerprint($source, $type, $message);
|
||||||
EverLog::debug('_createOrCommentGithubIssue', ['fp' => $fp, 'type' => $type]);
|
EverLog::debug('_createOrCommentGithubIssue', ['fp' => $fp, 'type' => $type]);
|
||||||
|
|
||||||
// ── 1. Search for an existing open issue with this fingerprint ─────────
|
// ── 1. Check local cache (fast, avoids Search API indexing lag) ────────
|
||||||
|
$fpCache = _loadFpCache();
|
||||||
|
$existingIssueNumber = null;
|
||||||
|
if (isset($fpCache[$fp])) {
|
||||||
|
$existingIssueNumber = $fpCache[$fp]['issue'];
|
||||||
|
// Comment throttle: skip if we already commented within the last 30 min
|
||||||
|
$lastComment = $fpCache[$fp]['last_comment'] ?? 0;
|
||||||
|
if (time() - $lastComment < 1800) {
|
||||||
|
EverLog::debug('_createOrCommentGithubIssue: throttled', ['fp' => $fp]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── 2. Fall back to GitHub Search (handles first run / cache cleared) ─
|
||||||
$searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body");
|
$searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body");
|
||||||
$searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1");
|
$searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1");
|
||||||
|
if (!empty($searchResult['body']['items'][0]['number'])) {
|
||||||
$existingIssueNumber = null;
|
$existingIssueNumber = (int)$searchResult['body']['items'][0]['number'];
|
||||||
if (isset($searchResult['body']['items']) && count($searchResult['body']['items']) > 0) {
|
// Populate local cache with what we found
|
||||||
$existingIssueNumber = $searchResult['body']['items'][0]['number'] ?? null;
|
$fpCache[$fp] = ['issue' => $existingIssueNumber, 'ts' => time(), 'last_comment' => 0];
|
||||||
|
_saveFpCache($fpCache);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build the common details block ─────────────────────────────────────
|
// ── Build the common details block ─────────────────────────────────────
|
||||||
@@ -9965,7 +10149,7 @@ function _createOrCommentGithubIssue(
|
|||||||
$verMd = $version ? "\n**Version:** `$version`" : '';
|
$verMd = $version ? "\n**Version:** `$version`" : '';
|
||||||
|
|
||||||
if ($existingIssueNumber) {
|
if ($existingIssueNumber) {
|
||||||
// ── 2a. Post a comment to the existing issue ──────────────────────
|
// ── 3a. Post a comment to the existing issue ──────────────────────
|
||||||
$body = "### 🔁 Recurrence — $ts\n"
|
$body = "### 🔁 Recurrence — $ts\n"
|
||||||
. "**Source:** `$source` | **Type:** `$type`\n"
|
. "**Source:** `$source` | **Type:** `$type`\n"
|
||||||
. $urlMd . $uaMd . $verMd . "\n"
|
. $urlMd . $uaMd . $verMd . "\n"
|
||||||
@@ -9975,8 +10159,11 @@ function _createOrCommentGithubIssue(
|
|||||||
"https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments",
|
"https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments",
|
||||||
['body' => $body]
|
['body' => $body]
|
||||||
);
|
);
|
||||||
|
// Update throttle timestamp
|
||||||
|
$fpCache[$fp]['last_comment'] = time();
|
||||||
|
_saveFpCache($fpCache);
|
||||||
} else {
|
} else {
|
||||||
// ── 2b. Create a new issue ────────────────────────────────────────
|
// ── 3b. Create a new issue ────────────────────────────────────────
|
||||||
// Determine labels from source
|
// Determine labels from source
|
||||||
$labelMap = [
|
$labelMap = [
|
||||||
'pwa' => 'js-error',
|
'pwa' => 'js-error',
|
||||||
@@ -10004,7 +10191,7 @@ function _createOrCommentGithubIssue(
|
|||||||
. "<!-- auto-report fp:$fp -->\n"
|
. "<!-- auto-report fp:$fp -->\n"
|
||||||
. "_This issue was created automatically by EverShelf's error reporter. fp:`{$fp}`_";
|
. "_This issue was created automatically by EverShelf's error reporter. fp:`{$fp}`_";
|
||||||
|
|
||||||
_githubRequest($token, 'POST',
|
$newIssueRes = _githubRequest($token, 'POST',
|
||||||
"https://api.github.com/repos/$repo/issues",
|
"https://api.github.com/repos/$repo/issues",
|
||||||
[
|
[
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
@@ -10012,6 +10199,12 @@ function _createOrCommentGithubIssue(
|
|||||||
'labels' => ['auto-report', $typeLabel],
|
'labels' => ['auto-report', $typeLabel],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
// Save to local cache immediately to prevent duplicates on rapid recurrences
|
||||||
|
$newNum = $newIssueRes['body']['number'] ?? null;
|
||||||
|
if ($newNum) {
|
||||||
|
$fpCache[$fp] = ['issue' => (int)$newNum, 'ts' => time(), 'last_comment' => time()];
|
||||||
|
_saveFpCache($fpCache);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10307,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 =======================================
|
// ===== GEMINI AI: ANOMALY EXPLANATION =======================================
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
+371
-17
@@ -1943,6 +1943,8 @@ let quaggaRunning = false;
|
|||||||
let aiStream = null;
|
let aiStream = null;
|
||||||
let _scanZoomLevel = 2; // always 2x
|
let _scanZoomLevel = 2; // always 2x
|
||||||
let _torchActive = false;
|
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)
|
// Apply fixed 2x zoom (hardware if available, CSS fallback)
|
||||||
async function _applyFixedZoom() {
|
async function _applyFixedZoom() {
|
||||||
@@ -2083,13 +2085,15 @@ function _showScanConfirm(name) {
|
|||||||
|
|
||||||
// ===== AI NUMBER OCR (Gemini reads printed barcode digits) =====
|
// ===== AI NUMBER OCR (Gemini reads printed barcode digits) =====
|
||||||
let _numOcrRunning = false;
|
let _numOcrRunning = false;
|
||||||
async function _tryGeminiNumberOCR() {
|
async function _tryGeminiNumberOCR(options = {}) {
|
||||||
|
const { chainToVisual = false } = options;
|
||||||
if (_numOcrRunning || !_requireGemini()) return;
|
if (_numOcrRunning || !_requireGemini()) return;
|
||||||
const video = document.getElementById('scanner-video');
|
const video = document.getElementById('scanner-video');
|
||||||
if (!video || !video.videoWidth) { showToast(t('error.camera'), 'error'); return; }
|
if (!video || !video.videoWidth) { showToast(t('error.camera'), 'error'); return; }
|
||||||
_numOcrRunning = true;
|
_numOcrRunning = true;
|
||||||
const btn = document.getElementById('scan-num-ocr-btn');
|
const btn = document.getElementById('scan-num-ocr-btn');
|
||||||
if (btn) { btn.disabled = true; btn.textContent = t('scan.num_ocr_searching'); }
|
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 {
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = video.videoWidth;
|
canvas.width = video.videoWidth;
|
||||||
@@ -2098,12 +2102,28 @@ async function _tryGeminiNumberOCR() {
|
|||||||
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
|
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
|
||||||
const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 });
|
const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 });
|
||||||
if (result.barcode) {
|
if (result.barcode) {
|
||||||
|
scanLog(`AI OCR: found barcode ${result.barcode}`);
|
||||||
showToast(t('scan.num_ocr_found').replace('{code}', result.barcode), 'success');
|
showToast(t('scan.num_ocr_found').replace('{code}', result.barcode), 'success');
|
||||||
onBarcodeDetected(result.barcode);
|
onBarcodeDetected(result.barcode);
|
||||||
|
} else {
|
||||||
|
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 {
|
} else {
|
||||||
showToast(t('scan.num_ocr_not_found'), 'warning');
|
showToast(t('scan.num_ocr_not_found'), 'warning');
|
||||||
|
_setScanStatus(t('scan.status_scanning'), '', '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} 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');
|
showToast(t('error.connection'), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
_numOcrRunning = false;
|
_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 =====
|
// ===== CAMERA HELPER =====
|
||||||
function getCameraConstraints(extraVideo = {}) {
|
function getCameraConstraints(extraVideo = {}) {
|
||||||
const s = getSettings();
|
const s = getSettings();
|
||||||
@@ -2296,6 +2535,7 @@ function _applySyncedSettings(serverSettings) {
|
|||||||
'shopping_enabled','shopping_mode','shopping_smart_suggestions',
|
'shopping_enabled','shopping_mode','shopping_smart_suggestions',
|
||||||
'shopping_forecast','shopping_auto_add_threshold',
|
'shopping_forecast','shopping_auto_add_threshold',
|
||||||
'dark_mode',
|
'dark_mode',
|
||||||
|
'barcode_ai_fallback',
|
||||||
// Home Assistant
|
// Home Assistant
|
||||||
'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events',
|
'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events',
|
||||||
'ha_notify_service','ha_expiry_days'];
|
'ha_notify_service','ha_expiry_days'];
|
||||||
@@ -2887,6 +3127,8 @@ async function loadSettingsUI() {
|
|||||||
const cameraSelect = document.getElementById('setting-camera-facing');
|
const cameraSelect = document.getElementById('setting-camera-facing');
|
||||||
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
|
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
|
||||||
loadCameraDevices();
|
loadCameraDevices();
|
||||||
|
const baifEl = document.getElementById('setting-barcode-ai-fallback');
|
||||||
|
if (baifEl) baifEl.checked = s.barcode_ai_fallback === true;
|
||||||
renderAppliances(s.appliances || []);
|
renderAppliances(s.appliances || []);
|
||||||
const mealPlanEnabled = s.meal_plan_enabled !== false;
|
const mealPlanEnabled = s.meal_plan_enabled !== false;
|
||||||
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
|
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
|
||||||
@@ -3465,6 +3707,8 @@ async function saveSettings() {
|
|||||||
s.dietary = document.getElementById('setting-dietary').value.trim();
|
s.dietary = document.getElementById('setting-dietary').value.trim();
|
||||||
// Camera
|
// Camera
|
||||||
s.camera_facing = document.getElementById('setting-camera-facing').value;
|
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
|
// Screensaver
|
||||||
const ssEl = document.getElementById('setting-screensaver-enabled');
|
const ssEl = document.getElementById('setting-screensaver-enabled');
|
||||||
if (ssEl) s.screensaver_enabled = ssEl.checked;
|
if (ssEl) s.screensaver_enabled = ssEl.checked;
|
||||||
@@ -3608,6 +3852,7 @@ async function saveSettings() {
|
|||||||
shopping_forecast: s.shopping_forecast !== false,
|
shopping_forecast: s.shopping_forecast !== false,
|
||||||
shopping_auto_add_threshold: s.shopping_auto_add_threshold || 0,
|
shopping_auto_add_threshold: s.shopping_auto_add_threshold || 0,
|
||||||
dark_mode: s.dark_mode || 'auto',
|
dark_mode: s.dark_mode || 'auto',
|
||||||
|
barcode_ai_fallback: !!s.barcode_ai_fallback,
|
||||||
// Home Assistant
|
// Home Assistant
|
||||||
ha_enabled: !!s.ha_enabled,
|
ha_enabled: !!s.ha_enabled,
|
||||||
ha_url: s.ha_url || '',
|
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
|
// Track current page for auto-refresh
|
||||||
let _currentPageId = 'dashboard';
|
let _currentPageId = 'dashboard';
|
||||||
let _currentPageParam = null;
|
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
|
// Refresh current page data without full navigation
|
||||||
function refreshCurrentPage() {
|
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;
|
_currentPageId = pageId;
|
||||||
_currentPageParam = param;
|
_currentPageParam = param;
|
||||||
// Hide all pages
|
// Hide all pages
|
||||||
@@ -3797,7 +4064,7 @@ function showPage(pageId, param = null) {
|
|||||||
}
|
}
|
||||||
loadInventory();
|
loadInventory();
|
||||||
break;
|
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
|
// Pre-warm the embedding model the first time user visits scan page
|
||||||
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
|
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
|
||||||
window._getCategoryPipeline(); // fire-and-forget
|
window._getCategoryPipeline(); // fire-and-forget
|
||||||
@@ -4942,10 +5209,11 @@ async function loadBannerAlerts() {
|
|||||||
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
|
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [invData, predData, anomalyData, finishedData, statsData] = await Promise.all([
|
const [invData, predData, anomalyData, dupLossData, finishedData, statsData] = await Promise.all([
|
||||||
api('inventory_list'),
|
api('inventory_list'),
|
||||||
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
|
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_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('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }),
|
||||||
api('stats').catch(() => ({ opened: [] })),
|
api('stats').catch(() => ({ opened: [] })),
|
||||||
]);
|
]);
|
||||||
@@ -5069,14 +5337,21 @@ async function loadBannerAlerts() {
|
|||||||
_bannerQueue.push({ type: 'anomaly', data: an });
|
_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 || [];
|
const finished = finishedData.finished || [];
|
||||||
finished.forEach(fin => {
|
finished.forEach(fin => {
|
||||||
if (confirmed['fin_' + fin.product_id]) return;
|
if (confirmed['fin_' + fin.product_id]) return;
|
||||||
_bannerQueue.push({ type: 'finished', data: fin });
|
_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).
|
// 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.
|
// Items are capped at 8 per load (opened packages first) to avoid banner overflow.
|
||||||
const noExpiryDismissed = _getNoExpiryDismissed();
|
const noExpiryDismissed = _getNoExpiryDismissed();
|
||||||
@@ -5157,6 +5432,8 @@ function _bannerPriority(entry) {
|
|||||||
// Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong)
|
// Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong)
|
||||||
return entry.data.direction === 'missing' ? 260 : 250;
|
return entry.data.direction === 'missing' ? 260 : 250;
|
||||||
}
|
}
|
||||||
|
case 'dup_loss_check':
|
||||||
|
return 700; // high-priority check: likely double-consume loss
|
||||||
case 'finished':
|
case 'finished':
|
||||||
return 600; // product ran out — confirm before removing from DB
|
return 600; // product ran out — confirm before removing from DB
|
||||||
case 'no_expiry':
|
case 'no_expiry':
|
||||||
@@ -5358,6 +5635,30 @@ function renderBannerItem() {
|
|||||||
}
|
}
|
||||||
actionsEl.innerHTML = btns;
|
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') {
|
} else if (entry.type === 'no_expiry') {
|
||||||
const item = entry.data;
|
const item = entry.data;
|
||||||
banner.className = 'alert-banner banner-no-expiry';
|
banner.className = 'alert-banner banner-no-expiry';
|
||||||
@@ -5495,6 +5796,32 @@ function dismissBannerAnomaly() {
|
|||||||
dismissBannerItem();
|
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() {
|
function weighBannerItem() {
|
||||||
const entry = _bannerQueue[_bannerIndex];
|
const entry = _bannerQueue[_bannerIndex];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
@@ -6489,11 +6816,14 @@ async function initScanner() {
|
|||||||
|
|
||||||
const constraints = getCameraConstraints();
|
const constraints = getCameraConstraints();
|
||||||
scanLog(`Camera mode: ${getSettings().camera_facing || 'environment'}`);
|
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)}`);
|
scanLog(`Constraints: ${JSON.stringify(constraints.video)}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
stopScanner();
|
stopScanner();
|
||||||
|
_clearAiMatchPanel();
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
const track = stream.getVideoTracks()[0];
|
const track = stream.getVideoTracks()[0];
|
||||||
@@ -6528,6 +6858,17 @@ async function initScanner() {
|
|||||||
}, 4000);
|
}, 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) {
|
} catch (err) {
|
||||||
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
|
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
|
||||||
console.error('Camera error:', err);
|
console.error('Camera error:', err);
|
||||||
@@ -6598,14 +6939,14 @@ async function startNativeScanner(videoEl) {
|
|||||||
|
|
||||||
if (frameCount === 1) {
|
if (frameCount === 1) {
|
||||||
updateFeedback('scanning');
|
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
|
// After 2s without detection, also start Quagga in parallel as backup
|
||||||
if (!quaggaParallelStarted && (Date.now() - startTime) > 2000) {
|
if (!quaggaParallelStarted && (Date.now() - startTime) > 2000) {
|
||||||
quaggaParallelStarted = true;
|
quaggaParallelStarted = true;
|
||||||
scanLog('Native: 2s elapsed, spawning Quagga in parallel');
|
scanLog('Native: 2s elapsed, spawning fallback scanner in parallel');
|
||||||
_setScanStatus(t('scan.status_parallel'), 'retry', 'Native + Quagga');
|
_setScanStatus(t('scan.status_parallel'), 'retry', '');
|
||||||
quaggaRunning = false; // temporarily release so Quagga can start
|
quaggaRunning = false; // temporarily release so Quagga can start
|
||||||
startQuaggaScanner(videoEl, false);
|
startQuaggaScanner(videoEl, false);
|
||||||
quaggaRunning = true; // re-take ownership (Quagga will share)
|
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.
|
// 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);
|
const highConfidence = ['ean_13','ean_8','upc_a','upc_e'].includes(format);
|
||||||
if (highConfidence || detectCount >= 2 || detectionHistory[code].count >= 2) {
|
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;
|
scanning = false;
|
||||||
quaggaRunning = false;
|
quaggaRunning = false;
|
||||||
updateFeedback(null);
|
updateFeedback(null);
|
||||||
@@ -6674,7 +7022,7 @@ function startQuaggaScanner(videoEl, isPrimary = true) {
|
|||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
let partialCount = 0;
|
let partialCount = 0;
|
||||||
|
|
||||||
scanLog(`Quagga starting — frontCam: ${frontCam}`);
|
scanLog(`Fallback scanner starting — frontCam: ${frontCam}`);
|
||||||
|
|
||||||
let scanning = true;
|
let scanning = true;
|
||||||
quaggaRunning = true;
|
quaggaRunning = true;
|
||||||
@@ -6850,6 +7198,10 @@ function stopScanner() {
|
|||||||
quaggaRunning = false;
|
quaggaRunning = false;
|
||||||
_scanZoomLevel = 2; // always 2x on next start
|
_scanZoomLevel = 2; // always 2x on next start
|
||||||
_torchActive = false;
|
_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) {
|
if (scannerStream) {
|
||||||
scannerStream.getTracks().forEach(t => t.stop());
|
scannerStream.getTracks().forEach(t => t.stop());
|
||||||
scannerStream = null;
|
scannerStream = null;
|
||||||
@@ -6861,6 +7213,7 @@ function stopScanner() {
|
|||||||
if (tb) tb.classList.remove('torch-on');
|
if (tb) tb.classList.remove('torch-on');
|
||||||
// Hide live code
|
// Hide live code
|
||||||
_hideScanLiveCode();
|
_hideScanLiveCode();
|
||||||
|
_clearAiMatchPanel();
|
||||||
// Also stop AI camera
|
// Also stop AI camera
|
||||||
if (aiStream) {
|
if (aiStream) {
|
||||||
aiStream.getTracks().forEach(t => t.stop());
|
aiStream.getTracks().forEach(t => t.stop());
|
||||||
@@ -6871,6 +7224,7 @@ function stopScanner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onBarcodeDetected(barcode) {
|
async function onBarcodeDetected(barcode) {
|
||||||
|
clearTimeout(_aiFallbackTimer); _aiFallbackTimer = null;
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
|
|
||||||
// Vibrate if available
|
// Vibrate if available
|
||||||
@@ -7026,7 +7380,7 @@ function autoSubmitEAN(inputEl, force = false) {
|
|||||||
if (!raw) { showToast(t('error.barcode_empty'), 'error'); inputEl.focus(); return; }
|
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 (!/^\d{4,14}$/.test(raw)) { showToast(t('error.barcode_format'), 'error'); inputEl.focus(); return; }
|
||||||
if (isComplete && !isValid) {
|
if (isComplete && !isValid) {
|
||||||
showToast('⚠️ Checksum EAN errato — verifica le cifre', 'warning');
|
showToast(t('error.barcode_checksum'), 'error'); inputEl.focus(); return;
|
||||||
}
|
}
|
||||||
stopScanner();
|
stopScanner();
|
||||||
onBarcodeDetected(raw);
|
onBarcodeDetected(raw);
|
||||||
@@ -7781,7 +8135,7 @@ function showProductAction() {
|
|||||||
|
|
||||||
// Update back button: go back to shopping if came from shopping list scan
|
// Update back button: go back to shopping if came from shopping list scan
|
||||||
const backBtn = document.getElementById('action-back-btn');
|
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
|
// Show "shopping target" banner if we came from the shopping list
|
||||||
const banner = document.getElementById('shopping-scan-target-banner');
|
const banner = document.getElementById('shopping-scan-target-banner');
|
||||||
@@ -7795,7 +8149,7 @@ function showProductAction() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="shopping-scan-target-actions">
|
<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-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>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else if (banner) {
|
} else if (banner) {
|
||||||
@@ -13559,7 +13913,7 @@ async function toggleRecipeFavorite(btn) {
|
|||||||
* Scale recipe ingredient quantities (#123).
|
* Scale recipe ingredient quantities (#123).
|
||||||
* Delta: +1 or -1. Min 1, max 20 persons.
|
* 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));
|
const newPersons = Math.max(1, Math.min(20, _recipeCurrentPersons + delta));
|
||||||
if (newPersons === _recipeCurrentPersons) return;
|
if (newPersons === _recipeCurrentPersons) return;
|
||||||
_recipeCurrentPersons = newPersons;
|
_recipeCurrentPersons = newPersons;
|
||||||
@@ -13604,9 +13958,9 @@ function renderRecipe(r) {
|
|||||||
html += '<div class="recipe-meta">';
|
html += '<div class="recipe-meta">';
|
||||||
if (r.meal) html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
|
if (r.meal) html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
|
||||||
html += `<span class="recipe-tag recipe-persons-ctrl">
|
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>
|
<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>`;
|
</span>`;
|
||||||
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</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>`;
|
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-warnings" class="preloader-warnings" style="display:none"></div>
|
||||||
<div id="preloader-error-msg" class="preloader-error-msg" 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<!-- Title — left-aligned; grows to fill space -->
|
<!-- Title — left-aligned; grows to fill space -->
|
||||||
<div class="header-title-wrap">
|
<div class="header-title-wrap">
|
||||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
<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>
|
</h1>
|
||||||
<!-- Update badge — shown alongside title, never replaces it -->
|
<!-- Update badge — shown alongside title, never replaces it -->
|
||||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
<!-- ===== INVENTORY LIST ===== -->
|
<!-- ===== INVENTORY LIST ===== -->
|
||||||
<section class="page" id="page-inventory">
|
<section class="page" id="page-inventory">
|
||||||
<div class="page-header">
|
<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>
|
<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>
|
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
<!-- ===== SCAN PAGE ===== -->
|
<!-- ===== SCAN PAGE ===== -->
|
||||||
<section class="page" id="page-scan">
|
<section class="page" id="page-scan">
|
||||||
<div class="page-header">
|
<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>
|
<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>
|
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,6 +256,14 @@
|
|||||||
<span id="scan-status-method" class="scan-status-method"></span>
|
<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>
|
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
|
||||||
</div>
|
</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 -->
|
<!-- Success flash overlay -->
|
||||||
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
||||||
<div class="scan-confirm-check">✓</div>
|
<div class="scan-confirm-check">✓</div>
|
||||||
@@ -274,6 +282,9 @@
|
|||||||
<!-- Scan errors -->
|
<!-- Scan errors -->
|
||||||
<div class="scan-result" id="scan-result" style="display:none"></div>
|
<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 -->
|
<!-- Recent scans -->
|
||||||
<div class="scan-recents" id="scan-recents" style="display:none">
|
<div class="scan-recents" id="scan-recents" style="display:none">
|
||||||
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
||||||
@@ -333,7 +344,7 @@
|
|||||||
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
||||||
<section class="page" id="page-action">
|
<section class="page" id="page-action">
|
||||||
<div class="page-header">
|
<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>
|
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
|
||||||
</div>
|
</div>
|
||||||
<!-- Banner: shopping list scan context -->
|
<!-- Banner: shopping list scan context -->
|
||||||
@@ -356,7 +367,7 @@
|
|||||||
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
||||||
<section class="page" id="page-add">
|
<section class="page" id="page-add">
|
||||||
<div class="page-header">
|
<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>
|
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="product-preview-small" id="add-product-preview"></div>
|
<div class="product-preview-small" id="add-product-preview"></div>
|
||||||
@@ -419,7 +430,7 @@
|
|||||||
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
||||||
<section class="page" id="page-use">
|
<section class="page" id="page-use">
|
||||||
<div class="page-header">
|
<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>
|
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="product-preview-small" id="use-product-preview"></div>
|
<div class="product-preview-small" id="use-product-preview"></div>
|
||||||
@@ -475,7 +486,7 @@
|
|||||||
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
||||||
<section class="page" id="page-product-form">
|
<section class="page" id="page-product-form">
|
||||||
<div class="page-header">
|
<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>
|
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
||||||
</div>
|
</div>
|
||||||
<form class="form" onsubmit="submitProduct(event)">
|
<form class="form" onsubmit="submitProduct(event)">
|
||||||
@@ -663,7 +674,7 @@
|
|||||||
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
||||||
<section class="page" id="page-products">
|
<section class="page" id="page-products">
|
||||||
<div class="page-header">
|
<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>
|
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
@@ -675,7 +686,7 @@
|
|||||||
<!-- ===== RECIPE PAGE ===== -->
|
<!-- ===== RECIPE PAGE ===== -->
|
||||||
<section class="page" id="page-recipe">
|
<section class="page" id="page-recipe">
|
||||||
<div class="page-header">
|
<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>
|
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-page-container">
|
<div class="recipe-page-container">
|
||||||
@@ -689,7 +700,7 @@
|
|||||||
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
||||||
<section class="page" id="page-shopping">
|
<section class="page" id="page-shopping">
|
||||||
<div class="page-header">
|
<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>
|
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="shopping-container">
|
<div class="shopping-container">
|
||||||
@@ -797,7 +808,7 @@
|
|||||||
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||||
<section class="page" id="page-ai">
|
<section class="page" id="page-ai">
|
||||||
<div class="page-header">
|
<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>
|
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-container">
|
<div class="ai-container">
|
||||||
@@ -835,7 +846,7 @@
|
|||||||
<!-- ===== SETTINGS PAGE ===== -->
|
<!-- ===== SETTINGS PAGE ===== -->
|
||||||
<section class="page" id="page-settings">
|
<section class="page" id="page-settings">
|
||||||
<div class="page-header">
|
<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>
|
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-tabs">
|
<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>
|
<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>
|
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Security Tab -->
|
<!-- Security Tab -->
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "EverShelf",
|
"name": "EverShelf",
|
||||||
"short_name": "EverShelf",
|
"short_name": "EverShelf",
|
||||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||||
"version": "1.7.25",
|
"version": "1.7.35",
|
||||||
"start_url": "/evershelf/",
|
"start_url": "/evershelf/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f0f4e8",
|
"background_color": "#f0f4e8",
|
||||||
|
|||||||
+28
-2
@@ -151,6 +151,12 @@
|
|||||||
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach.",
|
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach.",
|
||||||
"banner_anomaly_ghost_title": "weniger Bestand als erwartet",
|
"banner_anomaly_ghost_title": "weniger Bestand als erwartet",
|
||||||
"banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?",
|
"banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?",
|
||||||
|
"banner_dup_loss_title": "Prüfung Doppelabbuchung: {name}",
|
||||||
|
"banner_dup_loss_detail": "Mögliche doppelte Buchung in {location}: zwei schnelle Abgänge ({qty_pair}) in ~{seconds}s. Bitte prüfen und ggf. korrigieren.",
|
||||||
|
"banner_dup_loss_action_fix": "Menge korrigieren",
|
||||||
|
"banner_dup_loss_action_open": "Produktkarte öffnen",
|
||||||
|
"banner_dup_loss_action_done": "Bereits geprüft",
|
||||||
|
"banner_dup_loss_toast_done": "Prüfung als erledigt markiert",
|
||||||
"consumed": "Verbraucht: {n} ({pct}%)",
|
"consumed": "Verbraucht: {n} ({pct}%)",
|
||||||
"wasted": "Weggeworfen: {n} ({pct}%)",
|
"wasted": "Weggeworfen: {n} ({pct}%)",
|
||||||
"more_opened": "und {n} weitere geöffnet...",
|
"more_opened": "und {n} weitere geöffnet...",
|
||||||
@@ -220,7 +226,24 @@
|
|||||||
"status_partial": "Erkannt: {code} — prüfe...",
|
"status_partial": "Erkannt: {code} — prüfe...",
|
||||||
"status_invalid": "Ungültig: {code} — versuche erneut",
|
"status_invalid": "Ungültig: {code} — versuche erneut",
|
||||||
"status_confirmed": "Bestätigt!",
|
"status_confirmed": "Bestätigt!",
|
||||||
"status_parallel": "Kombinierter Scan aktiv..."
|
"status_parallel": "Kombinierter Scan aktiv...",
|
||||||
|
"status_ocr_searching": "Ich lese die Barcode-Ziffern...",
|
||||||
|
"status_ai_visual_searching": "Jetzt versuche ich, das Produkt zu erkennen...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
|
"ai_fallback_searching": "KI identifiziert Produkt...",
|
||||||
|
"ai_fallback_found": "Produkt von KI erkannt",
|
||||||
|
"ai_fallback_not_found": "KI: Produkt nicht erkannt",
|
||||||
|
"ai_fallback_exhausted": "KI: Produkt nicht erkannt — Barcode erneut scannen",
|
||||||
|
"ai_overlay_msg": "Gemini Vision analysiert das Produkt...",
|
||||||
|
"ai_retry_btn": "Mit KI erneut versuchen",
|
||||||
|
"ai_match_title": "Produkt von KI erkannt",
|
||||||
|
"ai_match_subtitle": "Waehle ein vorhandenes Produkt oder fuege das erkannte hinzu.",
|
||||||
|
"ai_match_existing": "Mogliche Treffer in der Vorratskammer",
|
||||||
|
"ai_match_none": "Keine ahnlichen Produkte in der Vorratskammer gefunden.",
|
||||||
|
"ai_match_use_btn": "Dieses nutzen",
|
||||||
|
"ai_match_add_btn": "\"{name}\" hinzufugen",
|
||||||
|
"ai_detected_label": "KI erkannt"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "Was möchtest du tun?",
|
"title": "Was möchtest du tun?",
|
||||||
@@ -664,7 +687,9 @@
|
|||||||
"back": "📱 Rückkamera (Standard)",
|
"back": "📱 Rückkamera (Standard)",
|
||||||
"front": "🤳 Frontkamera",
|
"front": "🤳 Frontkamera",
|
||||||
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
|
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
|
||||||
"detect_btn": "🔄 Kameras erkennen"
|
"detect_btn": "🔄 Kameras erkennen",
|
||||||
|
"ai_fallback_label": "KI-Bilderkennung (5s Fallback)",
|
||||||
|
"ai_fallback_hint": "Wird kein Barcode innerhalb von 5 Sekunden gelesen, wird automatisch ein Bild an die KI zur visuellen Produktidentifizierung gesendet. Erfordert konfiguriertes Gemini."
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "🔒 HTTPS-Zertifikat",
|
"title": "🔒 HTTPS-Zertifikat",
|
||||||
@@ -1063,6 +1088,7 @@
|
|||||||
"ai_quota": "KI-Kontingent erschöpft. Bitte in ein paar Minuten erneut versuchen.",
|
"ai_quota": "KI-Kontingent erschöpft. Bitte in ein paar Minuten erneut versuchen.",
|
||||||
"barcode_empty": "Barcode eingeben",
|
"barcode_empty": "Barcode eingeben",
|
||||||
"barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)",
|
"barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)",
|
||||||
|
"barcode_checksum": "Ungültiger EAN-Prüfziffer — bitte die Barcode-Ziffern prüfen",
|
||||||
"min_chars": "Mindestens 2 Zeichen eingeben",
|
"min_chars": "Mindestens 2 Zeichen eingeben",
|
||||||
"not_in_inventory": "Produkt nicht im Bestand",
|
"not_in_inventory": "Produkt nicht im Bestand",
|
||||||
"appliance_exists": "Gerät bereits vorhanden",
|
"appliance_exists": "Gerät bereits vorhanden",
|
||||||
|
|||||||
+28
-2
@@ -151,6 +151,12 @@
|
|||||||
"banner_anomaly_untracked_detail": "You have <strong>{inv_qty} {unit}</strong> in inventory, but recorded outflows exceed inflows — the initial stock was likely never added as an \"in\" transaction. You can correct the quantity or log the missing entries.",
|
"banner_anomaly_untracked_detail": "You have <strong>{inv_qty} {unit}</strong> in inventory, but recorded outflows exceed inflows — the initial stock was likely never added as an \"in\" transaction. You can correct the quantity or log the missing entries.",
|
||||||
"banner_anomaly_ghost_title": "you have less stock than expected",
|
"banner_anomaly_ghost_title": "you have less stock than expected",
|
||||||
"banner_anomaly_ghost_detail": "Based on recorded operations you should have {expected_qty} {unit} of {name}, but inventory shows only {inv_qty} {unit}. Did you take stock without recording it?",
|
"banner_anomaly_ghost_detail": "Based on recorded operations you should have {expected_qty} {unit} of {name}, but inventory shows only {inv_qty} {unit}. Did you take stock without recording it?",
|
||||||
|
"banner_dup_loss_title": "Double-consume check: {name}",
|
||||||
|
"banner_dup_loss_detail": "Possible duplicate entry in {location}: two close out events ({qty_pair}) in ~{seconds}s. Please verify and fix if needed.",
|
||||||
|
"banner_dup_loss_action_fix": "Fix quantity",
|
||||||
|
"banner_dup_loss_action_open": "Open product card",
|
||||||
|
"banner_dup_loss_action_done": "Already checked",
|
||||||
|
"banner_dup_loss_toast_done": "Check marked as reviewed",
|
||||||
"consumed": "Consumed: {n} ({pct}%)",
|
"consumed": "Consumed: {n} ({pct}%)",
|
||||||
"wasted": "Wasted: {n} ({pct}%)",
|
"wasted": "Wasted: {n} ({pct}%)",
|
||||||
"more_opened": "and {n} more opened...",
|
"more_opened": "and {n} more opened...",
|
||||||
@@ -220,7 +226,24 @@
|
|||||||
"status_partial": "Detected: {code} — verifying...",
|
"status_partial": "Detected: {code} — verifying...",
|
||||||
"status_invalid": "Invalid: {code} — retrying",
|
"status_invalid": "Invalid: {code} — retrying",
|
||||||
"status_confirmed": "Confirmed!",
|
"status_confirmed": "Confirmed!",
|
||||||
"status_parallel": "Using combined scan methods..."
|
"status_parallel": "Using combined scan methods...",
|
||||||
|
"status_ocr_searching": "Reading the barcode digits...",
|
||||||
|
"status_ai_visual_searching": "Now trying to recognize the product...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
|
"ai_fallback_searching": "AI identifying product...",
|
||||||
|
"ai_fallback_found": "Product identified by AI",
|
||||||
|
"ai_fallback_not_found": "AI: product not recognized",
|
||||||
|
"ai_fallback_exhausted": "AI: product not recognized — try scanning the barcode",
|
||||||
|
"ai_overlay_msg": "Gemini Vision is analyzing the product...",
|
||||||
|
"ai_retry_btn": "Retry with AI",
|
||||||
|
"ai_match_title": "Product recognized by AI",
|
||||||
|
"ai_match_subtitle": "Choose an existing pantry item or add the detected one.",
|
||||||
|
"ai_match_existing": "Possible pantry matches",
|
||||||
|
"ai_match_none": "No similar pantry products found.",
|
||||||
|
"ai_match_use_btn": "Use this",
|
||||||
|
"ai_match_add_btn": "Add \"{name}\"",
|
||||||
|
"ai_detected_label": "AI detected"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "What do you want to do?",
|
"title": "What do you want to do?",
|
||||||
@@ -664,7 +687,9 @@
|
|||||||
"back": "📱 Rear (default)",
|
"back": "📱 Rear (default)",
|
||||||
"front": "🤳 Front",
|
"front": "🤳 Front",
|
||||||
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
|
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
|
||||||
"detect_btn": "🔄 Detect cameras"
|
"detect_btn": "🔄 Detect cameras",
|
||||||
|
"ai_fallback_label": "AI visual identification (5s fallback)",
|
||||||
|
"ai_fallback_hint": "If no barcode is read within 5 seconds, a frame is automatically sent to AI to visually identify the product. Requires Gemini configured."
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "🔒 HTTPS Certificate",
|
"title": "🔒 HTTPS Certificate",
|
||||||
@@ -1063,6 +1088,7 @@
|
|||||||
"ai_quota": "AI quota exhausted. Please try again in a few minutes.",
|
"ai_quota": "AI quota exhausted. Please try again in a few minutes.",
|
||||||
"barcode_empty": "Enter a barcode",
|
"barcode_empty": "Enter a barcode",
|
||||||
"barcode_format": "Barcode must contain only numbers (4-14 digits)",
|
"barcode_format": "Barcode must contain only numbers (4-14 digits)",
|
||||||
|
"barcode_checksum": "Invalid EAN checksum — please check the barcode digits",
|
||||||
"min_chars": "Type at least 2 characters",
|
"min_chars": "Type at least 2 characters",
|
||||||
"not_in_inventory": "Product not in inventory",
|
"not_in_inventory": "Product not in inventory",
|
||||||
"appliance_exists": "Appliance already exists",
|
"appliance_exists": "Appliance already exists",
|
||||||
|
|||||||
+28
-2
@@ -149,6 +149,12 @@
|
|||||||
"banner_anomaly_untracked_detail": "Tienes <strong>{inv_qty} {unit}</strong> en inventario, pero las salidas registradas superan las entradas — el stock inicial probablemente nunca se añadió como transacción «entrada». Puedes corregir la cantidad o registrar las entradas faltantes.",
|
"banner_anomaly_untracked_detail": "Tienes <strong>{inv_qty} {unit}</strong> en inventario, pero las salidas registradas superan las entradas — el stock inicial probablemente nunca se añadió como transacción «entrada». Puedes corregir la cantidad o registrar las entradas faltantes.",
|
||||||
"banner_anomaly_ghost_title": "tienes menos stock del esperado",
|
"banner_anomaly_ghost_title": "tienes menos stock del esperado",
|
||||||
"banner_anomaly_ghost_detail": "Según las operaciones registradas deberías tener {expected_qty} {unit} de {name}, pero el inventario solo muestra {inv_qty} {unit}. ¿Tomaste stock sin registrarlo?",
|
"banner_anomaly_ghost_detail": "Según las operaciones registradas deberías tener {expected_qty} {unit} de {name}, pero el inventario solo muestra {inv_qty} {unit}. ¿Tomaste stock sin registrarlo?",
|
||||||
|
"banner_dup_loss_title": "Control de doble salida: {name}",
|
||||||
|
"banner_dup_loss_detail": "Posible registro duplicado en {location}: dos salidas seguidas ({qty_pair}) en ~{seconds}s. Revisa y corrige si hace falta.",
|
||||||
|
"banner_dup_loss_action_fix": "Corregir cantidad",
|
||||||
|
"banner_dup_loss_action_open": "Abrir ficha del producto",
|
||||||
|
"banner_dup_loss_action_done": "Ya revisado",
|
||||||
|
"banner_dup_loss_toast_done": "Control marcado como revisado",
|
||||||
"consumed": "Consumido: {n} ({pct}%)",
|
"consumed": "Consumido: {n} ({pct}%)",
|
||||||
"wasted": "Desperdiciado: {n} ({pct}%)",
|
"wasted": "Desperdiciado: {n} ({pct}%)",
|
||||||
"more_opened": "y {n} más abiertos...",
|
"more_opened": "y {n} más abiertos...",
|
||||||
@@ -217,7 +223,24 @@
|
|||||||
"status_partial": "Detectado: {code} — verificando...",
|
"status_partial": "Detectado: {code} — verificando...",
|
||||||
"status_invalid": "Inválido: {code} — reintentando",
|
"status_invalid": "Inválido: {code} — reintentando",
|
||||||
"status_confirmed": "Confirmado!",
|
"status_confirmed": "Confirmado!",
|
||||||
"status_parallel": "Escaneo combinado activo..."
|
"status_parallel": "Escaneo combinado activo...",
|
||||||
|
"status_ocr_searching": "Estoy leyendo los números del código de barras...",
|
||||||
|
"status_ai_visual_searching": "Ahora intento reconocer el producto...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
|
"ai_fallback_searching": "Identificación de IA en curso...",
|
||||||
|
"ai_fallback_found": "Producto identificado por IA",
|
||||||
|
"ai_fallback_not_found": "IA: producto no reconocido",
|
||||||
|
"ai_fallback_exhausted": "IA: producto no reconocido — prueba a escanear el código",
|
||||||
|
"ai_overlay_msg": "Gemini Vision está analizando el producto...",
|
||||||
|
"ai_retry_btn": "Reintentar con IA",
|
||||||
|
"ai_match_title": "Producto reconocido por IA",
|
||||||
|
"ai_match_subtitle": "Elige un producto ya en despensa o agrega el detectado.",
|
||||||
|
"ai_match_existing": "Posibles coincidencias en despensa",
|
||||||
|
"ai_match_none": "No se encontraron productos similares en despensa.",
|
||||||
|
"ai_match_use_btn": "Usar este",
|
||||||
|
"ai_match_add_btn": "Agregar \"{name}\"",
|
||||||
|
"ai_detected_label": "IA detecto"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "¿Qué quieres hacer?",
|
"title": "¿Qué quieres hacer?",
|
||||||
@@ -658,7 +681,9 @@
|
|||||||
"back": "📱 Trasera (por defecto)",
|
"back": "📱 Trasera (por defecto)",
|
||||||
"front": "🤳 Frontal",
|
"front": "🤳 Frontal",
|
||||||
"devices_hint": "Si tienes varias cámaras, puedes seleccionar una específica de la lista de arriba tras conceder los permisos.",
|
"devices_hint": "Si tienes varias cámaras, puedes seleccionar una específica de la lista de arriba tras conceder los permisos.",
|
||||||
"detect_btn": "🔄 Detectar cámaras"
|
"detect_btn": "🔄 Detectar cámaras",
|
||||||
|
"ai_fallback_label": "Identificación visual IA (repuesto 5s)",
|
||||||
|
"ai_fallback_hint": "Si no se lee ningún código de barras en 5 segundos, se envía automáticamente un fotograma a la IA para identificar el producto visualmente. Requiere Gemini configurado."
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "🔒 Certificado HTTPS",
|
"title": "🔒 Certificado HTTPS",
|
||||||
@@ -1014,6 +1039,7 @@
|
|||||||
"ai_quota": "Cuota de IA agotada. Inténtalo de nuevo en unos minutos.",
|
"ai_quota": "Cuota de IA agotada. Inténtalo de nuevo en unos minutos.",
|
||||||
"barcode_empty": "Introduce un código de barras",
|
"barcode_empty": "Introduce un código de barras",
|
||||||
"barcode_format": "El código de barras solo puede contener números (4-14 dígitos)",
|
"barcode_format": "El código de barras solo puede contener números (4-14 dígitos)",
|
||||||
|
"barcode_checksum": "Suma de comprobación EAN inválida — verifica los dígitos del código",
|
||||||
"min_chars": "Escribe al menos 2 caracteres",
|
"min_chars": "Escribe al menos 2 caracteres",
|
||||||
"not_in_inventory": "Producto no en inventario",
|
"not_in_inventory": "Producto no en inventario",
|
||||||
"appliance_exists": "El electrodoméstico ya existe",
|
"appliance_exists": "El electrodoméstico ya existe",
|
||||||
|
|||||||
+28
-2
@@ -149,6 +149,12 @@
|
|||||||
"banner_anomaly_untracked_detail": "Vous avez <strong>{inv_qty} {unit}</strong> en inventaire, mais les sorties enregistrées dépassent les entrées — le stock initial n'a probablement jamais été ajouté comme transaction « entrée ». Vous pouvez corriger la quantité ou saisir les entrées manquantes.",
|
"banner_anomaly_untracked_detail": "Vous avez <strong>{inv_qty} {unit}</strong> en inventaire, mais les sorties enregistrées dépassent les entrées — le stock initial n'a probablement jamais été ajouté comme transaction « entrée ». Vous pouvez corriger la quantité ou saisir les entrées manquantes.",
|
||||||
"banner_anomaly_ghost_title": "vous avez moins de stock que prévu",
|
"banner_anomaly_ghost_title": "vous avez moins de stock que prévu",
|
||||||
"banner_anomaly_ghost_detail": "D'après les opérations enregistrées vous devriez avoir {expected_qty} {unit} de {name}, mais l'inventaire n'en montre que {inv_qty} {unit}. Avez-vous pris du stock sans l'enregistrer ?",
|
"banner_anomaly_ghost_detail": "D'après les opérations enregistrées vous devriez avoir {expected_qty} {unit} de {name}, mais l'inventaire n'en montre que {inv_qty} {unit}. Avez-vous pris du stock sans l'enregistrer ?",
|
||||||
|
"banner_dup_loss_title": "Vérification double sortie : {name}",
|
||||||
|
"banner_dup_loss_detail": "Doublon possible dans {location} : deux sorties rapprochées ({qty_pair}) en ~{seconds}s. Vérifiez et corrigez si besoin.",
|
||||||
|
"banner_dup_loss_action_fix": "Corriger la quantité",
|
||||||
|
"banner_dup_loss_action_open": "Ouvrir la fiche produit",
|
||||||
|
"banner_dup_loss_action_done": "Déjà vérifié",
|
||||||
|
"banner_dup_loss_toast_done": "Contrôle marqué comme vérifié",
|
||||||
"consumed": "Consommé : {n} ({pct}%)",
|
"consumed": "Consommé : {n} ({pct}%)",
|
||||||
"wasted": "Gaspillé : {n} ({pct}%)",
|
"wasted": "Gaspillé : {n} ({pct}%)",
|
||||||
"more_opened": "et {n} autres ouverts...",
|
"more_opened": "et {n} autres ouverts...",
|
||||||
@@ -217,7 +223,24 @@
|
|||||||
"status_partial": "Lu : {code} — vérification...",
|
"status_partial": "Lu : {code} — vérification...",
|
||||||
"status_invalid": "Invalide : {code} — nouvel essai",
|
"status_invalid": "Invalide : {code} — nouvel essai",
|
||||||
"status_confirmed": "Confirmé !",
|
"status_confirmed": "Confirmé !",
|
||||||
"status_parallel": "Scan combiné actif..."
|
"status_parallel": "Scan combiné actif...",
|
||||||
|
"status_ocr_searching": "Je lis les chiffres du code-barres...",
|
||||||
|
"status_ai_visual_searching": "J'essaie maintenant de reconnaître le produit...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
|
"ai_fallback_searching": "Identification IA en cours...",
|
||||||
|
"ai_fallback_found": "Produit identifié par l'IA",
|
||||||
|
"ai_fallback_not_found": "IA : produit non reconnu",
|
||||||
|
"ai_fallback_exhausted": "IA : produit non reconnu — réessayez avec le code-barres",
|
||||||
|
"ai_overlay_msg": "Gemini Vision analyse le produit...",
|
||||||
|
"ai_retry_btn": "Reessayer avec l'IA",
|
||||||
|
"ai_match_title": "Produit reconnu par l'IA",
|
||||||
|
"ai_match_subtitle": "Choisissez un produit deja en stock ou ajoutez celui detecte.",
|
||||||
|
"ai_match_existing": "Correspondances possibles dans le stock",
|
||||||
|
"ai_match_none": "Aucun produit similaire trouve dans le stock.",
|
||||||
|
"ai_match_use_btn": "Utiliser celui-ci",
|
||||||
|
"ai_match_add_btn": "Ajouter \"{name}\"",
|
||||||
|
"ai_detected_label": "IA a detecte"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "Que voulez-vous faire ?",
|
"title": "Que voulez-vous faire ?",
|
||||||
@@ -658,7 +681,9 @@
|
|||||||
"back": "📱 Arrière (par défaut)",
|
"back": "📱 Arrière (par défaut)",
|
||||||
"front": "🤳 Frontale",
|
"front": "🤳 Frontale",
|
||||||
"devices_hint": "Si vous avez plusieurs caméras, vous pouvez en sélectionner une dans la liste ci-dessus après avoir accordé les permissions.",
|
"devices_hint": "Si vous avez plusieurs caméras, vous pouvez en sélectionner une dans la liste ci-dessus après avoir accordé les permissions.",
|
||||||
"detect_btn": "🔄 Détecter les caméras"
|
"detect_btn": "🔄 Détecter les caméras",
|
||||||
|
"ai_fallback_label": "Identification visuelle IA (repli 5s)",
|
||||||
|
"ai_fallback_hint": "Si aucun code-barres n'est lu en 5 secondes, une image est automatiquement envoyée à l'IA pour identifier visuellement le produit. Nécessite Gemini configuré."
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "🔒 Certificat HTTPS",
|
"title": "🔒 Certificat HTTPS",
|
||||||
@@ -1014,6 +1039,7 @@
|
|||||||
"ai_quota": "Quota IA épuisé. Réessayez dans quelques minutes.",
|
"ai_quota": "Quota IA épuisé. Réessayez dans quelques minutes.",
|
||||||
"barcode_empty": "Entrez un code-barres",
|
"barcode_empty": "Entrez un code-barres",
|
||||||
"barcode_format": "Le code-barres ne doit contenir que des chiffres (4-14 chiffres)",
|
"barcode_format": "Le code-barres ne doit contenir que des chiffres (4-14 chiffres)",
|
||||||
|
"barcode_checksum": "Somme de contrôle EAN invalide — vérifiez les chiffres du code-barres",
|
||||||
"min_chars": "Tapez au moins 2 caractères",
|
"min_chars": "Tapez au moins 2 caractères",
|
||||||
"not_in_inventory": "Produit absent de l'inventaire",
|
"not_in_inventory": "Produit absent de l'inventaire",
|
||||||
"appliance_exists": "L'appareil existe déjà",
|
"appliance_exists": "L'appareil existe déjà",
|
||||||
|
|||||||
+28
-2
@@ -151,6 +151,12 @@
|
|||||||
"banner_anomaly_untracked_detail": "Hai <strong>{inv_qty} {unit}</strong> in inventario, ma le uscite registrate superano le entrate — le scorte iniziali probabilmente non sono mai state aggiunte come entrata. Puoi correggere la quantità o registrare le entrate mancanti.",
|
"banner_anomaly_untracked_detail": "Hai <strong>{inv_qty} {unit}</strong> in inventario, ma le uscite registrate superano le entrate — le scorte iniziali probabilmente non sono mai state aggiunte come entrata. Puoi correggere la quantità o registrare le entrate mancanti.",
|
||||||
"banner_anomaly_ghost_title": "hai meno scorte del previsto",
|
"banner_anomaly_ghost_title": "hai meno scorte del previsto",
|
||||||
"banner_anomaly_ghost_detail": "In base alle operazioni registrate dovresti avere {expected_qty} {unit} di {name}, ma l'inventario mostra solo {inv_qty} {unit}. Hai prelevato senza registrarlo?",
|
"banner_anomaly_ghost_detail": "In base alle operazioni registrate dovresti avere {expected_qty} {unit} di {name}, ma l'inventario mostra solo {inv_qty} {unit}. Hai prelevato senza registrarlo?",
|
||||||
|
"banner_dup_loss_title": "Controllo doppio scarico: {name}",
|
||||||
|
"banner_dup_loss_detail": "Possibile doppia registrazione in {location}: due uscite ravvicinate ({qty_pair}) in ~{seconds}s. Verifica se va corretta.",
|
||||||
|
"banner_dup_loss_action_fix": "Correggi quantità",
|
||||||
|
"banner_dup_loss_action_open": "Apri scheda prodotto",
|
||||||
|
"banner_dup_loss_action_done": "Già verificato",
|
||||||
|
"banner_dup_loss_toast_done": "Controllo segnato come verificato",
|
||||||
"consumed": "Consumati: {n} ({pct}%)",
|
"consumed": "Consumati: {n} ({pct}%)",
|
||||||
"wasted": "Buttati: {n} ({pct}%)",
|
"wasted": "Buttati: {n} ({pct}%)",
|
||||||
"more_opened": "e altri {n} prodotti aperti...",
|
"more_opened": "e altri {n} prodotti aperti...",
|
||||||
@@ -220,7 +226,24 @@
|
|||||||
"status_partial": "Letto: {code} — verifico...",
|
"status_partial": "Letto: {code} — verifico...",
|
||||||
"status_invalid": "Non valido: {code} — riprovo",
|
"status_invalid": "Non valido: {code} — riprovo",
|
||||||
"status_confirmed": "Confermato!",
|
"status_confirmed": "Confermato!",
|
||||||
"status_parallel": "Doppia scansione attiva..."
|
"status_parallel": "Doppia scansione attiva...",
|
||||||
|
"status_ocr_searching": "Sto leggendo i numeri del codice a barre...",
|
||||||
|
"status_ai_visual_searching": "Ora provo a riconoscere il prodotto...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
|
"ai_fallback_searching": "Identificazione AI in corso...",
|
||||||
|
"ai_fallback_found": "Prodotto identificato dall'AI",
|
||||||
|
"ai_fallback_not_found": "AI: prodotto non riconosciuto",
|
||||||
|
"ai_fallback_exhausted": "AI: prodotto non riconosciuto — riprova con il barcode",
|
||||||
|
"ai_overlay_msg": "Gemini Vision sta analizzando il prodotto...",
|
||||||
|
"ai_retry_btn": "Riprova con AI",
|
||||||
|
"ai_match_title": "Prodotto riconosciuto con AI",
|
||||||
|
"ai_match_subtitle": "Scegli se usare un prodotto gia presente oppure aggiungere quello rilevato.",
|
||||||
|
"ai_match_existing": "Possibili corrispondenze in dispensa",
|
||||||
|
"ai_match_none": "Nessun prodotto simile trovato in dispensa.",
|
||||||
|
"ai_match_use_btn": "Usa questo",
|
||||||
|
"ai_match_add_btn": "Aggiungi \"{name}\"",
|
||||||
|
"ai_detected_label": "AI ha trovato"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "Cosa vuoi fare?",
|
"title": "Cosa vuoi fare?",
|
||||||
@@ -664,7 +687,9 @@
|
|||||||
"back": "📱 Posteriore (default)",
|
"back": "📱 Posteriore (default)",
|
||||||
"front": "🤳 Anteriore",
|
"front": "🤳 Anteriore",
|
||||||
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
|
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
|
||||||
"detect_btn": "🔄 Rileva fotocamere"
|
"detect_btn": "🔄 Rileva fotocamere",
|
||||||
|
"ai_fallback_label": "Identificazione visiva AI (fallback 5s)",
|
||||||
|
"ai_fallback_hint": "Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare il prodotto visivamente. Richiede Gemini configurato."
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "🔒 Certificato HTTPS",
|
"title": "🔒 Certificato HTTPS",
|
||||||
@@ -1063,6 +1088,7 @@
|
|||||||
"ai_quota": "Quota AI esaurita. Riprova tra qualche minuto.",
|
"ai_quota": "Quota AI esaurita. Riprova tra qualche minuto.",
|
||||||
"barcode_empty": "Inserisci un codice a barre",
|
"barcode_empty": "Inserisci un codice a barre",
|
||||||
"barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)",
|
"barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)",
|
||||||
|
"barcode_checksum": "Checksum EAN non valido — verifica le cifre del codice",
|
||||||
"min_chars": "Scrivi almeno 2 caratteri",
|
"min_chars": "Scrivi almeno 2 caratteri",
|
||||||
"not_in_inventory": "Prodotto non nell'inventario",
|
"not_in_inventory": "Prodotto non nell'inventario",
|
||||||
"appliance_exists": "Elettrodomestico già presente",
|
"appliance_exists": "Elettrodomestico già presente",
|
||||||
|
|||||||
Reference in New Issue
Block a user