From cf65e79010ccf09bdd6f4239a97231c2fe54df80 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Thu, 4 Jun 2026 17:22:59 +0000 Subject: [PATCH] Release v1.7.36: recipe stock hints, ghost products, and shopping total fix. Adds pantry stock/remainder lines on recipe ingredients with zero-waste use-all on sealed package leftovers, ghost product restore in the dashboard, unified shopping totals, i18n sync, and maintenance scripts. Co-authored-by: Cursor --- CHANGELOG.md | 21 + README.md | 3 +- api/index.php | 525 +++++++++++++++++--- assets/css/style.css | 7 + assets/js/app.js | 700 +++++++++++++++++++++------ index.html | 6 +- manifest.json | 2 +- scripts/merge-duplicate-products.php | 111 +++++ scripts/re-enrich-recipe.php | 50 ++ scripts/sync-i18n.py | 341 +++++++++++++ translations/de.json | 59 ++- translations/en.json | 59 ++- translations/es.json | 126 ++++- translations/fr.json | 126 ++++- translations/it.json | 59 ++- 15 files changed, 1908 insertions(+), 287 deletions(-) create mode 100644 scripts/merge-duplicate-products.php create mode 100644 scripts/re-enrich-recipe.php create mode 100644 scripts/sync-i18n.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bb341d5..b0f600f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap. +## [1.7.36] - 2026-06-04 + +### Added +- **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations. +- **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages). +- **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input. +- **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history. +- **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product. +- **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge). + +### Fixed +- **Unified shopping total** — Dashboard, Spesa page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too. +- **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation. +- **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups. +- **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs. +- **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust. +- **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation. +- **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches. +- **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name. +- **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock. + ## [1.7.35] - 2026-06-02 ### Fixed diff --git a/README.md b/README.md index 8013406..fed9686 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ [![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile) [![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/) -[![Version](https://img.shields.io/badge/version-1.7.33-brightgreen.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.36-brightgreen.svg)](CHANGELOG.md) [![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers) [![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main) [![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors) @@ -86,6 +86,7 @@ Connect your pantry to your smart home in minutes — no YAML, no manual sensor - **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones - **Storage & shelf-life hint** — When adding a new product, Gemini suggests the optimal storage location and shelf-life in the background; shown as an inline AI badge next to the expiry estimate - **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated +- **Recipe stock hints** — Each pantry ingredient shows how much you have and what remains after use; when the leftover would be less than 5% of the full sealed package (10% for an already-opened partial pack), the recipe automatically uses everything on hand to avoid waste - **Smart chat assistant** — Ask questions about your inventory, get cooking tips - **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip - **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do diff --git a/api/index.php b/api/index.php index dae2550..43f4089 100644 --- a/api/index.php +++ b/api/index.php @@ -650,6 +650,7 @@ if ($rateLimitAction) { // the explicit header is an additional defence-in-depth check for POST writes. $_writeActions = [ 'inventory_add','inventory_use','inventory_update','inventory_remove', + 'inventory_confirm_finished','inventory_restore_ghost', 'product_save','product_delete','product_merge', 'bring_add','bring_remove','bring_sync','bring_set_spec','bring_migrate_names', 'shopping_add','shopping_remove', @@ -715,6 +716,9 @@ try { case 'product_delete': deleteProduct($db); break; + case 'product_merge': + mergeProduct($db); + break; case 'products_list': listProducts($db); break; @@ -747,6 +751,9 @@ try { case 'inventory_confirm_finished': confirmFinished($db); break; + case 'inventory_restore_ghost': + restoreGhostInventory($db); + break; case 'inventory_summary': inventorySummary($db); break; @@ -2512,8 +2519,18 @@ function saveProduct(PDO $db): void { ? $input['shopping_name'] : computeShoppingName($input['name'], $input['category'] ?? '', $input['brand'] ?? ''); - if (!empty($input['id'])) { - // Update existing + $id = !empty($input['id']) ? (int)$input['id'] : 0; + $merged = false; + if (!$id) { + $dupId = findDuplicateProductId($db, $input['name'], $input['brand'] ?? '', $input['barcode'] ?? null, null); + if ($dupId) { + $id = $dupId; + $merged = true; + } + } + + if ($id) { + // Update existing (or matched duplicate) $stmt = $db->prepare(" UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?, default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?, @@ -2526,9 +2543,9 @@ function saveProduct(PDO $db): void { $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, $input['notes'] ?? '', $input['barcode'] ?? null, $input['package_unit'] ?? '', - $shoppingName, $nutriJson, $input['id'] + $shoppingName, $nutriJson, $id ]); - echo json_encode(['success' => true, 'id' => $input['id']]); + echo json_encode(['success' => true, 'id' => $id, 'merged' => $merged]); } else { // Insert new $stmt = $db->prepare(" @@ -2863,8 +2880,19 @@ function useFromInventory(PDO $db): void { // Guard against accidental double-consume triggers (scale jitter, double tap, // delayed/offline replay burst). We only apply this stricter gate to manual // uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected. - if (!$useAll) { - $dedupWindow = ($notes === '') ? 120 : 12; + $dedupWindow = $useAll ? 60 : (($notes === '') ? 120 : 12); + if ($useAll) { + $dedup = $db->prepare( + "SELECT id, quantity, created_at FROM transactions + WHERE product_id = ? + AND type IN ('out','waste') + AND undone = 0 + AND created_at >= datetime('now', '-' || ? || ' seconds') + ORDER BY id DESC + LIMIT 1" + ); + $dedup->execute([$productId, $dedupWindow]); + } else { $dedup = $db->prepare( "SELECT id, quantity, created_at FROM transactions WHERE product_id = ? @@ -2877,25 +2905,26 @@ function useFromInventory(PDO $db): void { LIMIT 1" ); $dedup->execute([$productId, $location, $notes, $dedupWindow]); - $recent = $dedup->fetch(); - if ($recent) { - EverLog::warn('useFromInventory duplicate blocked', [ - 'product_id' => $productId, - 'location' => $location, - 'window_s' => $dedupWindow, - 'recent_tx_id' => $recent['id'] ?? null, - 'recent_qty' => $recent['quantity'] ?? null, - 'recent_created_at' => $recent['created_at'] ?? null, - 'requested_qty' => $quantity, - 'notes' => $notes, - ]); - echo json_encode([ - 'success' => false, - 'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.', - 'duplicate' => true, - ]); - return; - } + } + $recent = $dedup->fetch(); + if ($recent) { + EverLog::warn('useFromInventory duplicate blocked', [ + 'product_id' => $productId, + 'location' => $location, + 'use_all' => $useAll, + 'window_s' => $dedupWindow, + 'recent_tx_id' => $recent['id'] ?? null, + 'recent_qty' => $recent['quantity'] ?? null, + 'recent_created_at' => $recent['created_at'] ?? null, + 'requested_qty' => $quantity, + 'notes' => $notes, + ]); + echo json_encode([ + 'success' => false, + 'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.', + 'duplicate' => true, + ]); + return; } // ───────────────────────────────────────────────────────────────────── @@ -3325,17 +3354,168 @@ function updateInventory(PDO $db): void { function deleteInventory(PDO $db): void { EverLog::info('deleteInventory'); $input = json_decode(file_get_contents('php://input'), true); - $id = $input['id'] ?? 0; - $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); + $id = (int)($input['id'] ?? 0); + if (!$id) { + http_response_code(400); + echo json_encode(['error' => 'Inventory ID required']); + return; + } + + $stmt = $db->prepare("SELECT id, product_id, quantity, location FROM inventory WHERE id = ?"); $stmt->execute([$id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + http_response_code(404); + echo json_encode(['error' => 'Inventory row not found']); + return; + } + + $qty = (float)$row['quantity']; + if ($qty > 0.0001) { + $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, ?)") + ->execute([(int)$row['product_id'], $qty, $row['location'], '[Eliminazione inventario]']); + } + + $db->prepare("DELETE FROM inventory WHERE id = ?")->execute([$id]); echo json_encode(['success' => true]); } +function productQtyThreshold(string $unit): float { + static $thresholds = ['g' => 20, 'ml' => 20, 'kg' => 0.02, 'l' => 0.02, 'conf' => 0.1, 'pz' => 0.5]; + return $thresholds[$unit] ?? 0.5; +} + +function normalizeProductName(string $name): string { + return mb_strtolower(trim($name)); +} + +function normalizeProductBrand(string $brand): string { + return mb_strtolower(trim($brand)); +} + +function brandsCompatible(string $a, string $b): bool { + $na = normalizeProductBrand($a); + $nb = normalizeProductBrand($b); + return $na === $nb || $na === '' || $nb === ''; +} + +function findDuplicateProductId(PDO $db, string $name, string $brand, ?string $barcode, ?int $excludeId = null): ?int { + if ($barcode !== null && trim($barcode) !== '') { + $sql = "SELECT id FROM products WHERE barcode = ? AND barcode IS NOT NULL AND TRIM(barcode) != ''"; + $params = [$barcode]; + if ($excludeId) { + $sql .= " AND id != ?"; + $params[] = $excludeId; + } + $sql .= " ORDER BY id ASC LIMIT 1"; + $stmt = $db->prepare($sql); + $stmt->execute($params); + $id = $stmt->fetchColumn(); + if ($id) { + return (int)$id; + } + } + + $nName = normalizeProductName($name); + if ($nName === '') { + return null; + } + + $sql = "SELECT id, brand FROM products WHERE lower(trim(name)) = ?"; + $params = [$nName]; + if ($excludeId) { + $sql .= " AND id != ?"; + $params[] = $excludeId; + } + $stmt = $db->prepare($sql); + $stmt->execute($params); + $candidates = $stmt->fetchAll(PDO::FETCH_ASSOC); + if (!$candidates) { + return null; + } + + $targetBrand = normalizeProductBrand($brand); + $compatible = null; + foreach ($candidates as $c) { + $cBrand = normalizeProductBrand($c['brand'] ?? ''); + if ($cBrand === $targetBrand) { + return (int)$c['id']; + } + if ($compatible === null && brandsCompatible($brand, $c['brand'] ?? '')) { + $compatible = (int)$c['id']; + } + } + return $compatible; +} + +function getProductLedgerBalance(PDO $db, int $productId): array { + $stmt = $db->prepare(" + SELECT + COALESCE(SUM(CASE WHEN type = 'in' AND undone = 0 THEN quantity ELSE 0 END), 0) AS total_in, + COALESCE(SUM(CASE WHEN type IN ('out','waste') AND undone = 0 THEN quantity ELSE 0 END), 0) AS total_out + FROM transactions + WHERE product_id = ? + "); + $stmt->execute([$productId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0]; + $stockStmt = $db->prepare("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ?"); + $stockStmt->execute([$productId]); + return [ + 'total_in' => (float)$row['total_in'], + 'total_out' => (float)$row['total_out'], + 'stock' => (float)$stockStmt->fetchColumn(), + ]; +} + +function mergeProducts(PDO $db, int $keepId, int $dropId): void { + if ($keepId === $dropId) { + return; + } + $check = $db->prepare("SELECT id FROM products WHERE id IN (?, ?)"); + $check->execute([$keepId, $dropId]); + if ($check->rowCount() < 2) { + throw new RuntimeException('One or both products not found'); + } + + $db->beginTransaction(); + try { + $db->prepare("UPDATE inventory SET product_id = ? WHERE product_id = ?")->execute([$keepId, $dropId]); + $db->prepare("UPDATE transactions SET product_id = ? WHERE product_id = ?")->execute([$keepId, $dropId]); + $db->prepare("DELETE FROM products WHERE id = ?")->execute([$dropId]); + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } +} + +function mergeProduct(PDO $db): void { + EverLog::info('mergeProduct'); + $input = json_decode(file_get_contents('php://input'), true); + $keepId = (int)($input['keep_id'] ?? $input['canonical_id'] ?? 0); + $dropId = (int)($input['drop_id'] ?? $input['duplicate_id'] ?? 0); + if (!$keepId || !$dropId) { + http_response_code(400); + echo json_encode(['error' => 'keep_id and drop_id required']); + return; + } + + try { + mergeProducts($db, $keepId, $dropId); + echo json_encode(['success' => true, 'keep_id' => $keepId, 'drop_id' => $dropId]); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +} + /** - * Returns products whose entire inventory is at quantity = 0 AND whose + * Returns products whose ledger balance exceeds stock (including vanished rows). * transaction balance (total_in - total_out) is still significantly positive — * meaning the system suspects the product ran out prematurely (scale drift, - * missed registration, etc.). + * missed registration, deleted inventory row, etc.). * * Products where the balance is at/near zero are legitimately finished by the * user; those rows are silently deleted here (no banner needed). @@ -3344,30 +3524,27 @@ function getFinishedItems(PDO $db): void { EverLog::debug('getFinishedItems'); $rows = $db->query(" SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, p.image_url, p.barcode, - MIN(i.location) AS location, - MAX(i.updated_at) AS updated_at, COALESCE(SUM(CASE WHEN t.type = 'in' AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_in, - COALESCE(SUM(CASE WHEN t.type IN ('out','waste') AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_out + COALESCE(SUM(CASE WHEN t.type IN ('out','waste') AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_out, + COALESCE((SELECT SUM(i2.quantity) FROM inventory i2 WHERE i2.product_id = p.id), 0) AS stock_qty, + (SELECT COUNT(*) FROM inventory i3 WHERE i3.product_id = p.id) AS inv_rows, + (SELECT i4.location FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS inv_location, + (SELECT i4.updated_at FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS inv_updated, + (SELECT t2.location FROM transactions t2 WHERE t2.product_id = p.id AND t2.undone = 0 ORDER BY t2.created_at DESC LIMIT 1) AS tx_location FROM products p - JOIN inventory i ON i.product_id = p.id LEFT JOIN transactions t ON t.product_id = p.id - WHERE NOT EXISTS ( - SELECT 1 FROM inventory i2 WHERE i2.product_id = p.id AND i2.quantity > 0 - ) GROUP BY p.id - ORDER BY MAX(i.updated_at) DESC + HAVING stock_qty <= 0.001 AND total_in > 0 + ORDER BY (total_in - total_out) DESC ")->fetchAll(PDO::FETCH_ASSOC); - // Per-unit threshold: residue below this is considered normal rounding/finish - $thresholds = ['g' => 20, 'ml' => 20, 'kg' => 0.02, 'l' => 0.02, 'conf' => 0.1, 'pz' => 0.5]; - $suspicious = []; foreach ($rows as $r) { $expected = (float)$r['total_in'] - (float)$r['total_out']; - $threshold = $thresholds[$r['unit']] ?? 0.5; + $threshold = productQtyThreshold($r['unit']); if ($expected > $threshold) { - // Transaction balance says stock should remain — show banner + $location = $r['inv_location'] ?: $r['tx_location'] ?: 'dispensa'; $suspicious[] = [ 'product_id' => (int)$r['product_id'], 'name' => $r['name'], @@ -3377,13 +3554,14 @@ function getFinishedItems(PDO $db): void { 'package_unit' => $r['package_unit'], 'image_url' => $r['image_url'], 'barcode' => $r['barcode'], - 'location' => $r['location'], - 'updated_at' => $r['updated_at'], + 'location' => $location, + 'updated_at' => $r['inv_updated'], 'expected_qty' => round($expected, 3), + 'ghost' => true, + 'vanished' => ((int)$r['inv_rows']) === 0, ]; } else { - // Legitimately finished — delete silently, no banner - $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0") + $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0") ->execute([$r['product_id']]); } } @@ -3392,7 +3570,8 @@ function getFinishedItems(PDO $db): void { } /** - * Permanently delete all qty=0 inventory rows for a product after user confirms it is finished. + * Permanently reconcile a finished/ghost product: log the missing quantity as + * an explicit out transaction, then delete any zero-qty inventory rows. */ function confirmFinished(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); @@ -3403,10 +3582,89 @@ function confirmFinished(PDO $db): void { echo json_encode(['error' => 'product_id required']); return; } - $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0")->execute([$productId]); + + $prod = $db->prepare("SELECT unit FROM products WHERE id = ?"); + $prod->execute([$productId]); + $unit = $prod->fetchColumn(); + if (!$unit) { + http_response_code(404); + echo json_encode(['error' => 'Product not found']); + return; + } + + $bal = getProductLedgerBalance($db, $productId); + $expected = $bal['total_in'] - $bal['total_out']; + $threshold = productQtyThreshold((string)$unit); + + if ($expected > $threshold) { + $locStmt = $db->prepare("SELECT location FROM inventory WHERE product_id = ? ORDER BY updated_at DESC LIMIT 1"); + $locStmt->execute([$productId]); + $location = $locStmt->fetchColumn(); + if (!$location) { + $locStmt = $db->prepare("SELECT location FROM transactions WHERE product_id = ? AND undone = 0 ORDER BY created_at DESC LIMIT 1"); + $locStmt->execute([$productId]); + $location = $locStmt->fetchColumn(); + } + $location = $location ?: 'dispensa'; + $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, ?)") + ->execute([$productId, round($expected, 3), $location, '[Riconciliazione] Confermato esaurito']); + } + + $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0")->execute([$productId]); echo json_encode(['success' => true]); } +/** + * Restore stock for a ghost product without adding a new purchase (in) transaction. + */ +function restoreGhostInventory(PDO $db): void { + EverLog::info('restoreGhostInventory'); + $input = json_decode(file_get_contents('php://input'), true); + $productId = (int)($input['product_id'] ?? 0); + $quantity = (float)($input['quantity'] ?? 0); + $location = trim((string)($input['location'] ?? 'dispensa')) ?: 'dispensa'; + + if (!$productId || $quantity <= 0) { + http_response_code(400); + echo json_encode(['error' => 'product_id and quantity required']); + return; + } + + $prod = $db->prepare("SELECT id FROM products WHERE id = ?"); + $prod->execute([$productId]); + if (!$prod->fetchColumn()) { + http_response_code(404); + echo json_encode(['error' => 'Product not found']); + return; + } + + $stmt = $db->prepare(" + SELECT id, quantity FROM inventory + WHERE product_id = ? AND location = ? AND opened_at IS NULL + ORDER BY CASE WHEN quantity > 0 THEN 0 ELSE 1 END, updated_at DESC + LIMIT 1 + "); + $stmt->execute([$productId, $location]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($row) { + $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + ->execute([$quantity, (int)$row['id']]); + $invId = (int)$row['id']; + } else { + $db->prepare("INSERT INTO inventory (product_id, location, quantity) VALUES (?, ?, ?)") + ->execute([$productId, $location, $quantity]); + $invId = (int)$db->lastInsertId(); + } + + echo json_encode([ + 'success' => true, + 'inventory_id' => $invId, + 'quantity' => $quantity, + 'location' => $location, + ]); +} + function inventorySummary(PDO $db): void { EverLog::debug('inventorySummary'); $stmt = $db->query(" @@ -5452,6 +5710,142 @@ PROMPT; return $text; } +/** Parse "200 g" / "2 pz" style recipe qty strings. */ +function recipeParseQtyString(string $qty): array { + $val = 0.0; + $unit = ''; + if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $qty, $qm)) { + $val = (float)str_replace(',', '.', $qm[1]); + $ru = strtolower($qm[2]); + if (strpos($ru, 'g') === 0) $unit = 'g'; + elseif ($ru === 'kg') { $unit = 'g'; $val *= 1000; } + elseif ($ru === 'ml') $unit = 'ml'; + elseif ($ru === 'cl') { $unit = 'ml'; $val *= 10; } + elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $unit = 'ml'; $val *= 1000; } + elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $unit = 'pz'; + elseif (strpos($ru, 'conf') === 0) $unit = 'conf'; + } + return ['val' => $val, 'unit' => $unit]; +} + +function recipeGetProductTotalStock(PDO $db, int $productId): float { + $stmt = $db->prepare('SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ? AND quantity > 0'); + $stmt->execute([$productId]); + return (float)$stmt->fetchColumn(); +} + +/** Full sealed unit size for % remainder (conf → default_quantity in g/ml per conf). */ +function recipeGetClosedProductBaseQty(array $ing): float { + $unit = $ing['inventory_unit'] ?? 'pz'; + $pkgSize = (float)($ing['default_quantity'] ?? 0); + $pkgUnit = strtolower($ing['package_unit'] ?? ''); + + if ($unit === 'conf' && $pkgSize > 0 && in_array($pkgUnit, ['g', 'ml'], true)) { + return $pkgSize; + } + if ($unit === 'conf' && $pkgSize > 0) { + return $pkgSize; + } + if ($pkgSize > 0 && in_array($unit, ['g', 'ml', 'pz'], true)) { + return $pkgSize; + } + if ($unit === 'conf') { + return 1.0; + } + return 0.0; +} + +/** Use-all when leftover is < 5% of the sealed package (not current stock). */ +function recipeShouldUseAllRemainder(float $remainDisp, array $ing, float $stockDisp = 0): bool { + if ($remainDisp <= 0) { + return false; + } + $packageBase = recipeGetClosedProductBaseQty($ing); + if ($packageBase <= 0) { + return false; + } + $pct = $remainDisp / $packageBase; + if ($pct < 0.05) { + return true; + } + // Opened/partial: less than one full sealed unit on hand — allow up to 10% tail waste + if ($stockDisp > 0 && $stockDisp < $packageBase && $pct < 0.10) { + return true; + } + return false; +} + +/** Normalize use qty, apply <5% remainder → use-all, set stock_have/stock_remain hints. */ +function recipeFinalizeIngQty(array &$ing, float $totalStockQty): void { + $parsed = recipeParseQtyString($ing['qty'] ?? ''); + $recipeVal = $parsed['val']; + $recipeUnit = $parsed['unit']; + $unit = $ing['inventory_unit'] ?? 'pz'; + $pkgSize = (float)($ing['default_quantity'] ?? 0); + $pkgUnit = strtolower($ing['package_unit'] ?? ''); + $isConfSub = ($unit === 'conf' && $pkgSize > 0 && in_array($pkgUnit, ['g', 'ml'], true)); + + $useQty = (float)($ing['qty_number'] ?? 0); + + // conf+weight: always prefer the recipe amount from the qty string (not inventory conf count) + if ($isConfSub && $recipeVal > 0 && $recipeUnit === $pkgUnit) { + $useQty = $recipeVal; + $ing['qty_number'] = round($useQty, 3); + $ing['qty'] = round($useQty) . ' ' . $pkgUnit; + } + + if ($isConfSub) { + $stockDisp = $totalStockQty * $pkgSize; + $useDisp = $useQty; + $dispUnit = $pkgUnit; + } else { + $stockDisp = $totalStockQty; + $useDisp = $useQty; + $dispUnit = $unit; + } + + if ($stockDisp <= 0 || $useDisp <= 0) { + $ing['stock_have'] = round($stockDisp, 2); + $ing['stock_remain'] = max(0, round($stockDisp - $useDisp, 2)); + $ing['stock_unit'] = $dispUnit; + return; + } + + $remainDisp = $stockDisp - $useDisp; + if (recipeShouldUseAllRemainder($remainDisp, $ing, $stockDisp)) { + $ing['use_all_suggested'] = true; + $useDisp = $stockDisp; + $remainDisp = 0; + if ($isConfSub) { + $ing['qty_number'] = round($useDisp, 1); + $ing['qty'] = round($useDisp) . ' ' . $pkgUnit; + } else { + $ing['qty_number'] = round($totalStockQty, 3); + if ($unit === 'pz') { + $ing['qty'] = round($totalStockQty, 2) . ' pz'; + } else { + $ing['qty'] = round($totalStockQty, ($unit === 'g' || $unit === 'ml') ? 0 : 2) . ' ' . $unit; + } + } + } + + $ing['stock_have'] = round($stockDisp, 2); + $ing['stock_remain'] = round($remainDisp, 2); + $ing['stock_unit'] = $dispUnit; +} + +function recipeApplyStockHintsToRecipe(PDO $db, array &$recipe): void { + if (empty($recipe['ingredients']) || !is_array($recipe['ingredients'])) return; + foreach ($recipe['ingredients'] as &$ing) { + if (empty($ing['from_pantry']) || empty($ing['product_id'])) continue; + $totalStock = recipeGetProductTotalStock($db, (int)$ing['product_id']); + if ($totalStock <= 0) continue; + $ing['inventory_qty_total'] = $totalStock; + recipeFinalizeIngQty($ing, $totalStock); + } + unset($ing); +} + // ===== RECIPE GENERATION WITH GEMINI ===== function generateRecipe(PDO $db): void { EverLog::debug('generateRecipe start'); @@ -6071,9 +6465,14 @@ PROMPT; if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { $defQty = (float)($bestMatch['default_quantity'] ?? 0); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); - if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { - $qtyNum = round($qtyNum * $defQty); - $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) { + if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) { + $qtyNum = $recipeVal; + $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; + } elseif ($qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); + $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } } } // Sanity check: qty_number should not exceed available @@ -6093,6 +6492,7 @@ PROMPT; } } unset($ing); + recipeApplyStockHintsToRecipe($db, $recipe); } EverLog::info('recipe generated', ['title' => $recipe['title'] ?? '?', 'meal' => $mealType, 'persons' => $persons, 'ingredients' => count($recipe['ingredients'] ?? [])]); @@ -6191,6 +6591,7 @@ PROMPT; if (!empty($recipe['ingredients'])) { _enrichChatIngredients($recipe['ingredients'], $items); } + recipeApplyStockHintsToRecipe($db, $recipe); echo json_encode(['success' => true, 'recipe' => $recipe]); } @@ -6305,6 +6706,7 @@ PROMPT; if (!empty($recipe['ingredients'])) { _enrichChatIngredients($recipe['ingredients'], $items); } + recipeApplyStockHintsToRecipe($db, $recipe); EverLog::info('recipe_from_ingredient ok', ['ingredient' => $ingredientName, 'title' => $recipe['title'] ?? '?', 'persons' => $persons]); echo json_encode(['success' => true, 'recipe' => $recipe]); @@ -6461,8 +6863,14 @@ function _enrichChatIngredients(array &$ingredients, array $items): void { if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { $defQty = (float)($bestMatch['default_quantity'] ?? 0); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); - if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { - $qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) { + if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) { + $qtyNum = $recipeVal; + $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; + } elseif ($qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); + $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } } } if ($qtyNum > $invQty) $qtyNum = $invQty; @@ -7024,8 +7432,14 @@ PROMPT; if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { $defQty = (float)($bestMatch['default_quantity'] ?? 0); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); - if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { - $qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) { + if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) { + $qtyNum = $recipeVal; + $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; + } elseif ($qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); + $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } } } if ($qtyNum > $invQty) $qtyNum = $invQty; @@ -7035,6 +7449,7 @@ PROMPT; } } unset($ing); + recipeApplyStockHintsToRecipe($db, $recipe); } $send('status', ['step' => 4, 'message' => '✅ Ricetta pronta!']); diff --git a/assets/css/style.css b/assets/css/style.css index a9498dd..04bc8a5 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -4671,6 +4671,13 @@ body.server-offline .bottom-nav { line-height: 1.3; } +.recipe-ing-stock { + color: var(--text-muted); + font-size: 0.72rem; + line-height: 1.35; + opacity: 0.9; +} + /* ===== SHOPPING SECTION (REPARTO) HEADERS ===== */ .shopping-section-divider { display: flex; diff --git a/assets/js/app.js b/assets/js/app.js index a5f45ab..efb2cf1 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -515,10 +515,9 @@ function _scaleAutoFillUse(msg) { // Determine target unit let unit; if (_useConfMode) { - // Scale always reads weight (g/ml) — auto-switch to sub-unit mode if still in conf mode - if (_useConfMode._activeUnit !== 'sub') { - switchUseUnit('sub'); - } + // Scale reads weight (g/ml) — only auto-fill in sub-unit mode. + // If the user chose "Confezioni", respect that (fraction buttons / manual qty). + if (_useConfMode._activeUnit === 'conf') return; unit = (_useConfMode.packageUnit || '').toLowerCase(); } else { unit = _useNormalUnit; @@ -876,7 +875,7 @@ function _scaleUpdateStatus(state) { if (state === 'connected') { const devEl = document.getElementById('scale-diag-device'); const batEl = document.getElementById('scale-diag-battery'); - if (devEl) devEl.textContent = _scaleDevice || 'Dispositivo sconosciuto'; + if (devEl) devEl.textContent = _scaleDevice || t('settings.scale.unknown_device'); if (batEl) batEl.textContent = _scaleBattery != null ? `🔋 ${_scaleBattery}%` : ''; const weightEl = document.getElementById('scale-diag-weight'); if (weightEl && _scaleLatestWeight) { @@ -1075,7 +1074,7 @@ async function discoverScaleGateway() { btn.disabled = true; btn.textContent = '⏳'; status.style.display = 'block'; - status.textContent = '🔍 Scanning local network for scale gateway…'; + status.textContent = t('settings.scale.discover_scanning'); try { const res = await fetch('api/scale_discover.php', { signal: AbortSignal.timeout(8000), headers: { ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}) } }); @@ -1087,7 +1086,10 @@ async function discoverScaleGateway() { const url = data.found[0]; const urlEl = document.getElementById('setting-scale-url'); if (urlEl) urlEl.value = url; - status.textContent = '✅ Gateway found: ' + url + (data.found.length > 1 ? ' (+' + (data.found.length - 1) + ' more)' : ''); + status.textContent = t('settings.scale.discover_found', { + url, + more: data.found.length > 1 ? ' (+' + (data.found.length - 1) + ' more)' : '', + }); status.style.color = 'var(--color-success, #059669)'; // Auto-save const s = getSettings(); @@ -1095,19 +1097,21 @@ async function discoverScaleGateway() { saveSettingsToStorage(s); scaleInit(); } else { - status.textContent = '❌ No gateway found on ' + (data.subnet || 'local network') + '. Make sure the Android app is running and on the same Wi-Fi.'; + status.textContent = t('settings.scale.discover_not_found', { subnet: data.subnet || 'local network' }); } } catch(e) { - status.textContent = '❌ Discovery failed: ' + (e.message || 'timeout'); + status.textContent = t('settings.scale.discover_failed', { error: e.message || 'timeout' }); } btn.disabled = false; - btn.textContent = '🔍 Auto'; + btn.textContent = t('settings.scale.discover_auto'); } // ===== i18n TRANSLATION SYSTEM ===== +const _I18N_VERSION = '20260604c'; // bump when translations change let _i18nStrings = null; // current language translations (flat) let _i18nFallback = null; // Italian fallback (flat) +let _i18nLoadedVersion = null; let _currentLang = localStorage.getItem('evershelf_lang') || navigator.language?.slice(0, 2) || 'en'; const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch', fr: 'Français', es: 'Español' }; if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'en'; @@ -1156,15 +1160,17 @@ function t(key, params) { async function loadTranslations(lang) { lang = lang || _currentLang; try { - // Always load Italian as fallback - if (!_i18nFallback) { - const fbRes = await fetch(`translations/it.json?v=${Date.now()}`); + const bust = _I18N_VERSION; + // Reload fallback when version changes (avoids stale cached keys) + if (!_i18nFallback || _i18nLoadedVersion !== bust) { + const fbRes = await fetch(`translations/it.json?v=${bust}`); if (fbRes.ok) _i18nFallback = _flattenI18n(await fbRes.json()); + _i18nLoadedVersion = bust; } if (lang === 'it') { _i18nStrings = _i18nFallback; } else { - const res = await fetch(`translations/${encodeURIComponent(lang)}.json?v=${Date.now()}`); + const res = await fetch(`translations/${encodeURIComponent(lang)}.json?v=${bust}`); if (res.ok) _i18nStrings = _flattenI18n(await res.json()); else _i18nStrings = _i18nFallback; } @@ -2619,10 +2625,10 @@ async function _renderBackupTab() { if (data.last_backup_ts) { const secsAgo = Math.floor(Date.now() / 1000) - data.last_backup_ts; let ago; - if (secsAgo < 120) ago = secsAgo < 5 ? t('time.just_now') || 'adesso' : `${secsAgo}s fa`; - else if (secsAgo < 3600) ago = `${Math.floor(secsAgo / 60)} min fa`; - else if (secsAgo < 86400) ago = `${Math.floor(secsAgo / 3600)}h fa`; - else ago = `${Math.floor(secsAgo / 86400)}gg fa`; + if (secsAgo < 120) ago = secsAgo < 5 ? t('time.just_now') : t('time.seconds_ago', { n: secsAgo }); + else if (secsAgo < 3600) ago = t('time.minutes_ago', { n: Math.floor(secsAgo / 60) }); + else if (secsAgo < 86400) ago = t('time.hours_ago', { n: Math.floor(secsAgo / 3600) }); + else ago = t('time.days_ago', { n: Math.floor(secsAgo / 86400) }); const name = data.last_backup_file || ''; lastInfoEl.innerHTML = `${t('settings.backup.last_backup') || 'Ultimo backup'}: ${ago} (${name})`; } else { @@ -3363,7 +3369,7 @@ function _kioskReconfigureScale() { // Kiosk APK is outdated — show update notice const notice = document.getElementById('kiosk-needs-update-notice'); if (notice) notice.style.display = ''; - showToast('⚠️ Aggiorna il kiosk per usare questa funzione', 'warning'); + showToast(t('settings.kiosk_update_required'), 'warning'); } } @@ -4909,8 +4915,9 @@ async function loadDashboard() { }); // Load shopping list count from Bring! loadShoppingCount(); - // Show last known price total immediately from sessionStorage (before next background fetch) - _updateDashboardPriceTotal(); + // Show last known total instantly, then refresh from server + _loadCanonicalTotalFromSession(); + _applyShoppingTotalDisplay(); // Quick recipe button - show when there are expiring products const recipeBar = document.getElementById('quick-recipe-bar'); @@ -5365,10 +5372,9 @@ async function loadBannerAlerts() { _bannerQueue.push({ type: 'dup_loss_check', data: ch }); }); - // 7. Finished products: inventory hit 0, waiting for user confirmation + // 7. Unresolved ghosts: always show while server reports ledger/stock mismatch const finished = finishedData.finished || []; finished.forEach(fin => { - if (confirmed['fin_' + fin.product_id]) return; _bannerQueue.push({ type: 'finished', data: fin }); }); @@ -5626,15 +5632,16 @@ function renderBannerItem() { } else if (entry.type === 'finished') { const fin = entry.data; banner.className = 'alert-banner banner-finished'; - iconEl.textContent = '📦'; + iconEl.textContent = fin.vanished ? '👻' : '📦'; const barcodeSuffix = fin.barcode && fin.barcode.length >= 3 ? ` …${escapeHtml(fin.barcode.slice(-3))}` : ''; titleEl.innerHTML = `${escapeHtml(fin.name)}${fin.brand ? ' (' + escapeHtml(fin.brand) + ')' : ''}${barcodeSuffix} — ${escapeHtml(t('dashboard.banner_finished_title'))}`; const expectedText = fin.expected_qty ? ' ' + t('dashboard.banner_finished_expected', { qty: fin.expected_qty, unit: fin.unit }) : ''; - detailEl.innerHTML = t('dashboard.banner_finished_zero') + expectedText + ' ' + t('dashboard.banner_finished_check'); + const baseText = fin.vanished ? t('dashboard.banner_finished_vanished') : t('dashboard.banner_finished_zero'); + detailEl.innerHTML = baseText + expectedText + ' ' + t('dashboard.banner_finished_check'); let btns = ``; - btns += ``; + btns += ``; actionsEl.innerHTML = btns; } else if (entry.type === 'anomaly') { @@ -5750,7 +5757,7 @@ function confirmBannerPrediction() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'prediction') return; setReviewConfirmed('pred_' + entry.data.inventory_id); - showToast('✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni', 'success'); + showToast(t('dashboard.banner_prediction_confirmed'), 'success'); dismissBannerItem(); } @@ -5792,7 +5799,7 @@ async function explainBannerAnomaly() { detailEl.innerHTML = `\ud83e\udd16 ${escapeHtml(result.explanation)}`; } else { detailEl.innerHTML = originalHtml; - showToast('Impossibile ottenere spiegazione AI', 'error'); + showToast(t('dashboard.banner_anomaly_explain_fail'), 'error'); } } catch (e) { detailEl.innerHTML = originalHtml; @@ -5813,7 +5820,7 @@ function dismissBannerAnomaly() { const key = entry.data.dismiss_key; setReviewConfirmed('an_' + key); api('dismiss_anomaly', {}, 'POST', { dismiss_key: key }).catch(() => {}); - showToast('Anomalia ignorata', 'info'); + showToast(t('dashboard.banner_anomaly_dismissed'), 'info'); dismissBannerItem(); } @@ -5975,30 +5982,40 @@ async function confirmBannerFinished() { try { await api('inventory_confirm_finished', {}, 'POST', { product_id: productId }); } catch(e) {} - setReviewConfirmed('fin_' + productId); showToast(t('toast.product_finished_confirmed'), 'success'); dismissBannerItem(); + if (typeof loadDashboard === 'function') loadDashboard(); } async function notFinishedBannerAction() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'finished') return; - const productId = entry.data.product_id; - // Remove from this session's queue (will re-appear next load if still at qty=0) - dismissBannerItem(); + const fin = entry.data; + const qty = fin.expected_qty; + const customQty = prompt( + t('dashboard.banner_finished_restore_prompt', { name: fin.name, qty, unit: fin.unit }), + String(qty) + ); + if (customQty === null) return; + const parsed = parseFloat(String(customQty).replace(',', '.')); + if (isNaN(parsed) || parsed <= 0) { + showToast(t('error.invalid_quantity'), 'error'); + return; + } showLoading(true); try { - const data = await api('product_get', { id: productId }); - showLoading(false); - if (data.product) { - currentProduct = data.product; - showAddForm(); - } else { - showToast(t('error.not_found'), 'error'); - } - } catch(e) { - showLoading(false); + await api('inventory_restore_ghost', {}, 'POST', { + product_id: fin.product_id, + quantity: parsed, + location: fin.location || 'dispensa', + }); + showToast(t('toast.ghost_restored', { name: fin.name, qty: parsed, unit: fin.unit }), 'success'); + dismissBannerItem(); + if (typeof loadDashboard === 'function') loadDashboard(); + } catch (e) { showToast(t('error.connection'), 'error'); + } finally { + showLoading(false); } } @@ -6667,7 +6684,14 @@ function editInventoryItem(id) { const scaleEditReady = s.scale_enabled && s.scale_gateway_url && _scaleConnected && (effectiveUnit === 'g' || effectiveUnit === 'ml'); - window._editingProduct = { name: item.name, category: item.category || '', _isOpened: !!item.opened_at }; + window._editingProduct = { + name: item.name, + category: item.category || '', + _isOpened: !!item.opened_at, + _prevUnit: item.unit || 'pz', + default_quantity: item.default_quantity, + package_unit: item.package_unit || '', + }; // Rebuild modal content for editing (don't close and reopen - just replace content) document.getElementById('modal-content').innerHTML = ` @@ -6740,6 +6764,23 @@ function onEditUnitChange() { const unit = document.getElementById('edit-unit').value; const confGroup = document.getElementById('edit-conf-size-group'); if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none'; + if (unit === 'conf') { + const sizeInput = document.getElementById('edit-conf-size'); + const confUnitSel = document.getElementById('edit-conf-unit'); + if (sizeInput && !parseFloat(sizeInput.value)) { + const ep = window._editingProduct || {}; + const defQty = parseFloat(ep.default_quantity) || 0; + const pkgUnit = ep.package_unit || ''; + const prevUnit = ep._prevUnit || ''; + if (defQty > 0 && pkgUnit) { + sizeInput.value = defQty; + if (confUnitSel) confUnitSel.value = pkgUnit; + } else if (defQty > 0 && ['g', 'ml'].includes(prevUnit)) { + sizeInput.value = defQty; + if (confUnitSel) confUnitSel.value = prevUnit; + } + } + } } async function submitEditInventory(e, id, productId) { @@ -6749,6 +6790,15 @@ async function submitEditInventory(e, id, productId) { const expiry = document.getElementById('edit-expiry').value || null; const unit = document.getElementById('edit-unit').value; + if (unit === 'conf') { + const confSize = parseFloat(document.getElementById('edit-conf-size')?.value); + if (!confSize || confSize <= 0) { + showToast(t('product.conf_size_required'), 'error'); + document.getElementById('edit-conf-size')?.focus(); + return; + } + } + // Safety guard: warn if quantity is unreasonably large to prevent unit-confusion errors // (e.g. user types "183" thinking it's ml, but the field expects conf units) const _largeQtyLimits = { conf: 50, pz: 200, g: 10000, ml: 10000 }; @@ -7533,7 +7583,7 @@ async function createQuickProduct(name) { }; showLoading(false); clearQuickNameResults(); - showToast('Prodotto creato!', 'success'); + showToast(t('toast.product_created'), 'success'); // If regex gave 'altro', try embedding in background and silently update if (category === 'altro' && typeof classifyCategoryByEmbedding === 'function') { @@ -7725,15 +7775,20 @@ function onCategoryChange(fromAutoDetect = false) { if (catDefaults[cat]) { // Only auto-fill unit/qty if user hasn't manually touched them - if (qtyInput.dataset.manuallySet !== 'true') { + const unitManuallySet = unitSelect.dataset.manuallySet === 'true'; + if (qtyInput.dataset.manuallySet !== 'true' && !unitManuallySet) { unitSelect.value = catDefaults[cat].unit; qtyInput.value = catDefaults[cat].qty; + } else if (qtyInput.dataset.manuallySet !== 'true' && unitManuallySet) { + qtyInput.value = catDefaults[cat].qty; } } } function onPfUnitChange() { - const unit = document.getElementById('pf-unit').value; + const unitEl = document.getElementById('pf-unit'); + if (unitEl) unitEl.dataset.manuallySet = 'true'; + const unit = unitEl?.value; const confRow = document.getElementById('pf-conf-size-row'); if (confRow) confRow.style.display = unit === 'conf' ? 'block' : 'none'; } @@ -7830,7 +7885,7 @@ async function scanBarcodeForForm() { document.getElementById('pf-barcode').value = code; _updateBarcodeHint(); if (navigator.vibrate) navigator.vibrate(80); - showToast(`🔖 Barcode acquisito: ${code}`, 'success'); + showToast(t('scan.barcode_acquired', { code }), 'success'); return; } } @@ -7869,7 +7924,7 @@ async function submitProduct(e) { if (result.success) { currentProduct = { ...productData, id: result.id }; showLoading(false); - showToast('Prodotto salvato!', 'success'); + showToast(t('toast.product_saved'), 'success'); showProductAction(); } else { showLoading(false); @@ -8409,7 +8464,7 @@ function showThrowForm() { api('inventory_list').then(data => { const items = (data.inventory || []).filter(i => i.product_id == currentProduct.id); if (items.length === 0) { - showToast('Prodotto non nell\'inventario', 'error'); + showToast(t('error.not_in_inventory'), 'error'); return; } @@ -8812,7 +8867,7 @@ function recalculateAddExpiry() { const newLabel = formatEstimatedExpiry(days); let suffix = ''; - if (window._historyExpiryDays) suffix = ' (da storico)'; + if (window._historyExpiryDays) suffix = t('product.from_history'); else if (loc === 'freezer' && isVacuum) suffix = ' ' + t('add.suffix_freezer_vacuum'); else if (loc === 'freezer') suffix = ' (freezer)'; else if (isVacuum) suffix = ' ' + t('add.suffix_vacuum'); @@ -8840,7 +8895,7 @@ async function _fetchExpiryHistoryAndUpdate(productId) { let days = isVacuum ? getVacuumExpiryDays(data.avg_days) : data.avg_days; const newDate = addDays(days); const newLabel = formatEstimatedExpiry(days); - const suffix = ` 📊 storico`; + const suffix = ` ${t('product.history_badge')}`; const expiryInput = document.getElementById('add-expiry'); const estimateEl = document.querySelector('.expiry-estimate-label'); const dateEl = document.querySelector('.expiry-estimate-date'); @@ -8917,7 +8972,7 @@ async function _applyAIProductHint() { // Show a toast only if location changed if (locChanged) { const locLabels = { dispensa: t('location.dispensa') || 'Dispensa', frigo: t('location.frigo') || 'Frigo', freezer: t('location.freezer') || 'Freezer' }; - showToast(`🤖 AI: conserva in ${locLabels[data.location] || data.location}`, 'info', 4000); + showToast(t('ai.conservation_hint', { location: locLabels[data.location] || data.location }), 'info', 4000); } } catch (e) { document.getElementById('ai-hint-loading')?.remove(); @@ -9041,7 +9096,7 @@ function selectPurchaseType(btn, type) { const estimatedDate = addDays(days); const estimateLabel = formatEstimatedExpiry(days); let suffix = ''; - if (window._historyExpiryDays) suffix = ` 📊 storico`; + if (window._historyExpiryDays) suffix = ` ${t('product.history_badge')}`; else if (loc === 'freezer' && isVacuum) suffix = ' ' + t('add.suffix_freezer_vacuum'); else if (loc === 'freezer') suffix = ' ' + t('add.suffix_freezer'); else if (isVacuum) suffix = ' ' + t('add.suffix_vacuum'); @@ -9070,7 +9125,7 @@ function selectPurchaseType(btn, type) { -

${t('add.expiry_hint')}

+

${t('product.expiry_hint')}

@@ -9168,7 +9223,7 @@ async function submitAdd(e) { const confSize = parseFloat(document.getElementById('add-conf-size')?.value); if (!confSize || confSize <= 0) { showLoading(false); - showToast('Specifica il contenuto di ogni confezione', 'error'); + showToast(t('product.conf_size_required'), 'error'); document.getElementById('add-conf-size')?.focus(); return; } @@ -9326,7 +9381,7 @@ function _updateUseHeroMeta(items) { const unit = items[0]?.unit; const qtyStr = stripHtml(formatQuantity(totalQty, unit, items[0]?.default_quantity, items[0]?.package_unit)); const locCount = new Set(items.map(i => i.location)).size; - const locSuffix = locCount > 1 ? ` · ${locCount} ${t('use.locations_short') || 'posti'}` : ''; + const locSuffix = locCount > 1 ? ` · ${locCount} ${t('use.locations_short')}` : ''; pills.push(`📦 ${escapeHtml(qtyStr)}${locSuffix}`); } @@ -9408,6 +9463,7 @@ function _renderUseExpiryHint(items) { } function _isOpenedInventoryItem(item) { + if (item.opened_at) return true; const q = parseFloat(item.quantity); const dq = parseFloat(item.default_quantity) || 0; if (item.unit === 'conf' && dq > 0) return q !== Math.floor(q); @@ -9415,6 +9471,10 @@ function _isOpenedInventoryItem(item) { return false; } +function _useAllTotalQty(items) { + return items.reduce((s, i) => s + parseFloat(i.quantity || 0), 0); +} + function _locationHasOpenedPackage(items, location) { return items.some(i => i.location === location && _isOpenedInventoryItem(i)); } @@ -9513,10 +9573,10 @@ async function loadUseInventoryInfo() { unitSwitch.style.display = 'flex'; document.getElementById('use-unit-sub').textContent = subLabel; - // If scale is active, start in sub-unit (g/ml) mode — scale always reads weight. - // Otherwise default to conf so the user thinks in packages. + // If scale is active, prefer grams for weighing — unless user explicitly chose conf. const _scaleActiveNow = getSettings().scale_enabled && getSettings().scale_gateway_url && _scaleConnected; - switchUseUnit(_scaleActiveNow ? 'sub' : 'conf'); + const _pref = _useConfMode._userPref; + switchUseUnit(_pref === 'conf' ? 'conf' : (_scaleActiveNow ? 'sub' : 'conf')); // Fraction shortcut buttons for conf mode (½, 1, 2 packages) const existingConfFrac = document.getElementById('conf-fraction-btns'); @@ -9586,6 +9646,8 @@ function switchUseUnit(mode) { const qtyInput = document.getElementById('use-quantity'); const hint = document.getElementById('use-partial-hint'); + if (_useConfMode) _useConfMode._userPref = mode; + // Show/hide fraction buttons depending on mode const confFracBtns = document.getElementById('conf-fraction-btns'); const pzFracBtns = document.getElementById('pz-fraction-btns'); @@ -9921,7 +9983,7 @@ function showLowStockBringPrompt(result, afterCallback) { if (shoppingListUUID) payload.listUUID = shoppingListUUID; const data = await api('shopping_add', {}, 'POST', payload); if (data.success && data.added > 0) { - showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'); + showToast(t('toast.finished_to_bring'), 'info'); } } catch(_e) { /* silent */ } })(); @@ -10174,7 +10236,7 @@ async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId, forcedVa product_id: productId, vacuum_sealed: newVacuum, }); - showToast(`📦 Spostato in ${LOCATIONS[toLoc]?.label || toLoc}`, 'success'); + showToast(t('move.moved_simple', { location: LOCATIONS[toLoc]?.label || toLoc }), 'success'); } } } catch (e) { @@ -10187,11 +10249,16 @@ async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId, forcedVa async function submitUseAll() { const name = currentProduct ? currentProduct.name : ''; const items0 = _useCurrentItems ? _useCurrentItems.filter(i => parseFloat(i.quantity) > 0) : []; + if (items0.length === 0) return; - // If there are opened packages, show the disambiguation FIRST (before the destructive confirm) + const unit = items0[0]?.unit || 'pz'; + const totalQty = _useAllTotalQty(items0); + const isConfMulti = unit === 'conf' && totalQty > 1.001; + + // Opened packages and/or conf disambiguation (one conf vs all) const allOpened = items0.filter(_isOpenedInventoryItem); - if (allOpened.length >= 1) { - _showUseAllDisambiguation(allOpened, items0); + if (allOpened.length >= 1 || isConfMulti) { + _showUseAllDisambiguation(allOpened, items0, { showOneConf: isConfMulti }); return; } @@ -10202,8 +10269,6 @@ async function submitUseAll() { } // Multiple rows, no opened packages → standard destructive confirm - const totalQty = items0.reduce((s, i) => s + parseFloat(i.quantity || 0), 0); - const unit = items0[0]?.unit || 'pz'; const qtyStr = stripHtml(formatQuantity(totalQty, unit, items0[0]?.default_quantity, items0[0]?.package_unit)); _showDestructiveConfirm( t('use.use_all_confirm_title') || '✅ Finisci tutto', @@ -10224,7 +10289,7 @@ async function _doSubmitUseAll() { }); showLoading(false); if (result.success) { - showToast(`📤 ${currentProduct.name} terminato!`, 'success'); + showToast(t('toast.finished_all', { name: currentProduct.name }), 'success'); if (result.added_to_bring) { setTimeout(() => showToast(t('use.toast_bring'), 'info'), 1500); } @@ -10239,12 +10304,16 @@ async function _doSubmitUseAll() { } /** - * Show a modal asking which opened package to mark as finished. - * Called when multiple opened packages exist across different locations. + * Ask whether the user finished one package or everything. + * Shown for opened packages and/or conf products with qty > 1. */ -function _showUseAllDisambiguation(openedItems, allItems) { +function _showUseAllDisambiguation(openedItems, allItems, options = {}) { + const { showOneConf = false } = options; const contentEl = document.getElementById('modal-content'); - const name = currentProduct ? currentProduct.name : ''; + const preferLoc = document.getElementById('use-location')?.value + || openedItems[0]?.location + || allItems[0]?.location + || 'dispensa'; const locButtons = openedItems.map(item => { const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location }; @@ -10257,10 +10326,17 @@ function _showUseAllDisambiguation(openedItems, allItems) { `; }).join(''); - // Option to finish everything - const totalQty = allItems.reduce((s, i) => s + parseFloat(i.quantity), 0); + const totalQty = _useAllTotalQty(allItems); const unit = allItems[0]?.unit || 'pz'; const totalStr = stripHtml(formatQuantity(totalQty, unit, allItems[0]?.default_quantity, allItems[0]?.package_unit)); + const oneStr = stripHtml(formatQuantity(1, unit, allItems[0]?.default_quantity, allItems[0]?.package_unit)); + + const oneConfBtn = showOneConf ? ` + ` : ''; contentEl.innerHTML = `

${t('use.disambiguation_hint')}

${locButtons} + ${oneConfBtn} `; @@ -15796,7 +16182,7 @@ async function generateRecipe() { } if (recipe) { - renderRecipe(recipe); + await renderRecipe(recipe); if (recipe.title) _generatedTodayTitles.push(recipe.title); await saveRecipeToArchive(recipe); _cachedRecipe = { meal, recipe }; @@ -15898,7 +16284,7 @@ async function chatTransferToRecipes(btn, replyText) { if (!recipe.persons) recipe.persons = 2; await saveRecipeToArchive(recipe); _cachedRecipe = { meal: recipe.meal || '', recipe }; - renderRecipe(recipe); + await renderRecipe(recipe); // Transform the transfer button into "Apri la ricetta" btn.disabled = false; btn.textContent = '📖 ' + (t('chat.open_recipe') || 'Apri la ricetta'); @@ -15951,7 +16337,7 @@ async function generateRecipeForIngredient(ingredientName) { if (!recipe.persons) recipe.persons = 2; await saveRecipeToArchive(recipe); _cachedRecipe = { meal: recipe.meal || '', recipe }; - renderRecipe(recipe); + await renderRecipe(recipe); document.getElementById('recipe-loading').style.display = 'none'; document.getElementById('recipe-result').style.display = ''; } catch (err) { @@ -16230,7 +16616,7 @@ function _handleOfflineApi(action, params, body) { // ─── Writes: queue and apply optimistic update to cache ────────────────── const QUEUEABLE = ['inventory_update', 'inventory_use', 'inventory_delete', - 'inventory_add', 'inventory_confirm_finished']; + 'inventory_add', 'inventory_confirm_finished', 'inventory_restore_ghost']; if (QUEUEABLE.includes(action)) { _offlineQueuePush(action, body); _applyOptimisticUpdate(action, body); @@ -16495,6 +16881,7 @@ function activateScreensaver() { updateScreensaverClock(); _screensaverClockInterval = setInterval(updateScreensaverClock, 1000); updateScreensaverShopping(); + syncShoppingPriceTotal(false); // Load data and start fact/nutrition rotation loadScreensaverData().then(() => { _startScreensaverRotation(); @@ -16526,12 +16913,13 @@ function updateScreensaverShopping() { let priceCol = ''; if (s.price_enabled) { - const saved = sessionStorage.getItem('_pricetotal'); - if (saved) { + _loadCanonicalTotalFromSession(); + const label = _canonicalShoppingTotal?.label; + if (label) { priceCol = `
-
${saved.replace('ca. ', '')}
-
💰 spesa stimata
+
${escapeHtml(label.replace(/^ca\.\s*/, ''))}
+
💰 ${escapeHtml(t('shopping.price_total_short') || 'spesa stimata')}
`; } } @@ -16748,7 +17136,7 @@ async function _screensaverAutoAddItems() { logOperation('bring_auto_add_screensaver', { added: itemsToAdd.map(i => i.name) }); // Refresh bring list silently then update screensaver counter loadShoppingList._bgCall = true; - loadShoppingList().then(() => updateScreensaverShopping()); + loadShoppingList().then(() => syncShoppingPriceTotal(false)); } } catch (e) { /* ignore */ } } @@ -17105,7 +17493,7 @@ function initSpesaMode() { function startSpesaMode() { _spesaMode = true; _spesaSession = []; - showToast('🛒 Modalità Spesa attivata!', 'success'); + showToast(t('scan.mode_shopping_activated'), 'success'); showPage('scan'); updateSpesaBanner(); } @@ -17975,18 +18363,16 @@ async function _initApp() { if (!_screensaverActive) refreshCurrentPage(); }, 5 * 60 * 1000); - // 2) Ogni 2 min: aggiorna contatore lista spesa nel badge dashboard e prezzi in background + // 2) Ogni 2 min: aggiorna contatore lista spesa e totale unificato setInterval(() => { - if (_screensaverActive) return; if (_currentPageId === 'shopping') { loadShoppingList._bgCall = true; loadShoppingList(); } else { loadShoppingCount(); - // Fetch prices silently in background so dashboard stat stays fresh const _s = getSettings(); - if (_s.price_enabled && shoppingItems.length > 0 && !_pricesFetching) { - fetchAllPrices(false); + if (_s.price_enabled && !_pricesFetching) { + syncShoppingPriceTotal(false); } } }, 2 * 60 * 1000); diff --git a/index.html b/index.html index be4e117..e1d37b0 100644 --- a/index.html +++ b/index.html @@ -72,7 +72,7 @@ - v1.7.35 + v1.7.36
@@ -85,7 +85,7 @@

- EverShelfv1.7.35 + EverShelfv1.7.36

@@ -1970,6 +1970,6 @@
- + diff --git a/manifest.json b/manifest.json index 5099699..d1327a9 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "name": "EverShelf", "short_name": "EverShelf", "description": "Gestione completa della dispensa di casa con scansione barcode", - "version": "1.7.35", + "version": "1.7.36", "start_url": "/evershelf/", "display": "standalone", "background_color": "#f0f4e8", diff --git a/scripts/merge-duplicate-products.php b/scripts/merge-duplicate-products.php new file mode 100644 index 0000000..f772f4b --- /dev/null +++ b/scripts/merge-duplicate-products.php @@ -0,0 +1,111 @@ +#!/usr/bin/env php +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +function normName(string $name): string { + return mb_strtolower(trim($name)); +} + +function normBrand(string $brand): string { + return mb_strtolower(trim($brand)); +} + +function brandsCompatible(string $a, string $b): bool { + $na = normBrand($a); + $nb = normBrand($b); + return $na === $nb || $na === '' || $nb === ''; +} + +function productScore(PDO $db, int $id): float { + $tx = (float)$db->query("SELECT COUNT(*) FROM transactions WHERE product_id = $id")->fetchColumn(); + $inv = (float)$db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = $id")->fetchColumn(); + return $tx * 10 + $inv; +} + +function mergeProducts(PDO $db, int $keepId, int $dropId): void { + $db->beginTransaction(); + try { + $db->prepare('UPDATE inventory SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]); + $db->prepare('UPDATE transactions SET product_id = ? WHERE product_id = ?')->execute([$keepId, $dropId]); + $db->prepare('DELETE FROM products WHERE id = ?')->execute([$dropId]); + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } +} + +$products = $db->query('SELECT id, name, brand, barcode FROM products ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); +$byName = []; +foreach ($products as $p) { + $key = normName($p['name']); + if ($key === '') { + continue; + } + $byName[$key][] = $p; +} + +$merged = 0; +foreach ($byName as $nameKey => $group) { + if (count($group) < 2) { + continue; + } + + // Split into compatible-brand clusters + $clusters = []; + foreach ($group as $p) { + $placed = false; + foreach ($clusters as &$cluster) { + $ref = $cluster[0]; + if (brandsCompatible($p['brand'] ?? '', $ref['brand'] ?? '')) { + $cluster[] = $p; + $placed = true; + break; + } + } + unset($cluster); + if (!$placed) { + $clusters[] = [$p]; + } + } + + foreach ($clusters as $cluster) { + if (count($cluster) < 2) { + continue; + } + + usort($cluster, fn($a, $b) => productScore($db, (int)$b['id']) <=> productScore($db, (int)$a['id'])); + $keep = (int)$cluster[0]['id']; + $keepName = $cluster[0]['name']; + for ($i = 1; $i < count($cluster); $i++) { + $drop = (int)$cluster[$i]['id']; + echo ($dryRun ? '[dry-run] ' : '') . "Merge #{$drop} \"{$cluster[$i]['name']}\" → #{$keep} \"{$keepName}\"\n"; + if (!$dryRun) { + mergeProducts($db, $keep, $drop); + } + $merged++; + } + } +} + +echo $dryRun + ? "Dry run: $merged merge(s) would be performed.\n" + : "Done: $merged duplicate product(s) merged.\n"; diff --git a/scripts/re-enrich-recipe.php b/scripts/re-enrich-recipe.php new file mode 100644 index 0000000..ef9700e --- /dev/null +++ b/scripts/re-enrich-recipe.php @@ -0,0 +1,50 @@ +#!/usr/bin/env php + + */ +define('CRON_MODE', true); +require __DIR__ . '/../api/index.php'; + +$id = (int)($argv[1] ?? 0); +if ($id <= 0) { + fwrite(STDERR, "Usage: php scripts/re-enrich-recipe.php \n"); + exit(1); +} + +$db = getDB(); +$stmt = $db->prepare('SELECT id, recipe_json FROM recipes WHERE id = ?'); +$stmt->execute([$id]); +$row = $stmt->fetch(PDO::FETCH_ASSOC); +if (!$row) { + fwrite(STDERR, "Recipe {$id} not found\n"); + exit(1); +} + +$recipe = json_decode($row['recipe_json'], true); +if (!is_array($recipe)) { + fwrite(STDERR, "Invalid recipe JSON for id {$id}\n"); + exit(1); +} + +recipeApplyStockHintsToRecipe($db, $recipe); + +$upd = $db->prepare('UPDATE recipes SET recipe_json = ? WHERE id = ?'); +$upd->execute([json_encode($recipe, JSON_UNESCAPED_UNICODE), $id]); + +echo "Updated recipe {$id}: " . ($recipe['title'] ?? '?') . "\n"; +foreach ($recipe['ingredients'] ?? [] as $ing) { + if (empty($ing['from_pantry'])) continue; + $useAll = !empty($ing['use_all_suggested']) ? ' [USE ALL]' : ''; + echo sprintf( + " %s: %s | hai %.1f %s | restano %.1f %s%s\n", + $ing['name'] ?? '?', + $ing['qty'] ?? '?', + $ing['stock_have'] ?? 0, + $ing['stock_unit'] ?? '', + $ing['stock_remain'] ?? 0, + $ing['stock_unit'] ?? '', + $useAll + ); +} diff --git a/scripts/sync-i18n.py b/scripts/sync-i18n.py new file mode 100644 index 0000000..dde0cf0 --- /dev/null +++ b/scripts/sync-i18n.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Sync translation files: ensure all locales have the same keys as it.json (reference).""" +from __future__ import annotations + +import copy +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent / 'translations' +REF = 'it.json' +LOCALES = ['it.json', 'en.json', 'de.json', 'fr.json', 'es.json'] + +# New keys added across all locales (nested path -> value per locale) +NEW_KEYS: dict[str, dict[str, str]] = { + 'dashboard.banner_prediction_confirmed': { + 'it': '✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni', + 'en': '✅ Confirmed — forecasts will recalculate from your next entries', + 'de': '✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet', + 'fr': '✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements', + 'es': '✅ Confirmado — las previsiones se recalcularán con tus próximos registros', + }, + 'dashboard.banner_anomaly_explain_fail': { + 'it': 'Impossibile ottenere spiegazione AI', + 'en': 'Could not get AI explanation', + 'de': 'KI-Erklärung konnte nicht abgerufen werden', + 'fr': 'Impossible d\'obtenir l\'explication IA', + 'es': 'No se pudo obtener la explicación de IA', + }, + 'dashboard.banner_anomaly_dismissed': { + 'it': 'Anomalia ignorata', + 'en': 'Anomaly dismissed', + 'de': 'Anomalie ignoriert', + 'fr': 'Anomalie ignorée', + 'es': 'Anomalía descartada', + }, + 'error.copy_failed': { + 'it': 'Copia negli appunti non riuscita', + 'en': 'Copy to clipboard failed', + 'de': 'Kopieren in die Zwischenablage fehlgeschlagen', + 'fr': 'Échec de la copie dans le presse-papiers', + 'es': 'Error al copiar al portapapeles', + }, + 'error.invalid_quantity': { + 'it': 'Quantità non valida', + 'en': 'Invalid quantity', + 'de': 'Ungültige Menge', + 'fr': 'Quantité invalide', + 'es': 'Cantidad no válida', + }, + 'dashboard.banner_finished_restore_prompt': { + 'it': 'Quante {unit} di {name} hai ancora? (stima sistema: {qty})', + 'en': 'How many {unit} of {name} do you still have? (system estimate: {qty})', + 'de': 'Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})', + 'fr': 'Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})', + 'es': '¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})', + }, + 'time.just_now': { + 'it': 'adesso', 'en': 'just now', 'de': 'gerade eben', 'fr': 'à l\'instant', 'es': 'ahora', + }, + 'time.seconds_ago': { + 'it': '{n}s fa', 'en': '{n}s ago', 'de': 'vor {n}s', 'fr': 'il y a {n}s', 'es': 'hace {n}s', + }, + 'time.minutes_ago': { + 'it': '{n} min fa', 'en': '{n} min ago', 'de': 'vor {n} min', 'fr': 'il y a {n} min', 'es': 'hace {n} min', + }, + 'time.hours_ago': { + 'it': '{n} h fa', 'en': '{n} h ago', 'de': 'vor {n} h', 'fr': 'il y a {n} h', 'es': 'hace {n} h', + }, + 'time.days_ago': { + 'it': '{n} gg fa', 'en': '{n} d ago', 'de': 'vor {n} T', 'fr': 'il y a {n} j', 'es': 'hace {n} d', + }, + 'use.locations_short': { + 'it': 'posti', 'en': 'places', 'de': 'Orte', 'fr': 'emplacements', 'es': 'ubicaciones', + }, + 'move.moved_simple': { + 'it': '📦 Spostato in {location}', + 'en': '📦 Moved to {location}', + 'de': '📦 Nach {location} verschoben', + 'fr': '📦 Déplacé vers {location}', + 'es': '📦 Movido a {location}', + }, + 'product.history_badge': { + 'it': '📊 storico', 'en': '📊 history', 'de': '📊 Verlauf', 'fr': '📊 historique', 'es': '📊 historial', + }, + 'ai.conservation_hint': { + 'it': '🤖 AI: conserva in {location}', + 'en': '🤖 AI: store in {location}', + 'de': '🤖 KI: lagere in {location}', + 'fr': '🤖 IA : conserve dans {location}', + 'es': '🤖 IA: conserva en {location}', + }, + 'settings.kiosk_update_required': { + 'it': '⚠️ Aggiorna il kiosk per usare questa funzione', + 'en': '⚠️ Update the kiosk app to use this feature', + 'de': '⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen', + 'fr': '⚠️ Mettez à jour l\'application kiosk pour utiliser cette fonction', + 'es': '⚠️ Actualiza la app kiosk para usar esta función', + }, + 'shopping.bring_names_migrated': { + 'it': '🔄 {n} nomi generalizzati in Bring!', + 'en': '🔄 {n} names generalized in Bring!', + 'de': '🔄 {n} Namen in Bring! verallgemeinert', + 'fr': '🔄 {n} noms généralisés dans Bring !', + 'es': '🔄 {n} nombres generalizados en Bring!', + }, + 'scan.mode_shopping_activated': { + 'it': '🛒 Modalità Spesa attivata!', + 'en': '🛒 Shopping mode activated!', + 'de': '🛒 Einkaufsmodus aktiviert!', + 'fr': '🛒 Mode courses activé !', + 'es': '🛒 ¡Modo compras activado!', + }, + 'settings.scale.discover_scanning': { + 'it': '🔍 Scansione rete locale per gateway bilancia…', + 'en': '🔍 Scanning local network for scale gateway…', + 'de': '🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…', + 'fr': '🔍 Recherche du gateway balance sur le réseau local…', + 'es': '🔍 Buscando pasarela de báscula en la red local…', + }, + 'settings.scale.discover_found': { + 'it': '✅ Gateway trovato: {url}{more}', + 'en': '✅ Gateway found: {url}{more}', + 'de': '✅ Gateway gefunden: {url}{more}', + 'fr': '✅ Gateway trouvé : {url}{more}', + 'es': '✅ Pasarela encontrada: {url}{more}', + }, + 'settings.scale.discover_not_found': { + 'it': '❌ Nessun gateway su {subnet}. Avvia l\'app Android sulla stessa Wi-Fi.', + 'en': '❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.', + 'de': '❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.', + 'fr': '❌ Aucun gateway sur {subnet}. Lancez l\'app Android sur le même Wi-Fi.', + 'es': '❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.', + }, + 'settings.scale.discover_failed': { + 'it': '❌ Ricerca fallita: {error}', + 'en': '❌ Discovery failed: {error}', + 'de': '❌ Suche fehlgeschlagen: {error}', + 'fr': '❌ Échec de la recherche : {error}', + 'es': '❌ Búsqueda fallida: {error}', + }, + 'settings.scale.discover_auto': { + 'it': '🔍 Auto', 'en': '🔍 Auto', 'de': '🔍 Auto', 'fr': '🔍 Auto', 'es': '🔍 Auto', + }, + 'settings.scale.unknown_device': { + 'it': 'Dispositivo sconosciuto', + 'en': 'Unknown device', + 'de': 'Unbekanntes Gerät', + 'fr': 'Appareil inconnu', + 'es': 'Dispositivo desconocido', + }, + 'product.from_history': { + 'it': ' (da storico)', 'en': ' (from history)', 'de': ' (aus Verlauf)', 'fr': ' (historique)', 'es': ' (del historial)', + }, + 'recipes.ing_stock_line': { + 'it': 'Hai {have} · restano {remain} dopo l\'uso', + 'en': 'You have {have} · {remain} left after use', + 'de': 'Du hast {have} · {remain} bleiben nach Gebrauch', + 'fr': 'Vous avez {have} · il reste {remain} après usage', + 'es': 'Tienes {have} · quedan {remain} después del uso', + }, + 'recipes.ing_use_all_note': { + 'it': 'uso totale (<5% della confezione intera)', + 'en': 'use all (<5% of full package left)', + 'de': 'alles verwenden (<5% der Vollpackung)', + 'fr': 'tout utiliser (<5% du conditionnement entier)', + 'es': 'usar todo (<5% del envase completo)', + }, +} + +# fr/es gaps filled with proper translations (flat key -> value) +FR_FILL: dict[str, str] = { + 'action.related_stock_title': 'Aussi à la maison', + 'dashboard.banner_expired_action_modify': 'Modifier', + 'dashboard.banner_expired_action_vacuum': 'Mettre sous vide', + 'recipes.stream_interrupted': 'Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.', + 'scan.stock_in_pantry': 'Déjà à la maison :', + 'scanner.expiry_found': 'Date trouvée', + 'scanner.expiry_raw_label': 'Lu', + 'scanner.expiry_read_fail': 'Impossible de lire la date.', + 'settings.info.act_new_products': 'Nouveaux produits', + 'settings.info.act_restock': 'Réapprovisionnements', + 'settings.info.act_title': 'Activité mensuelle', + 'settings.info.act_tx_month': 'Mouvements', + 'settings.info.act_tx_year': 'Mouvements annuels', + 'settings.info.act_use': 'Utilisations', + 'settings.info.ai_calls': 'Appels', + 'settings.info.ai_hint': 'Consommation mensuelle et coût estimé pour la clé API actuelle.', + 'settings.info.ai_overview': 'Aperçu IA, inventaire et état du système', + 'settings.info.ai_title': 'Gemini AI — Utilisation des tokens', + 'settings.info.bring_days': 'jeton expire dans {n} jours', + 'settings.info.bring_expired': 'jeton expiré', + 'settings.info.by_action': 'Répartition par fonction', + 'settings.info.by_model': 'Répartition par modèle', + 'settings.info.cache_entries': 'produits', + 'settings.info.calls_unit': 'appels', + 'settings.info.currency_hint': 'Devise utilisée pour tous les coûts et prix dans l\'app.', + 'settings.info.currency_title': 'Devise', + 'settings.info.db_size': 'Base de données', + 'settings.info.est_cost': 'Coût est.', + 'settings.info.input_tok': 'Tokens entrée', + 'settings.info.inv_active': 'Actifs', + 'settings.info.inv_expired': 'Expirés', + 'settings.info.inv_expiring': 'Expirent (7j)', + 'settings.info.inv_finished': 'Terminés', + 'settings.info.inv_products': 'Produits totaux', + 'settings.info.inv_title': 'Inventaire', + 'settings.info.last_backup': 'Dernière sauvegarde', + 'settings.info.loading': 'Chargement…', + 'settings.info.log_level': 'Niveau de log', + 'settings.info.log_size': 'Logs', + 'settings.info.output_tok': 'Tokens sortie', + 'settings.info.price_cache': 'Cache prix', + 'settings.info.pricing_note': 'Tarifs Gemini : 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.', + 'settings.info.system_title': 'Système', + 'settings.info.tab': 'Info', + 'settings.info.total_tokens': 'Tokens totaux', + 'settings.info.year_label': 'Année {year}', + 'settings.tab_general': 'Général', + 'settings.tts.test_sound_btn': '🔔 Test sonore', + 'shopping.pantry_hint': 'Déjà à la maison : {qty}', + 'startup.check_db_legacy': 'Ancienne BD (dispensa.db)', + 'startup.check_scale': 'Passerelle balance', + 'startup.check_tts': 'URL synthèse vocale', + 'startup.critical_error_intro': 'L\'application ne peut pas démarrer en raison des problèmes suivants :', + 'startup.error_network_detail': 'Le navigateur ne peut pas joindre le serveur PHP.\n\nCauses possibles :\n• Apache/PHP n\'est pas démarré\n• Problème réseau ou pare-feu\n• URL incorrecte\n\nDémarrez le serveur et réessayez.', + 'toast.vacuum_sealed': '{name} enregistré sous vide', +} + +ES_FILL = { + 'action.related_stock_title': 'También en casa', + 'dashboard.banner_expired_action_modify': 'Editar', + 'dashboard.banner_expired_action_vacuum': 'Poner al vacío', + 'recipes.stream_interrupted': 'Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.', + 'scan.stock_in_pantry': 'Ya en despensa:', + 'scanner.expiry_found': 'Fecha encontrada', + 'scanner.expiry_raw_label': 'Leído', + 'scanner.expiry_read_fail': 'No se puede leer la fecha.', + 'settings.info.act_new_products': 'Productos nuevos', + 'settings.info.act_restock': 'Reabastecimientos', + 'settings.info.act_title': 'Actividad mensual', + 'settings.info.act_tx_month': 'Movimientos', + 'settings.info.act_tx_year': 'Movimientos anuales', + 'settings.info.act_use': 'Usos', + 'settings.info.ai_calls': 'Llamadas', + 'settings.info.ai_hint': 'Consumo mensual y coste estimado para la clave API actual.', + 'settings.info.ai_overview': 'Resumen de IA, inventario y estado del sistema', + 'settings.info.ai_title': 'Gemini AI — Uso de tokens', + 'settings.info.bring_days': 'token expira en {n} días', + 'settings.info.bring_expired': 'token expirado', + 'settings.info.by_action': 'Desglose por función', + 'settings.info.by_model': 'Desglose por modelo', + 'settings.info.cache_entries': 'productos', + 'settings.info.calls_unit': 'llamadas', + 'settings.info.currency_hint': 'Moneda usada para todos los costes y precios en la app.', + 'settings.info.currency_title': 'Moneda', + 'settings.info.db_size': 'Base de datos', + 'settings.info.est_cost': 'Coste est.', + 'settings.info.input_tok': 'Tokens de entrada', + 'settings.info.inv_active': 'Activos', + 'settings.info.inv_expired': 'Caducados', + 'settings.info.inv_expiring': 'Caducan (7d)', + 'settings.info.inv_finished': 'Agotados', + 'settings.info.inv_products': 'Productos totales', + 'settings.info.inv_title': 'Inventario', + 'settings.info.last_backup': 'Última copia', + 'settings.info.loading': 'Cargando…', + 'settings.info.log_level': 'Nivel de log', + 'settings.info.log_size': 'Logs', + 'settings.info.output_tok': 'Tokens de salida', + 'settings.info.price_cache': 'Caché de precios', + 'settings.info.pricing_note': 'Precios Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.', + 'settings.info.system_title': 'Sistema', + 'settings.info.tab': 'Info', + 'settings.info.total_tokens': 'Tokens totales', + 'settings.info.year_label': 'Año {year}', + 'settings.tab_general': 'General', + 'settings.tts.test_sound_btn': '🔔 Prueba de sonido', + 'shopping.pantry_hint': 'Ya en casa: {qty}', + 'startup.check_db_legacy': 'BD antigua (dispensa.db)', + 'startup.check_scale': 'Pasarela báscula', + 'startup.check_tts': 'URL texto a voz', + 'startup.critical_error_intro': 'La app no puede iniciarse por los siguientes problemas:', + 'startup.error_network_detail': 'El navegador no puede conectar con el servidor PHP.\n\nPosibles causas:\n• Apache/PHP no está en ejecución\n• Problema de red o firewall\n• URL incorrecta\n\nInicia el servidor e inténtalo de nuevo.', + 'toast.vacuum_sealed': '{name} guardado al vacío', +} + + +def flatten(obj: dict, prefix: str = '') -> dict[str, str]: + out: dict[str, str] = {} + for k, v in obj.items(): + key = f'{prefix}.{k}' if prefix else k + if isinstance(v, dict): + out.update(flatten(v, key)) + else: + out[key] = v + return out + + +def set_nested(root: dict, dotted: str, value: str) -> None: + parts = dotted.split('.') + d = root + for p in parts[:-1]: + d = d.setdefault(p, {}) + d[parts[-1]] = value + + +def main() -> None: + ref = json.loads((ROOT / REF).read_text(encoding='utf-8')) + ref_flat = flatten(ref) + en_flat = flatten(json.loads((ROOT / 'en.json').read_text(encoding='utf-8'))) + + for fname in LOCALES: + lang = fname.replace('.json', '') + path = ROOT / fname + data = json.loads(path.read_text(encoding='utf-8')) + flat = flatten(data) + + # Fill missing keys from reference (Italian text as last resort via en) + for key, ref_val in ref_flat.items(): + if key not in flat: + if lang == 'fr' and key in FR_FILL: + val = FR_FILL[key] + elif lang == 'es' and key in ES_FILL: + val = ES_FILL[key] + elif lang == 'en': + val = en_flat.get(key, ref_val) + else: + val = en_flat.get(key, ref_val) + set_nested(data, key, val) + flat[key] = val + + # Inject new keys + for key, per_lang in NEW_KEYS.items(): + set_nested(data, key, per_lang[lang if lang in per_lang else 'en']) + + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + print(f'Updated {fname}') + + +if __name__ == '__main__': + main() diff --git a/translations/de.json b/translations/de.json index ec327be..a18f78e 100644 --- a/translations/de.json +++ b/translations/de.json @@ -143,8 +143,10 @@ "banner_prediction_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.", "banner_prediction_less": "Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}. Wenn sich dein Verbrauch geändert hat, aktualisiert sich die Prognose automatisch.", "banner_finished_zero": "Bestand zeigt null, aber gespeicherte Buchungen deuten an, dass es nicht leer sein sollte.", + "banner_finished_vanished": "Das Produkt erscheint nicht mehr im Bestand, aber die Buchungen deuten an, dass es nicht leer sein sollte.", "banner_finished_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.", "banner_finished_check": "Kannst du nachschauen?", + "banner_finished_action_restore": "{qty} {unit} wiederherstellen", "banner_anomaly_phantom_title": "mehr Bestand als erwartet", "banner_anomaly_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?", "banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht", @@ -164,7 +166,11 @@ "banner_opened_detail": "{when} in {location} · du hast noch {qty}.", "banner_explain_title": "Gemini um eine Erklärung bitten", "banner_explain_btn": "Erklären", - "banner_analyzing": "🤖 Analysiere…" + "banner_analyzing": "🤖 Analysiere…", + "banner_prediction_confirmed": "✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet", + "banner_anomaly_explain_fail": "KI-Erklärung konnte nicht abgerufen werden", + "banner_anomaly_dismissed": "Anomalie ignoriert", + "banner_finished_restore_prompt": "Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})" }, "inventory": { "title": "Vorrat", @@ -243,7 +249,8 @@ "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" + "ai_detected_label": "KI erkannt", + "mode_shopping_activated": "🛒 Einkaufsmodus aktiviert!" }, "action": { "title": "Was möchtest du tun?", @@ -316,14 +323,17 @@ "toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt", "toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!", "disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?", + "disambiguation_one_conf": "1 Packung aufgebraucht ({qty})", "disambiguation_all": "🗑️ ALLES verbraucht ({qty})", + "toast_one_conf_finished": "📦 1 Packung von {name} verbraucht!", "error_exceeds_stock": "⚠️ Du kannst nicht mehr verwenden als du verfügbar hast!", "use_all_confirm_title": "✅ Alles aufbrauchen", "use_all_confirm_msg": "Bestätige, dass du das Produkt vollständig aufgebraucht hast:", "use_all_confirm_btn": "✅ Ja, aufgebraucht", "throw_all_confirm_title": "🗑️ Alles entsorgen", "throw_all_confirm_msg": "Möchtest du wirklich das gesamte Produkt entsorgen?", - "throw_all_confirm_btn": "🗑️ Ja, entsorgen" + "throw_all_confirm_btn": "🗑️ Ja, entsorgen", + "locations_short": "Orte" }, "product": { "title_new": "Neues Produkt", @@ -363,7 +373,9 @@ "weight_label": "Gewicht", "origin_label": "Herkunft", "labels_label": "Etiketten", - "select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:" + "select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:", + "history_badge": "📊 Verlauf", + "from_history": " (aus Verlauf)" }, "products": { "title": "📦 Alle Produkte", @@ -425,7 +437,9 @@ "nutrition_per_serving": "Geschätzte Werte pro Portion", "storage_title": "Aufbewahrung von Resten", "storage_days": "{n} Tage", - "storage_immediately": "Am besten sofort verzehren" + "storage_immediately": "Am besten sofort verzehren", + "ing_stock_line": "Du hast {have} · {remain} bleiben nach Gebrauch", + "ing_use_all_note": "alles verwenden (<5% der Vollpackung)" }, "shopping": { "title": "🛒 Einkaufsliste", @@ -512,6 +526,7 @@ "remove_error": "Fehler beim Entfernen", "btn_fetch_prices": "Preise suchen", "price_total_label": "💰 Geschätzter Gesamtpreis:", + "price_total_short": "geschätzte Ausgaben", "price_loading": "Preise werden gesucht…", "price_not_found": "Preis n/v", "suggest_loading": "Analyse läuft...", @@ -521,7 +536,8 @@ "priority_low": "Niedrig", "smart_last_update": "Aktualisiert {time}", "names_already_updated": "Alle Namen sind bereits aktuell", - "pantry_hint": "Bereits zuhause: {qty}" + "pantry_hint": "Bereits zuhause: {qty}", + "bring_names_migrated": "🔄 {n} Namen in Bring! verallgemeinert" }, "ai": { "title": "🤖 KI-Identifikation", @@ -532,7 +548,8 @@ "no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\nFüge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.", "fields_filled": "✅ Felder von KI ausgefüllt", "use_data": "✅ KI-Daten verwenden", - "use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)" + "use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)", + "conservation_hint": "🤖 KI: lagere in {location}" }, "log": { "title": "📒 Verlauf", @@ -787,7 +804,13 @@ "kiosk_title": "📡 BLE-Waage im Kiosk integriert", "kiosk_hint": "Die Waage wird direkt vom internen BLE-Gateway des Kiosks verwaltet. Um ein neues Gerät zu koppeln, verwende den Konfigurationsassistenten.", "kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren", - "ble_protocols": "

🔌 Unterstützte BLE-Protokolle:

  • Bluetooth SIG Weight Scale (0x181D)
  • Bluetooth SIG Body Composition (0x181B) — Gewicht, Fett, BMI
  • Xiaomi Mi Body Composition Scale 2
  • Generisch — automatische Heuristik für 100+ Modelle
" + "ble_protocols": "

🔌 Unterstützte BLE-Protokolle:

  • Bluetooth SIG Weight Scale (0x181D)
  • Bluetooth SIG Body Composition (0x181B) — Gewicht, Fett, BMI
  • Xiaomi Mi Body Composition Scale 2
  • Generisch — automatische Heuristik für 100+ Modelle
", + "discover_scanning": "🔍 Lokales Netz wird nach Waagen-Gateway durchsucht…", + "discover_found": "✅ Gateway gefunden: {url}{more}", + "discover_not_found": "❌ Kein Gateway in {subnet}. Android-App auf demselben WLAN starten.", + "discover_failed": "❌ Suche fehlgeschlagen: {error}", + "discover_auto": "🔍 Auto", + "unknown_device": "Unbekanntes Gerät" }, "kiosk": { "hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.", @@ -974,7 +997,8 @@ "sensor_copied": "YAML in die Zwischenablage kopiert!", "save_btn": "HA-Einstellungen speichern", "ha_hint": "Wenn du Home Assistant verwendest, nutze den Home Assistant-Tab für TTS, Webhooks und Sensoren." - } + }, + "kiosk_update_required": "⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen" }, "expiry": { "today": "HEUTE", @@ -1048,6 +1072,7 @@ "finished_all": "📤 {name} aufgebraucht!", "vacuum_sealed": "{name} als vakuumversiegelt gespeichert", "product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst", + "ghost_restored": "✅ {name}: {qty} {unit} im Bestand wiederhergestellt", "appliance_added": "Gerät hinzugefügt", "item_added": "{name} hinzugefügt" }, @@ -1119,7 +1144,9 @@ "offline_ops_pending": "{n} Aktionen ausstehend", "offline_synced": "{n} Aktionen synchronisiert", "offline_ai_disabled": "Offline nicht verfügbar", - "offline_cache_ready": "Offline — {n} Produkte im Cache" + "offline_cache_ready": "Offline — {n} Produkte im Cache", + "copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen", + "invalid_quantity": "Ungültige Menge" }, "confirm_placeholder_search": null, "confirm": { @@ -1241,7 +1268,8 @@ "stay_btn": "Nein, bleibt in {location}", "moved_toast": "📦 Offene Packung bewegt nach {location}", "vacuum_restore": "Vakuum wiederherstellen", - "vacuum_seal_rest": "Rest vakuumieren" + "vacuum_seal_rest": "Rest vakuumieren", + "moved_simple": "📦 Nach {location} verschoben" }, "nova": { "1": "Unverarbeitet", @@ -1494,5 +1522,12 @@ "top_used": "meistbenutzt", "top_cats": "Hauptkategorien", "source": "Transaktionsverlauf · aktueller Monat" + }, + "time": { + "just_now": "gerade eben", + "seconds_ago": "vor {n}s", + "minutes_ago": "vor {n} min", + "hours_ago": "vor {n} h", + "days_ago": "vor {n} T" } -} \ No newline at end of file +} diff --git a/translations/en.json b/translations/en.json index 9087b75..a804434 100644 --- a/translations/en.json +++ b/translations/en.json @@ -143,8 +143,10 @@ "banner_prediction_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.", "banner_prediction_less": "estimate: {expected} {unit}{time}; current quantity: {actual} {unit}. If your usage pace changed, the forecast updates automatically.", "banner_finished_zero": "Inventory shows zero, but recorded movements suggest it shouldn't be empty.", + "banner_finished_vanished": "This product no longer appears in inventory, but recorded movements suggest it shouldn't be empty.", "banner_finished_expected": "According to records you should still have {qty} {unit}.", "banner_finished_check": "Can you check?", + "banner_finished_action_restore": "Restore {qty} {unit}", "banner_anomaly_phantom_title": "you have more stock than expected", "banner_anomaly_phantom_detail": "Inventory shows {inv_qty} {unit}, but based on records you should only have {expected_qty} {unit}. Did you add stock without recording it?", "banner_anomaly_untracked_title": "stock not recorded as an entry", @@ -164,7 +166,11 @@ "banner_opened_detail": "{when} in {location} · you still have {qty}.", "banner_explain_title": "Ask Gemini for an explanation", "banner_explain_btn": "Explain", - "banner_analyzing": "🤖 Analyzing…" + "banner_analyzing": "🤖 Analyzing…", + "banner_prediction_confirmed": "✅ Confirmed — forecasts will recalculate from your next entries", + "banner_anomaly_explain_fail": "Could not get AI explanation", + "banner_anomaly_dismissed": "Anomaly dismissed", + "banner_finished_restore_prompt": "How many {unit} of {name} do you still have? (system estimate: {qty})" }, "inventory": { "title": "Pantry", @@ -243,7 +249,8 @@ "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" + "ai_detected_label": "AI detected", + "mode_shopping_activated": "🛒 Shopping mode activated!" }, "action": { "title": "What do you want to do?", @@ -316,14 +323,17 @@ "toast_bring": "🛒 Product finished → added to Bring!", "toast_opened_finished": "🔓 Opened package of {name} finished!", "disambiguation_hint": "What do you mean by \"all done\"?", + "disambiguation_one_conf": "Finished 1 package ({qty})", "disambiguation_all": "🗑️ Finish EVERYTHING ({qty})", + "toast_one_conf_finished": "📦 1 package of {name} finished!", "error_exceeds_stock": "⚠️ You cannot use more than you have available!", "use_all_confirm_title": "✅ Finish everything", "use_all_confirm_msg": "Confirm that you have finished the product:", "use_all_confirm_btn": "✅ Yes, finished", "throw_all_confirm_title": "🗑️ Discard everything", "throw_all_confirm_msg": "Do you really want to throw away the whole product?", - "throw_all_confirm_btn": "🗑️ Yes, discard" + "throw_all_confirm_btn": "🗑️ Yes, discard", + "locations_short": "places" }, "product": { "title_new": "New Product", @@ -363,7 +373,9 @@ "weight_label": "Weight", "origin_label": "Origin", "labels_label": "Labels", - "select_variant": "Select the exact variant or use AI data:" + "select_variant": "Select the exact variant or use AI data:", + "history_badge": "📊 history", + "from_history": " (from history)" }, "products": { "title": "📦 All Products", @@ -425,7 +437,9 @@ "nutrition_per_serving": "Estimated values per serving", "storage_title": "How to store leftovers", "storage_days": "{n} days", - "storage_immediately": "Best eaten immediately" + "storage_immediately": "Best eaten immediately", + "ing_stock_line": "You have {have} · {remain} left after use", + "ing_use_all_note": "use all (<5% of full package left)" }, "shopping": { "title": "🛒 Shopping List", @@ -512,6 +526,7 @@ "remove_error": "Removal error", "btn_fetch_prices": "Find prices", "price_total_label": "💰 Estimated total:", + "price_total_short": "estimated total", "price_loading": "Looking up prices…", "price_not_found": "price n/a", "suggest_loading": "Analyzing...", @@ -521,7 +536,8 @@ "priority_low": "Low", "smart_last_update": "Updated {time}", "names_already_updated": "All names are already up to date", - "pantry_hint": "Already at home: {qty}" + "pantry_hint": "Already at home: {qty}", + "bring_names_migrated": "🔄 {n} names generalized in Bring!" }, "ai": { "title": "🤖 AI Identification", @@ -532,7 +548,8 @@ "no_api_key": "⚠️ Gemini API key not configured.\nAdd GEMINI_API_KEY to the .env file on the server.", "fields_filled": "✅ Fields filled by AI", "use_data": "✅ Use AI data", - "use_data_no_barcode": "✅ Use AI data (no barcode)" + "use_data_no_barcode": "✅ Use AI data (no barcode)", + "conservation_hint": "🤖 AI: store in {location}" }, "log": { "title": "📒 Operations Log", @@ -787,7 +804,13 @@ "kiosk_title": "📡 BLE Scale integrated in Kiosk", "kiosk_hint": "The scale is directly managed by the internal BLE Gateway of the kiosk. To pair a new device, use the configuration wizard.", "kiosk_reconfigure": "🔄 Reconfigure BLE Scale", - "ble_protocols": "

🔌 Supported BLE protocols:

  • Bluetooth SIG Weight Scale (0x181D)
  • Bluetooth SIG Body Composition (0x181B) — weight, fat, BMI
  • Xiaomi Mi Body Composition Scale 2
  • Generic — automatic heuristic for 100+ models
" + "ble_protocols": "

🔌 Supported BLE protocols:

  • Bluetooth SIG Weight Scale (0x181D)
  • Bluetooth SIG Body Composition (0x181B) — weight, fat, BMI
  • Xiaomi Mi Body Composition Scale 2
  • Generic — automatic heuristic for 100+ models
", + "discover_scanning": "🔍 Scanning local network for scale gateway…", + "discover_found": "✅ Gateway found: {url}{more}", + "discover_not_found": "❌ No gateway found on {subnet}. Make sure the Android app is running and on the same Wi-Fi.", + "discover_failed": "❌ Discovery failed: {error}", + "discover_auto": "🔍 Auto", + "unknown_device": "Unknown device" }, "kiosk": { "hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.", @@ -974,7 +997,8 @@ "sensor_copied": "YAML copied to clipboard!", "save_btn": "Save HA settings", "ha_hint": "If you use Home Assistant, use the Home Assistant tab to configure TTS, webhooks and sensors." - } + }, + "kiosk_update_required": "⚠️ Update the kiosk app to use this feature" }, "expiry": { "today": "TODAY", @@ -1048,6 +1072,7 @@ "finished_all": "📤 {name} finished!", "vacuum_sealed": "{name} saved as vacuum sealed", "product_finished_confirmed": "✅ Removed — add it again when you restock", + "ghost_restored": "✅ {name}: restored {qty} {unit} to inventory", "appliance_added": "Appliance added", "item_added": "{name} added" }, @@ -1119,7 +1144,9 @@ "offline_ops_pending": "{n} operations pending", "offline_synced": "{n} operations synced", "offline_ai_disabled": "Not available offline", - "offline_cache_ready": "Offline — {n} items cached" + "offline_cache_ready": "Offline — {n} items cached", + "copy_failed": "Copy to clipboard failed", + "invalid_quantity": "Invalid quantity" }, "confirm_placeholder_search": null, "confirm": { @@ -1241,7 +1268,8 @@ "stay_btn": "No, stay in {location}", "moved_toast": "📦 Opened package moved to {location}", "vacuum_restore": "Restore vacuum sealed", - "vacuum_seal_rest": "Vacuum seal the rest" + "vacuum_seal_rest": "Vacuum seal the rest", + "moved_simple": "📦 Moved to {location}" }, "nova": { "1": "Unprocessed", @@ -1494,5 +1522,12 @@ "top_used": "top used", "top_cats": "Top categories", "source": "Transaction history · current month" + }, + "time": { + "just_now": "just now", + "seconds_ago": "{n}s ago", + "minutes_ago": "{n} min ago", + "hours_ago": "{n} h ago", + "days_ago": "{n} d ago" } -} \ No newline at end of file +} diff --git a/translations/es.json b/translations/es.json index 229afad..c2a4a62 100644 --- a/translations/es.json +++ b/translations/es.json @@ -141,8 +141,10 @@ "banner_prediction_more": "estimación anterior: {expected} {unit}{time}; cantidad actual: {actual} {unit}.", "banner_prediction_less": "estimación: {expected} {unit}{time}; cantidad actual: {actual} {unit}. Si tu ritmo de uso cambió, la previsión se actualiza automáticamente.", "banner_finished_zero": "El inventario muestra cero, pero los movimientos registrados sugieren que no debería estar vacío.", + "banner_finished_vanished": "Este producto ya no aparece en el inventario, pero los movimientos registrados sugieren que no debería estar vacío.", "banner_finished_expected": "Según los registros deberías tener todavía {qty} {unit}.", "banner_finished_check": "¿Puedes comprobarlo?", + "banner_finished_action_restore": "Restaurar {qty} {unit}", "banner_anomaly_phantom_title": "tienes más stock del esperado", "banner_anomaly_phantom_detail": "El inventario indica {inv_qty} {unit}, pero según los registros solo deberías tener {expected_qty} {unit}. ¿Añadiste stock sin registrarlo?", "banner_anomaly_untracked_title": "stock no registrado como entrada", @@ -162,7 +164,13 @@ "banner_opened_detail": "{when} en {location} · aún tienes {qty}.", "banner_explain_title": "Pedir explicación a Gemini", "banner_explain_btn": "Explicar", - "banner_analyzing": "🤖 Analizando…" + "banner_analyzing": "🤖 Analizando…", + "banner_expired_action_modify": "Editar", + "banner_expired_action_vacuum": "Poner al vacío", + "banner_prediction_confirmed": "✅ Confirmado — las previsiones se recalcularán con tus próximos registros", + "banner_anomaly_explain_fail": "No se pudo obtener la explicación de IA", + "banner_anomaly_dismissed": "Anomalía descartada", + "banner_finished_restore_prompt": "¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})" }, "inventory": { "title": "Despensa", @@ -240,7 +248,9 @@ "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" + "ai_detected_label": "IA detecto", + "stock_in_pantry": "Ya en despensa:", + "mode_shopping_activated": "🛒 ¡Modo compras activado!" }, "action": { "title": "¿Qué quieres hacer?", @@ -254,7 +264,8 @@ "throw_btn": "🗑️ DESECHAR", "throw_sub": "tirar", "edit_sub": "caducidad, ubicación…", - "create_recipe_btn": "Receta" + "create_recipe_btn": "Receta", + "related_stock_title": "También en casa" }, "add": { "title": "Añadir a la despensa", @@ -312,14 +323,17 @@ "toast_bring": "🛒 Producto terminado → añadido a Bring!", "toast_opened_finished": "🔓 ¡Paquete abierto de {name} terminado!", "disambiguation_hint": "¿Qué quieres decir con «todo terminado»?", + "disambiguation_one_conf": "Terminado 1 envase ({qty})", "disambiguation_all": "🗑️ Terminar TODO ({qty})", + "toast_one_conf_finished": "📦 1 envase de {name} terminado!", "error_exceeds_stock": "⚠️ ¡No puedes usar más de lo que tienes disponible!", "use_all_confirm_title": "✅ Terminar todo", "use_all_confirm_msg": "Confirma que has terminado el producto:", "use_all_confirm_btn": "✅ Sí, terminado", "throw_all_confirm_title": "🗑️ Desechar todo", "throw_all_confirm_msg": "¿Realmente quieres tirar todo el producto?", - "throw_all_confirm_btn": "🗑️ Sí, desechar" + "throw_all_confirm_btn": "🗑️ Sí, desechar", + "locations_short": "ubicaciones" }, "product": { "title_new": "Nuevo producto", @@ -359,7 +373,9 @@ "weight_label": "Peso", "origin_label": "Origen", "labels_label": "Etiquetas", - "select_variant": "Selecciona la variante exacta o usa los datos de IA:" + "select_variant": "Selecciona la variante exacta o usa los datos de IA:", + "history_badge": "📊 historial", + "from_history": " (del historial)" }, "products": { "title": "📦 Todos los productos", @@ -420,7 +436,10 @@ "nutrition_per_serving": "Valores estimados por ración", "storage_title": "Cómo conservar las sobras", "storage_days": "{n} días", - "storage_immediately": "Mejor consumir de inmediato" + "storage_immediately": "Mejor consumir de inmediato", + "stream_interrupted": "Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.", + "ing_stock_line": "Tienes {have} · quedan {remain} después del uso", + "ing_use_all_note": "usar todo (<5% del envase completo)" }, "shopping": { "title": "🛒 Lista de la compra", @@ -507,6 +526,7 @@ "remove_error": "Error al eliminar", "btn_fetch_prices": "Buscar precios", "price_total_label": "💰 Total estimado:", + "price_total_short": "total estimado", "price_loading": "Buscando precios…", "price_not_found": "precio n/d", "suggest_loading": "Analizando...", @@ -515,7 +535,9 @@ "priority_medium": "Media", "priority_low": "Baja", "smart_last_update": "Actualizado {time}", - "names_already_updated": "Todos los nombres ya están actualizados" + "names_already_updated": "Todos los nombres ya están actualizados", + "pantry_hint": "Ya en casa: {qty}", + "bring_names_migrated": "🔄 {n} nombres generalizados en Bring!" }, "ai": { "title": "🤖 Identificación IA", @@ -526,7 +548,8 @@ "no_api_key": "⚠️ Clave API de Gemini no configurada.\nAñade GEMINI_API_KEY al archivo .env en el servidor.", "fields_filled": "✅ Campos rellenados por IA", "use_data": "✅ Usar datos de IA", - "use_data_no_barcode": "✅ Usar datos de IA (sin código de barras)" + "use_data_no_barcode": "✅ Usar datos de IA (sin código de barras)", + "conservation_hint": "🤖 IA: conserva en {location}" }, "log": { "title": "📒 Registro de operaciones", @@ -742,7 +765,8 @@ "heard_yes": "Sí, la escuché", "heard_no": "No, no escuché nada", "test_ok_kiosk": "TTS funcionando.", - "test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android." + "test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android.", + "test_sound_btn": "🔔 Prueba de sonido" }, "language": { "title": "🌐 Idioma", @@ -780,7 +804,13 @@ "kiosk_title": "📡 Báscula BLE integrada en el kiosco", "kiosk_hint": "La báscula está gestionada directamente por la pasarela BLE interna del kiosco. Para vincular un nuevo dispositivo, usa el asistente de configuración.", "kiosk_reconfigure": "🔄 Reconfigurar báscula BLE", - "ble_protocols": "

🔌 Protocolos BLE soportados:

  • Bluetooth SIG Weight Scale (0x181D)
  • Bluetooth SIG Body Composition (0x181B) — peso, grasa, IMC
  • Xiaomi Mi Body Composition Scale 2
  • Genérico — heurística automática para 100+ modelos
" + "ble_protocols": "

🔌 Protocolos BLE soportados:

  • Bluetooth SIG Weight Scale (0x181D)
  • Bluetooth SIG Body Composition (0x181B) — peso, grasa, IMC
  • Xiaomi Mi Body Composition Scale 2
  • Genérico — heurística automática para 100+ modelos
", + "discover_scanning": "🔍 Buscando pasarela de báscula en la red local…", + "discover_found": "✅ Pasarela encontrada: {url}{more}", + "discover_not_found": "❌ Ninguna pasarela en {subnet}. Inicia la app Android en la misma Wi-Fi.", + "discover_failed": "❌ Búsqueda fallida: {error}", + "discover_auto": "🔍 Auto", + "unknown_device": "Dispositivo desconocido" }, "kiosk": { "hint": "Convierte una tableta Android en un panel EverShelf permanente con pasarela BLE integrada.", @@ -926,7 +956,49 @@ "sensor_copied": "¡YAML copiado al portapapeles!", "save_btn": "Guardar ajustes HA", "ha_hint": "Si usas Home Assistant, utiliza la pestaña Home Assistant para configurar TTS, webhooks y sensores." - } + }, + "info": { + "tab": "Info", + "ai_title": "Gemini AI — Uso de tokens", + "ai_hint": "Consumo mensual y coste estimado para la clave API actual.", + "loading": "Cargando…", + "total_tokens": "Tokens totales", + "est_cost": "Coste est.", + "input_tok": "Tokens de entrada", + "output_tok": "Tokens de salida", + "ai_calls": "Llamadas", + "by_action": "Desglose por función", + "by_model": "Desglose por modelo", + "pricing_note": "Precios Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.", + "system_title": "Sistema", + "db_size": "Base de datos", + "log_size": "Logs", + "log_level": "Nivel de log", + "ai_overview": "Resumen de IA, inventario y estado del sistema", + "calls_unit": "llamadas", + "inv_title": "Inventario", + "inv_active": "Activos", + "inv_products": "Productos totales", + "inv_expiring": "Caducan (7d)", + "inv_expired": "Caducados", + "inv_finished": "Agotados", + "act_title": "Actividad mensual", + "act_tx_month": "Movimientos", + "act_restock": "Reabastecimientos", + "act_use": "Usos", + "act_new_products": "Productos nuevos", + "act_tx_year": "Movimientos anuales", + "price_cache": "Caché de precios", + "cache_entries": "productos", + "last_backup": "Última copia", + "bring_days": "token expira en {n} días", + "bring_expired": "token expirado", + "year_label": "Año {year}", + "currency_title": "Moneda", + "currency_hint": "Moneda usada para todos los costes y precios en la app." + }, + "tab_general": "General", + "kiosk_update_required": "⚠️ Actualiza la app kiosk para usar esta función" }, "expiry": { "today": "HOY", @@ -999,8 +1071,10 @@ "thrown_away_partial": "🗑️ {qty} {unit} de {name} tirado(s)", "finished_all": "📤 ¡{name} terminado!", "product_finished_confirmed": "✅ Eliminado — añádelo de nuevo cuando reabastezcas", + "ghost_restored": "✅ {name}: restaurados {qty} {unit} en el inventario", "appliance_added": "Electrodoméstico añadido", - "item_added": "{name} añadido" + "item_added": "{name} añadido", + "vacuum_sealed": "{name} guardado al vacío" }, "antiwaste": { "title": "🌱 Informe anti-desperdicio", @@ -1070,7 +1144,9 @@ "offline_ops_pending": "{n} operaciones pendientes", "offline_synced": "{n} operaciones sincronizadas", "offline_ai_disabled": "No disponible sin conexión", - "offline_cache_ready": "Offline — {n} productos en caché" + "offline_cache_ready": "Offline — {n} productos en caché", + "copy_failed": "Error al copiar al portapapeles", + "invalid_quantity": "Cantidad no válida" }, "confirm_placeholder_search": null, "confirm": { @@ -1171,7 +1247,10 @@ "retake_btn": "🔄 Repetir", "camera_error_hint": "Asegúrate de usar HTTPS y haber concedido los permisos de cámara.
Puedes introducir el código de barras manualmente o usar la identificación IA.", "no_barcode": "Sin código de barras", - "save_new_btn": "🆕 Ninguno de estos — guardar como nuevo" + "save_new_btn": "🆕 Ninguno de estos — guardar como nuevo", + "expiry_found": "Fecha encontrada", + "expiry_read_fail": "No se puede leer la fecha.", + "expiry_raw_label": "Leído" }, "lowstock": { "title": "⚠️ ¡Stock bajo!", @@ -1189,7 +1268,8 @@ "stay_btn": "No, quedarse en {location}", "moved_toast": "📦 Paquete abierto movido a {location}", "vacuum_restore": "🫙 Restaurar al vacío", - "vacuum_seal_rest": "🔒 Sellar el resto al vacío" + "vacuum_seal_rest": "🔒 Sellar el resto al vacío", + "moved_simple": "📦 Movido a {location}" }, "nova": { "1": "Sin procesar", @@ -1424,7 +1504,12 @@ "token_autoconfig": "Configurando acceso...", "token_prompt_title": "🔒 Token API", "token_prompt_hint": "Introduce el valor API_TOKEN del archivo .env del servidor.", - "token_prompt_btn": "Continuar" + "token_prompt_btn": "Continuar", + "check_db_legacy": "BD antigua (dispensa.db)", + "check_tts": "URL texto a voz", + "check_scale": "Pasarela báscula", + "critical_error_intro": "La app no puede iniciarse por los siguientes problemas:", + "error_network_detail": "El navegador no puede conectar con el servidor PHP.\n\nPosibles causas:\n• Apache/PHP no está en ejecución\n• Problema de red o firewall\n• URL incorrecta\n\nInicia el servidor e inténtalo de nuevo." }, "stats_monthly": { "title": "Estadísticas Mensuales", @@ -1437,5 +1522,12 @@ "top_used": "más usado", "top_cats": "Categorías principales", "source": "Historial de transacciones · mes actual" + }, + "time": { + "just_now": "ahora", + "seconds_ago": "hace {n}s", + "minutes_ago": "hace {n} min", + "hours_ago": "hace {n} h", + "days_ago": "hace {n} d" } -} \ No newline at end of file +} diff --git a/translations/fr.json b/translations/fr.json index eaee732..23b989a 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -141,8 +141,10 @@ "banner_prediction_more": "estimation précédente : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}.", "banner_prediction_less": "estimation : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}. Si votre rythme d'utilisation a changé, la prévision se met à jour automatiquement.", "banner_finished_zero": "L'inventaire indique zéro, mais les mouvements enregistrés suggèrent qu'il ne devrait pas être vide.", + "banner_finished_vanished": "Ce produit n'apparaît plus dans l'inventaire, mais les mouvements enregistrés suggèrent qu'il ne devrait pas être vide.", "banner_finished_expected": "D'après les enregistrements vous devriez avoir encore {qty} {unit}.", "banner_finished_check": "Pouvez-vous vérifier ?", + "banner_finished_action_restore": "Restaurer {qty} {unit}", "banner_anomaly_phantom_title": "vous avez plus de stock que prévu", "banner_anomaly_phantom_detail": "L'inventaire indique {inv_qty} {unit}, mais selon les enregistrements vous ne devriez avoir que {expected_qty} {unit}. Avez-vous ajouté du stock sans l'enregistrer ?", "banner_anomaly_untracked_title": "stock non enregistré comme entrée", @@ -162,7 +164,13 @@ "banner_opened_detail": "{when} dans {location} · il vous reste encore {qty}.", "banner_explain_title": "Demander une explication à Gemini", "banner_explain_btn": "Expliquer", - "banner_analyzing": "🤖 Analyse en cours…" + "banner_analyzing": "🤖 Analyse en cours…", + "banner_expired_action_modify": "Modifier", + "banner_expired_action_vacuum": "Mettre sous vide", + "banner_prediction_confirmed": "✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements", + "banner_anomaly_explain_fail": "Impossible d'obtenir l'explication IA", + "banner_anomaly_dismissed": "Anomalie ignorée", + "banner_finished_restore_prompt": "Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})" }, "inventory": { "title": "Garde-manger", @@ -240,7 +248,9 @@ "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" + "ai_detected_label": "IA a detecte", + "stock_in_pantry": "Déjà à la maison :", + "mode_shopping_activated": "🛒 Mode courses activé !" }, "action": { "title": "Que voulez-vous faire ?", @@ -254,7 +264,8 @@ "throw_btn": "🗑️ JETER", "throw_sub": "jeter", "edit_sub": "péremption, emplacement…", - "create_recipe_btn": "Recette" + "create_recipe_btn": "Recette", + "related_stock_title": "Aussi à la maison" }, "add": { "title": "Ajouter au garde-manger", @@ -312,14 +323,17 @@ "toast_bring": "🛒 Produit terminé → ajouté à Bring !", "toast_opened_finished": "🔓 Emballage ouvert de {name} terminé !", "disambiguation_hint": "Que voulez-vous dire par « tout fini » ?", + "disambiguation_one_conf": "Terminer 1 emballage ({qty})", "disambiguation_all": "🗑️ Tout finir ({qty})", + "toast_one_conf_finished": "📦 1 emballage de {name} terminé !", "error_exceeds_stock": "⚠️ Vous ne pouvez pas utiliser plus que ce que vous avez disponible !", "use_all_confirm_title": "✅ Tout terminer", "use_all_confirm_msg": "Confirmez que vous avez terminé le produit :", "use_all_confirm_btn": "✅ Oui, terminé", "throw_all_confirm_title": "🗑️ Tout jeter", "throw_all_confirm_msg": "Voulez-vous vraiment jeter tout le produit ?", - "throw_all_confirm_btn": "🗑️ Oui, jeter" + "throw_all_confirm_btn": "🗑️ Oui, jeter", + "locations_short": "emplacements" }, "product": { "title_new": "Nouveau produit", @@ -359,7 +373,9 @@ "weight_label": "Poids", "origin_label": "Origine", "labels_label": "Labels", - "select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :" + "select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :", + "history_badge": "📊 historique", + "from_history": " (historique)" }, "products": { "title": "📦 Tous les produits", @@ -420,7 +436,10 @@ "nutrition_per_serving": "Valeurs estimées par portion", "storage_title": "Comment conserver les restes", "storage_days": "{n} jours", - "storage_immediately": "À consommer immédiatement" + "storage_immediately": "À consommer immédiatement", + "stream_interrupted": "Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.", + "ing_stock_line": "Vous avez {have} · il reste {remain} après usage", + "ing_use_all_note": "tout utiliser (<5% du conditionnement entier)" }, "shopping": { "title": "🛒 Liste de courses", @@ -507,6 +526,7 @@ "remove_error": "Erreur de suppression", "btn_fetch_prices": "Trouver les prix", "price_total_label": "💰 Total estimé :", + "price_total_short": "total estimé", "price_loading": "Recherche des prix…", "price_not_found": "prix n/d", "suggest_loading": "Analyse en cours...", @@ -515,7 +535,9 @@ "priority_medium": "Moyenne", "priority_low": "Faible", "smart_last_update": "Mis à jour {time}", - "names_already_updated": "Tous les noms sont déjà à jour" + "names_already_updated": "Tous les noms sont déjà à jour", + "pantry_hint": "Déjà à la maison : {qty}", + "bring_names_migrated": "🔄 {n} noms généralisés dans Bring !" }, "ai": { "title": "🤖 Identification IA", @@ -526,7 +548,8 @@ "no_api_key": "⚠️ Clé API Gemini non configurée.\nAjoutez GEMINI_API_KEY au fichier .env sur le serveur.", "fields_filled": "✅ Champs remplis par l'IA", "use_data": "✅ Utiliser les données IA", - "use_data_no_barcode": "✅ Utiliser les données IA (sans code-barres)" + "use_data_no_barcode": "✅ Utiliser les données IA (sans code-barres)", + "conservation_hint": "🤖 IA : conserve dans {location}" }, "log": { "title": "📒 Journal des opérations", @@ -742,7 +765,8 @@ "heard_yes": "Oui, je l'ai entendu", "heard_no": "Non, je n'ai rien entendu", "test_ok_kiosk": "TTS fonctionne.", - "test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android." + "test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android.", + "test_sound_btn": "🔔 Test sonore" }, "language": { "title": "🌐 Langue", @@ -780,7 +804,13 @@ "kiosk_title": "📡 Balance BLE intégrée dans le kiosque", "kiosk_hint": "La balance est directement gérée par la passerelle BLE interne du kiosque. Pour associer un nouvel appareil, utilisez l'assistant de configuration.", "kiosk_reconfigure": "🔄 Reconfigurer la balance BLE", - "ble_protocols": "

🔌 Protocoles BLE supportés :

  • Bluetooth SIG Weight Scale (0x181D)
  • Bluetooth SIG Body Composition (0x181B) — poids, graisse, IMC
  • Xiaomi Mi Body Composition Scale 2
  • Générique — heuristique automatique pour 100+ modèles
" + "ble_protocols": "

🔌 Protocoles BLE supportés :

  • Bluetooth SIG Weight Scale (0x181D)
  • Bluetooth SIG Body Composition (0x181B) — poids, graisse, IMC
  • Xiaomi Mi Body Composition Scale 2
  • Générique — heuristique automatique pour 100+ modèles
", + "discover_scanning": "🔍 Recherche du gateway balance sur le réseau local…", + "discover_found": "✅ Gateway trouvé : {url}{more}", + "discover_not_found": "❌ Aucun gateway sur {subnet}. Lancez l'app Android sur le même Wi-Fi.", + "discover_failed": "❌ Échec de la recherche : {error}", + "discover_auto": "🔍 Auto", + "unknown_device": "Appareil inconnu" }, "kiosk": { "hint": "Transformez une tablette Android en panneau EverShelf permanent avec passerelle BLE intégrée.", @@ -926,7 +956,49 @@ "sensor_copied": "YAML copié dans le presse-papiers !", "save_btn": "Enregistrer les paramètres HA", "ha_hint": "Si vous utilisez Home Assistant, utilisez l'onglet Home Assistant pour configurer TTS, webhooks et capteurs." - } + }, + "info": { + "tab": "Info", + "ai_title": "Gemini AI — Utilisation des tokens", + "ai_hint": "Consommation mensuelle et coût estimé pour la clé API actuelle.", + "loading": "Chargement…", + "total_tokens": "Tokens totaux", + "est_cost": "Coût est.", + "input_tok": "Tokens entrée", + "output_tok": "Tokens sortie", + "ai_calls": "Appels", + "by_action": "Répartition par fonction", + "by_model": "Répartition par modèle", + "pricing_note": "Tarifs Gemini : 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.", + "system_title": "Système", + "db_size": "Base de données", + "log_size": "Logs", + "log_level": "Niveau de log", + "ai_overview": "Aperçu IA, inventaire et état du système", + "calls_unit": "appels", + "inv_title": "Inventaire", + "inv_active": "Actifs", + "inv_products": "Produits totaux", + "inv_expiring": "Expirent (7j)", + "inv_expired": "Expirés", + "inv_finished": "Terminés", + "act_title": "Activité mensuelle", + "act_tx_month": "Mouvements", + "act_restock": "Réapprovisionnements", + "act_use": "Utilisations", + "act_new_products": "Nouveaux produits", + "act_tx_year": "Mouvements annuels", + "price_cache": "Cache prix", + "cache_entries": "produits", + "last_backup": "Dernière sauvegarde", + "bring_days": "jeton expire dans {n} jours", + "bring_expired": "jeton expiré", + "year_label": "Année {year}", + "currency_title": "Devise", + "currency_hint": "Devise utilisée pour tous les coûts et prix dans l'app." + }, + "tab_general": "Général", + "kiosk_update_required": "⚠️ Mettez à jour l'application kiosk pour utiliser cette fonction" }, "expiry": { "today": "AUJOURD'HUI", @@ -999,8 +1071,10 @@ "thrown_away_partial": "🗑️ {qty} {unit} de {name} jeté(s)", "finished_all": "📤 {name} terminé !", "product_finished_confirmed": "✅ Supprimé — ajoutez-le à nouveau lors du réapprovisionnement", + "ghost_restored": "✅ {name} : {qty} {unit} restaurés dans l'inventaire", "appliance_added": "Appareil ajouté", - "item_added": "{name} ajouté" + "item_added": "{name} ajouté", + "vacuum_sealed": "{name} enregistré sous vide" }, "antiwaste": { "title": "🌱 Rapport anti-gaspi", @@ -1070,7 +1144,9 @@ "offline_ops_pending": "{n} opérations en attente", "offline_synced": "{n} opérations synchronisées", "offline_ai_disabled": "Indisponible hors ligne", - "offline_cache_ready": "Offline — {n} produits en cache" + "offline_cache_ready": "Offline — {n} produits en cache", + "copy_failed": "Échec de la copie dans le presse-papiers", + "invalid_quantity": "Quantité invalide" }, "confirm_placeholder_search": null, "confirm": { @@ -1171,7 +1247,10 @@ "retake_btn": "🔄 Reprendre", "camera_error_hint": "Assurez-vous d'utiliser HTTPS et d'avoir accordé les permissions caméra.
Vous pouvez entrer le code-barres manuellement ou utiliser l'identification IA.", "no_barcode": "Pas de code-barres", - "save_new_btn": "🆕 Aucun de ceux-ci — enregistrer comme nouveau" + "save_new_btn": "🆕 Aucun de ceux-ci — enregistrer comme nouveau", + "expiry_found": "Date trouvée", + "expiry_read_fail": "Impossible de lire la date.", + "expiry_raw_label": "Lu" }, "lowstock": { "title": "⚠️ Stock faible !", @@ -1189,7 +1268,8 @@ "stay_btn": "Non, rester dans {location}", "moved_toast": "📦 Emballage ouvert déplacé vers {location}", "vacuum_restore": "🫙 Restaurer sous vide", - "vacuum_seal_rest": "🔒 Mettre le reste sous vide" + "vacuum_seal_rest": "🔒 Mettre le reste sous vide", + "moved_simple": "📦 Déplacé vers {location}" }, "nova": { "1": "Non transformé", @@ -1424,7 +1504,12 @@ "token_autoconfig": "Configuration de l'accès...", "token_prompt_title": "🔒 Jeton API", "token_prompt_hint": "Saisissez la valeur API_TOKEN du fichier .env du serveur.", - "token_prompt_btn": "Continuer" + "token_prompt_btn": "Continuer", + "check_db_legacy": "Ancienne BD (dispensa.db)", + "check_tts": "URL synthèse vocale", + "check_scale": "Passerelle balance", + "critical_error_intro": "L'application ne peut pas démarrer en raison des problèmes suivants :", + "error_network_detail": "Le navigateur ne peut pas joindre le serveur PHP.\n\nCauses possibles :\n• Apache/PHP n'est pas démarré\n• Problème réseau ou pare-feu\n• URL incorrecte\n\nDémarrez le serveur et réessayez." }, "stats_monthly": { "title": "Statistiques Mensuelles", @@ -1437,5 +1522,12 @@ "top_used": "le plus utilisé", "top_cats": "Catégories principales", "source": "Historique des transactions · mois en cours" + }, + "time": { + "just_now": "à l'instant", + "seconds_ago": "il y a {n}s", + "minutes_ago": "il y a {n} min", + "hours_ago": "il y a {n} h", + "days_ago": "il y a {n} j" } -} \ No newline at end of file +} diff --git a/translations/it.json b/translations/it.json index be2196b..61be39c 100644 --- a/translations/it.json +++ b/translations/it.json @@ -143,8 +143,10 @@ "banner_prediction_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.", "banner_prediction_less": "stima: {expected} {unit}{time}; quantità attuale: {actual} {unit}. Se hai cambiato ritmo d'uso, la previsione si aggiorna automaticamente.", "banner_finished_zero": "L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.", + "banner_finished_vanished": "Il prodotto non compare più in inventario, ma i movimenti registrati dicono che non dovrebbe essere finito.", "banner_finished_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.", "banner_finished_check": "Puoi controllare?", + "banner_finished_action_restore": "Ripristina {qty} {unit}", "banner_anomaly_phantom_title": "hai più scorte del previsto", "banner_anomaly_phantom_detail": "L'inventario segna {inv_qty} {unit}, ma in base alle registrazioni ne dovresti avere solo {expected_qty} {unit}. Hai aggiunto scorte senza registrarle?", "banner_anomaly_untracked_title": "scorte non registrate come entrata", @@ -164,7 +166,11 @@ "banner_opened_detail": "{when} in {location} · hai ancora {qty}.", "banner_explain_title": "Chiedi a Gemini una spiegazione", "banner_explain_btn": "Spiega", - "banner_analyzing": "🤖 Analizzo…" + "banner_analyzing": "🤖 Analizzo…", + "banner_prediction_confirmed": "✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni", + "banner_anomaly_explain_fail": "Impossibile ottenere spiegazione AI", + "banner_anomaly_dismissed": "Anomalia ignorata", + "banner_finished_restore_prompt": "Quante {unit} di {name} hai ancora? (stima sistema: {qty})" }, "inventory": { "title": "Dispensa", @@ -243,7 +249,8 @@ "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" + "ai_detected_label": "AI ha trovato", + "mode_shopping_activated": "🛒 Modalità Spesa attivata!" }, "action": { "title": "Cosa vuoi fare?", @@ -316,14 +323,17 @@ "toast_bring": "🛒 Prodotto finito → aggiunto a Bring!", "toast_opened_finished": "🔓 Confezione aperta di {name} finita!", "disambiguation_hint": "Cosa intendi con \"finito tutto\"?", + "disambiguation_one_conf": "Finita 1 confezione ({qty})", "disambiguation_all": "🗑️ Finito TUTTO ({qty})", + "toast_one_conf_finished": "📦 1 confezione di {name} terminata!", "error_exceeds_stock": "⚠️ Non puoi usare più di quanto hai disponibile!", "use_all_confirm_title": "✅ Finisci tutto", "use_all_confirm_msg": "Conferma che hai finito tutto il prodotto:", "use_all_confirm_btn": "✅ Sì, finito", "throw_all_confirm_title": "🗑️ Butta tutto", "throw_all_confirm_msg": "Vuoi davvero buttare via tutto il prodotto?", - "throw_all_confirm_btn": "🗑️ Sì, butta" + "throw_all_confirm_btn": "🗑️ Sì, butta", + "locations_short": "posti" }, "product": { "title_new": "Nuovo Prodotto", @@ -363,7 +373,9 @@ "weight_label": "Peso", "origin_label": "Origine", "labels_label": "Etichette", - "select_variant": "Seleziona la variante esatta o usa i dati AI:" + "select_variant": "Seleziona la variante esatta o usa i dati AI:", + "history_badge": "📊 storico", + "from_history": " (da storico)" }, "products": { "title": "📦 Tutti i Prodotti", @@ -425,7 +437,9 @@ "nutrition_per_serving": "Valori stimati per porzione", "storage_title": "Come conservare gli avanzi", "storage_days": "{n} giorni", - "storage_immediately": "Da consumare subito" + "storage_immediately": "Da consumare subito", + "ing_stock_line": "Hai {have} · restano {remain} dopo l'uso", + "ing_use_all_note": "uso totale (<5% della confezione intera)" }, "shopping": { "title": "🛒 Lista della Spesa", @@ -512,6 +526,7 @@ "remove_error": "Errore nella rimozione", "btn_fetch_prices": "Cerca i prezzi", "price_total_label": "💰 Spesa stimata:", + "price_total_short": "spesa stimata", "price_loading": "Ricerca prezzi…", "price_not_found": "prezzo n/d", "suggest_loading": "Analisi in corso...", @@ -521,7 +536,8 @@ "priority_low": "Bassa", "smart_last_update": "Aggiornato {time}", "names_already_updated": "Tutti i nomi sono già aggiornati", - "pantry_hint": "Hai gia {qty} in dispensa" + "pantry_hint": "Hai gia {qty} in dispensa", + "bring_names_migrated": "🔄 {n} nomi generalizzati in Bring!" }, "ai": { "title": "🤖 Identificazione AI", @@ -532,7 +548,8 @@ "no_api_key": "⚠️ Chiave API Gemini non configurata.\nAggiungi GEMINI_API_KEY nel file .env sul server.", "fields_filled": "✅ Campi compilati dall'AI", "use_data": "✅ Usa dati AI", - "use_data_no_barcode": "✅ Usa dati AI (senza barcode)" + "use_data_no_barcode": "✅ Usa dati AI (senza barcode)", + "conservation_hint": "🤖 AI: conserva in {location}" }, "log": { "title": "📒 Storico", @@ -787,7 +804,13 @@ "kiosk_title": "📡 Bilancia BLE integrata nel Kiosk", "kiosk_hint": "La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.", "kiosk_reconfigure": "🔄 Riconfigura bilancia BLE", - "ble_protocols": "

🔌 Protocolli BLE supportati:

  • Bluetooth SIG Weight Scale (0x181D)
  • Bluetooth SIG Body Composition (0x181B) — peso, grasso, BMI
  • Xiaomi Mi Body Composition Scale 2
  • Generico — heuristica automatica su 100+ modelli
" + "ble_protocols": "

🔌 Protocolli BLE supportati:

  • Bluetooth SIG Weight Scale (0x181D)
  • Bluetooth SIG Body Composition (0x181B) — peso, grasso, BMI
  • Xiaomi Mi Body Composition Scale 2
  • Generico — heuristica automatica su 100+ modelli
", + "discover_scanning": "🔍 Scansione rete locale per gateway bilancia…", + "discover_found": "✅ Gateway trovato: {url}{more}", + "discover_not_found": "❌ Nessun gateway su {subnet}. Avvia l'app Android sulla stessa Wi-Fi.", + "discover_failed": "❌ Ricerca fallita: {error}", + "discover_auto": "🔍 Auto", + "unknown_device": "Dispositivo sconosciuto" }, "kiosk": { "hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.", @@ -974,7 +997,8 @@ "sensor_copied": "YAML copiato negli appunti!", "save_btn": "Salva impostazioni HA", "ha_hint": "Se usi Home Assistant, usa il tab Home Assistant per configurare TTS, webhook e sensori." - } + }, + "kiosk_update_required": "⚠️ Aggiorna il kiosk per usare questa funzione" }, "expiry": { "today": "OGGI", @@ -1048,6 +1072,7 @@ "finished_all": "📤 {name} terminato!", "vacuum_sealed": "{name} salvato come sottovuoto", "product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri", + "ghost_restored": "✅ {name}: ripristinati {qty} {unit} in inventario", "appliance_added": "Elettrodomestico aggiunto", "item_added": "{name} aggiunto" }, @@ -1119,7 +1144,9 @@ "offline_ops_pending": "{n} operazioni in attesa", "offline_synced": "{n} operazioni sincronizzate", "offline_ai_disabled": "Non disponibile offline", - "offline_cache_ready": "Offline — {n} prodotti in cache" + "offline_cache_ready": "Offline — {n} prodotti in cache", + "copy_failed": "Copia negli appunti non riuscita", + "invalid_quantity": "Quantità non valida" }, "confirm": { "remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?", @@ -1240,7 +1267,8 @@ "stay_btn": "No, resta in {location}", "moved_toast": "📦 Confezione aperta spostata in {location}", "vacuum_restore": "Torna sotto vuoto", - "vacuum_seal_rest": "Metti sotto vuoto il resto" + "vacuum_seal_rest": "Metti sotto vuoto il resto", + "moved_simple": "📦 Spostato in {location}" }, "nova": { "1": "Non trasformato", @@ -1493,5 +1521,12 @@ "top_used": "più usato", "top_cats": "Categorie principali", "source": "Storico transazioni · mese corrente" + }, + "time": { + "just_now": "adesso", + "seconds_ago": "{n}s fa", + "minutes_ago": "{n} min fa", + "hours_ago": "{n} h fa", + "days_ago": "{n} gg fa" } -} \ No newline at end of file +}