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 <cursoragent@cursor.com>
This commit is contained in:
dadaloop82
2026-06-04 17:22:59 +00:00
parent a0385cfb9b
commit cf65e79010
15 changed files with 1908 additions and 287 deletions
+21
View File
@@ -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. - **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 ## [1.7.35] - 2026-06-02
### Fixed ### Fixed
+2 -1
View File
@@ -25,7 +25,7 @@
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) [![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) [![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/) [![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) [![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) [![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) [![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 - **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 - **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 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 - **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 - **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 - **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
+470 -55
View File
@@ -650,6 +650,7 @@ if ($rateLimitAction) {
// the explicit header is an additional defence-in-depth check for POST writes. // the explicit header is an additional defence-in-depth check for POST writes.
$_writeActions = [ $_writeActions = [
'inventory_add','inventory_use','inventory_update','inventory_remove', 'inventory_add','inventory_use','inventory_update','inventory_remove',
'inventory_confirm_finished','inventory_restore_ghost',
'product_save','product_delete','product_merge', 'product_save','product_delete','product_merge',
'bring_add','bring_remove','bring_sync','bring_set_spec','bring_migrate_names', 'bring_add','bring_remove','bring_sync','bring_set_spec','bring_migrate_names',
'shopping_add','shopping_remove', 'shopping_add','shopping_remove',
@@ -715,6 +716,9 @@ try {
case 'product_delete': case 'product_delete':
deleteProduct($db); deleteProduct($db);
break; break;
case 'product_merge':
mergeProduct($db);
break;
case 'products_list': case 'products_list':
listProducts($db); listProducts($db);
break; break;
@@ -747,6 +751,9 @@ try {
case 'inventory_confirm_finished': case 'inventory_confirm_finished':
confirmFinished($db); confirmFinished($db);
break; break;
case 'inventory_restore_ghost':
restoreGhostInventory($db);
break;
case 'inventory_summary': case 'inventory_summary':
inventorySummary($db); inventorySummary($db);
break; break;
@@ -2512,8 +2519,18 @@ function saveProduct(PDO $db): void {
? $input['shopping_name'] ? $input['shopping_name']
: computeShoppingName($input['name'], $input['category'] ?? '', $input['brand'] ?? ''); : computeShoppingName($input['name'], $input['category'] ?? '', $input['brand'] ?? '');
if (!empty($input['id'])) { $id = !empty($input['id']) ? (int)$input['id'] : 0;
// Update existing $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(" $stmt = $db->prepare("
UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?, UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?,
default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?, default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?,
@@ -2526,9 +2543,9 @@ function saveProduct(PDO $db): void {
$input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['image_url'] ?? '', $input['unit'] ?? 'pz',
$input['default_quantity'] ?? 1, $input['notes'] ?? '', $input['default_quantity'] ?? 1, $input['notes'] ?? '',
$input['barcode'] ?? null, $input['package_unit'] ?? '', $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 { } else {
// Insert new // Insert new
$stmt = $db->prepare(" $stmt = $db->prepare("
@@ -2863,8 +2880,19 @@ function useFromInventory(PDO $db): void {
// Guard against accidental double-consume triggers (scale jitter, double tap, // Guard against accidental double-consume triggers (scale jitter, double tap,
// delayed/offline replay burst). We only apply this stricter gate to manual // delayed/offline replay burst). We only apply this stricter gate to manual
// uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected. // uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected.
if (!$useAll) { $dedupWindow = $useAll ? 60 : (($notes === '') ? 120 : 12);
$dedupWindow = ($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( $dedup = $db->prepare(
"SELECT id, quantity, created_at FROM transactions "SELECT id, quantity, created_at FROM transactions
WHERE product_id = ? WHERE product_id = ?
@@ -2877,25 +2905,26 @@ function useFromInventory(PDO $db): void {
LIMIT 1" LIMIT 1"
); );
$dedup->execute([$productId, $location, $notes, $dedupWindow]); $dedup->execute([$productId, $location, $notes, $dedupWindow]);
$recent = $dedup->fetch(); }
if ($recent) { $recent = $dedup->fetch();
EverLog::warn('useFromInventory duplicate blocked', [ if ($recent) {
'product_id' => $productId, EverLog::warn('useFromInventory duplicate blocked', [
'location' => $location, 'product_id' => $productId,
'window_s' => $dedupWindow, 'location' => $location,
'recent_tx_id' => $recent['id'] ?? null, 'use_all' => $useAll,
'recent_qty' => $recent['quantity'] ?? null, 'window_s' => $dedupWindow,
'recent_created_at' => $recent['created_at'] ?? null, 'recent_tx_id' => $recent['id'] ?? null,
'requested_qty' => $quantity, 'recent_qty' => $recent['quantity'] ?? null,
'notes' => $notes, 'recent_created_at' => $recent['created_at'] ?? null,
]); 'requested_qty' => $quantity,
echo json_encode([ 'notes' => $notes,
'success' => false, ]);
'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.', echo json_encode([
'duplicate' => true, 'success' => false,
]); 'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.',
return; 'duplicate' => true,
} ]);
return;
} }
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
@@ -3325,17 +3354,168 @@ function updateInventory(PDO $db): void {
function deleteInventory(PDO $db): void { function deleteInventory(PDO $db): void {
EverLog::info('deleteInventory'); EverLog::info('deleteInventory');
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? 0; $id = (int)($input['id'] ?? 0);
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); 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]); $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]); 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 * transaction balance (total_in - total_out) is still significantly positive
* meaning the system suspects the product ran out prematurely (scale drift, * 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 * Products where the balance is at/near zero are legitimately finished by the
* user; those rows are silently deleted here (no banner needed). * user; those rows are silently deleted here (no banner needed).
@@ -3344,30 +3524,27 @@ function getFinishedItems(PDO $db): void {
EverLog::debug('getFinishedItems'); EverLog::debug('getFinishedItems');
$rows = $db->query(" $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, 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' 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 FROM products p
JOIN inventory i ON i.product_id = p.id
LEFT JOIN transactions t ON t.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 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); ")->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 = []; $suspicious = [];
foreach ($rows as $r) { foreach ($rows as $r) {
$expected = (float)$r['total_in'] - (float)$r['total_out']; $expected = (float)$r['total_in'] - (float)$r['total_out'];
$threshold = $thresholds[$r['unit']] ?? 0.5; $threshold = productQtyThreshold($r['unit']);
if ($expected > $threshold) { if ($expected > $threshold) {
// Transaction balance says stock should remain — show banner $location = $r['inv_location'] ?: $r['tx_location'] ?: 'dispensa';
$suspicious[] = [ $suspicious[] = [
'product_id' => (int)$r['product_id'], 'product_id' => (int)$r['product_id'],
'name' => $r['name'], 'name' => $r['name'],
@@ -3377,13 +3554,14 @@ function getFinishedItems(PDO $db): void {
'package_unit' => $r['package_unit'], 'package_unit' => $r['package_unit'],
'image_url' => $r['image_url'], 'image_url' => $r['image_url'],
'barcode' => $r['barcode'], 'barcode' => $r['barcode'],
'location' => $r['location'], 'location' => $location,
'updated_at' => $r['updated_at'], 'updated_at' => $r['inv_updated'],
'expected_qty' => round($expected, 3), 'expected_qty' => round($expected, 3),
'ghost' => true,
'vanished' => ((int)$r['inv_rows']) === 0,
]; ];
} else { } 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']]); ->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 { function confirmFinished(PDO $db): void {
$input = json_decode(file_get_contents('php://input'), true); $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']); echo json_encode(['error' => 'product_id required']);
return; 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]); 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 { function inventorySummary(PDO $db): void {
EverLog::debug('inventorySummary'); EverLog::debug('inventorySummary');
$stmt = $db->query(" $stmt = $db->query("
@@ -5452,6 +5710,142 @@ PROMPT;
return $text; 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 ===== // ===== RECIPE GENERATION WITH GEMINI =====
function generateRecipe(PDO $db): void { function generateRecipe(PDO $db): void {
EverLog::debug('generateRecipe start'); EverLog::debug('generateRecipe start');
@@ -6071,9 +6465,14 @@ PROMPT;
if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) {
$defQty = (float)($bestMatch['default_quantity'] ?? 0); $defQty = (float)($bestMatch['default_quantity'] ?? 0);
$pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? '');
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) {
$qtyNum = round($qtyNum * $defQty); if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) {
$ing['qty'] = $qtyNum . ' ' . $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 // Sanity check: qty_number should not exceed available
@@ -6093,6 +6492,7 @@ PROMPT;
} }
} }
unset($ing); unset($ing);
recipeApplyStockHintsToRecipe($db, $recipe);
} }
EverLog::info('recipe generated', ['title' => $recipe['title'] ?? '?', 'meal' => $mealType, 'persons' => $persons, 'ingredients' => count($recipe['ingredients'] ?? [])]); EverLog::info('recipe generated', ['title' => $recipe['title'] ?? '?', 'meal' => $mealType, 'persons' => $persons, 'ingredients' => count($recipe['ingredients'] ?? [])]);
@@ -6191,6 +6591,7 @@ PROMPT;
if (!empty($recipe['ingredients'])) { if (!empty($recipe['ingredients'])) {
_enrichChatIngredients($recipe['ingredients'], $items); _enrichChatIngredients($recipe['ingredients'], $items);
} }
recipeApplyStockHintsToRecipe($db, $recipe);
echo json_encode(['success' => true, 'recipe' => $recipe]); echo json_encode(['success' => true, 'recipe' => $recipe]);
} }
@@ -6305,6 +6706,7 @@ PROMPT;
if (!empty($recipe['ingredients'])) { if (!empty($recipe['ingredients'])) {
_enrichChatIngredients($recipe['ingredients'], $items); _enrichChatIngredients($recipe['ingredients'], $items);
} }
recipeApplyStockHintsToRecipe($db, $recipe);
EverLog::info('recipe_from_ingredient ok', ['ingredient' => $ingredientName, 'title' => $recipe['title'] ?? '?', 'persons' => $persons]); EverLog::info('recipe_from_ingredient ok', ['ingredient' => $ingredientName, 'title' => $recipe['title'] ?? '?', 'persons' => $persons]);
echo json_encode(['success' => true, 'recipe' => $recipe]); echo json_encode(['success' => true, 'recipe' => $recipe]);
@@ -6461,8 +6863,14 @@ function _enrichChatIngredients(array &$ingredients, array $items): void {
if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) {
$defQty = (float)($bestMatch['default_quantity'] ?? 0); $defQty = (float)($bestMatch['default_quantity'] ?? 0);
$pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? '');
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) {
$qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; 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; if ($qtyNum > $invQty) $qtyNum = $invQty;
@@ -7024,8 +7432,14 @@ PROMPT;
if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) {
$defQty = (float)($bestMatch['default_quantity'] ?? 0); $defQty = (float)($bestMatch['default_quantity'] ?? 0);
$pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? '');
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) {
$qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; 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; if ($qtyNum > $invQty) $qtyNum = $invQty;
@@ -7035,6 +7449,7 @@ PROMPT;
} }
} }
unset($ing); unset($ing);
recipeApplyStockHintsToRecipe($db, $recipe);
} }
$send('status', ['step' => 4, 'message' => '✅ Ricetta pronta!']); $send('status', ['step' => 4, 'message' => '✅ Ricetta pronta!']);
+7
View File
@@ -4671,6 +4671,13 @@ body.server-offline .bottom-nav {
line-height: 1.3; 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 (REPARTO) HEADERS ===== */
.shopping-section-divider { .shopping-section-divider {
display: flex; display: flex;
+543 -157
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -72,7 +72,7 @@
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div> <div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div> <div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button> <button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
<span class="app-preloader-version" id="preloader-version">v1.7.35</span> <span class="app-preloader-version" id="preloader-version">v1.7.36</span>
</div> </div>
</div> </div>
@@ -85,7 +85,7 @@
<!-- Title — left-aligned; grows to fill space --> <!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap"> <div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')"> <h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.35</span> <img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.36</span>
</h1> </h1>
<!-- Update badge — shown alongside title, never replaces it --> <!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span> <span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -1970,6 +1970,6 @@
</div> </div>
</div> </div>
<script src="assets/js/app.js?v=20260603c"></script> <script src="assets/js/app.js?v=20260604c"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf", "name": "EverShelf",
"short_name": "EverShelf", "short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode", "description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.35", "version": "1.7.36",
"start_url": "/evershelf/", "start_url": "/evershelf/",
"display": "standalone", "display": "standalone",
"background_color": "#f0f4e8", "background_color": "#f0f4e8",
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env php
<?php
/**
* One-time merge of duplicate product records (same normalized name + compatible brand).
* Opened-package splits remain as separate inventory rows on the canonical product.
*
* Usage: php scripts/merge-duplicate-products.php [--dry-run]
*/
declare(strict_types=1);
$dryRun = in_array('--dry-run', $argv, true);
$dbPath = __DIR__ . '/../data/evershelf.db';
if (!file_exists($dbPath)) {
fwrite(STDERR, "Database not found: $dbPath\n");
exit(1);
}
$db = new PDO('sqlite:' . $dbPath);
$db->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";
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env php
<?php
/**
* Re-apply stock hints and 5% use-all rule to an archived recipe.
* Usage: php scripts/re-enrich-recipe.php <recipe_id>
*/
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 <recipe_id>\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
);
}
+341
View File
@@ -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()
+46 -11
View File
@@ -143,8 +143,10 @@
"banner_prediction_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.", "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_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_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_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.",
"banner_finished_check": "Kannst du nachschauen?", "banner_finished_check": "Kannst du nachschauen?",
"banner_finished_action_restore": "{qty} {unit} wiederherstellen",
"banner_anomaly_phantom_title": "mehr Bestand als erwartet", "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_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", "banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht",
@@ -164,7 +166,11 @@
"banner_opened_detail": "{when} in {location} · du hast noch <strong>{qty}</strong>.", "banner_opened_detail": "{when} in {location} · du hast noch <strong>{qty}</strong>.",
"banner_explain_title": "Gemini um eine Erklärung bitten", "banner_explain_title": "Gemini um eine Erklärung bitten",
"banner_explain_btn": "Erklären", "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": { "inventory": {
"title": "Vorrat", "title": "Vorrat",
@@ -243,7 +249,8 @@
"ai_match_none": "Keine ahnlichen Produkte in der Vorratskammer gefunden.", "ai_match_none": "Keine ahnlichen Produkte in der Vorratskammer gefunden.",
"ai_match_use_btn": "Dieses nutzen", "ai_match_use_btn": "Dieses nutzen",
"ai_match_add_btn": "\"{name}\" hinzufugen", "ai_match_add_btn": "\"{name}\" hinzufugen",
"ai_detected_label": "KI erkannt" "ai_detected_label": "KI erkannt",
"mode_shopping_activated": "🛒 Einkaufsmodus aktiviert!"
}, },
"action": { "action": {
"title": "Was möchtest du tun?", "title": "Was möchtest du tun?",
@@ -316,14 +323,17 @@
"toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt", "toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
"toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!", "toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!",
"disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?", "disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?",
"disambiguation_one_conf": "<strong>1 Packung</strong> aufgebraucht ({qty})",
"disambiguation_all": "🗑️ ALLES verbraucht ({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!", "error_exceeds_stock": "⚠️ Du kannst nicht mehr verwenden als du verfügbar hast!",
"use_all_confirm_title": "✅ Alles aufbrauchen", "use_all_confirm_title": "✅ Alles aufbrauchen",
"use_all_confirm_msg": "Bestätige, dass du das Produkt vollständig aufgebraucht hast:", "use_all_confirm_msg": "Bestätige, dass du das Produkt vollständig aufgebraucht hast:",
"use_all_confirm_btn": "✅ Ja, aufgebraucht", "use_all_confirm_btn": "✅ Ja, aufgebraucht",
"throw_all_confirm_title": "🗑️ Alles entsorgen", "throw_all_confirm_title": "🗑️ Alles entsorgen",
"throw_all_confirm_msg": "Möchtest du wirklich das gesamte Produkt 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": { "product": {
"title_new": "Neues Produkt", "title_new": "Neues Produkt",
@@ -363,7 +373,9 @@
"weight_label": "Gewicht", "weight_label": "Gewicht",
"origin_label": "Herkunft", "origin_label": "Herkunft",
"labels_label": "Etiketten", "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": { "products": {
"title": "📦 Alle Produkte", "title": "📦 Alle Produkte",
@@ -425,7 +437,9 @@
"nutrition_per_serving": "Geschätzte Werte pro Portion", "nutrition_per_serving": "Geschätzte Werte pro Portion",
"storage_title": "Aufbewahrung von Resten", "storage_title": "Aufbewahrung von Resten",
"storage_days": "{n} Tage", "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": { "shopping": {
"title": "🛒 Einkaufsliste", "title": "🛒 Einkaufsliste",
@@ -512,6 +526,7 @@
"remove_error": "Fehler beim Entfernen", "remove_error": "Fehler beim Entfernen",
"btn_fetch_prices": "Preise suchen", "btn_fetch_prices": "Preise suchen",
"price_total_label": "💰 Geschätzter Gesamtpreis:", "price_total_label": "💰 Geschätzter Gesamtpreis:",
"price_total_short": "geschätzte Ausgaben",
"price_loading": "Preise werden gesucht…", "price_loading": "Preise werden gesucht…",
"price_not_found": "Preis n/v", "price_not_found": "Preis n/v",
"suggest_loading": "Analyse läuft...", "suggest_loading": "Analyse läuft...",
@@ -521,7 +536,8 @@
"priority_low": "Niedrig", "priority_low": "Niedrig",
"smart_last_update": "Aktualisiert {time}", "smart_last_update": "Aktualisiert {time}",
"names_already_updated": "Alle Namen sind bereits aktuell", "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": { "ai": {
"title": "🤖 KI-Identifikation", "title": "🤖 KI-Identifikation",
@@ -532,7 +548,8 @@
"no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>", "no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>",
"fields_filled": "✅ Felder von KI ausgefüllt", "fields_filled": "✅ Felder von KI ausgefüllt",
"use_data": "✅ KI-Daten verwenden", "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": { "log": {
"title": "📒 Verlauf", "title": "📒 Verlauf",
@@ -787,7 +804,13 @@
"kiosk_title": "📡 BLE-Waage im Kiosk integriert", "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_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", "kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch &mdash; automatische Heuristik für 100+ Modelle</li></ul>" "ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch &mdash; automatische Heuristik für 100+ Modelle</li></ul>",
"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": { "kiosk": {
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.", "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!", "sensor_copied": "YAML in die Zwischenablage kopiert!",
"save_btn": "HA-Einstellungen speichern", "save_btn": "HA-Einstellungen speichern",
"ha_hint": "Wenn du Home Assistant verwendest, nutze den Home Assistant-Tab für TTS, Webhooks und Sensoren." "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": { "expiry": {
"today": "HEUTE", "today": "HEUTE",
@@ -1048,6 +1072,7 @@
"finished_all": "📤 {name} aufgebraucht!", "finished_all": "📤 {name} aufgebraucht!",
"vacuum_sealed": "{name} als vakuumversiegelt gespeichert", "vacuum_sealed": "{name} als vakuumversiegelt gespeichert",
"product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst", "product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst",
"ghost_restored": "✅ {name}: {qty} {unit} im Bestand wiederhergestellt",
"appliance_added": "Gerät hinzugefügt", "appliance_added": "Gerät hinzugefügt",
"item_added": "{name} hinzugefügt" "item_added": "{name} hinzugefügt"
}, },
@@ -1119,7 +1144,9 @@
"offline_ops_pending": "{n} Aktionen ausstehend", "offline_ops_pending": "{n} Aktionen ausstehend",
"offline_synced": "{n} Aktionen synchronisiert", "offline_synced": "{n} Aktionen synchronisiert",
"offline_ai_disabled": "Offline nicht verfügbar", "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_placeholder_search": null,
"confirm": { "confirm": {
@@ -1241,7 +1268,8 @@
"stay_btn": "Nein, bleibt in {location}", "stay_btn": "Nein, bleibt in {location}",
"moved_toast": "📦 Offene Packung bewegt nach {location}", "moved_toast": "📦 Offene Packung bewegt nach {location}",
"vacuum_restore": "Vakuum wiederherstellen", "vacuum_restore": "Vakuum wiederherstellen",
"vacuum_seal_rest": "Rest vakuumieren" "vacuum_seal_rest": "Rest vakuumieren",
"moved_simple": "📦 Nach {location} verschoben"
}, },
"nova": { "nova": {
"1": "Unverarbeitet", "1": "Unverarbeitet",
@@ -1494,5 +1522,12 @@
"top_used": "meistbenutzt", "top_used": "meistbenutzt",
"top_cats": "Hauptkategorien", "top_cats": "Hauptkategorien",
"source": "Transaktionsverlauf · aktueller Monat" "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"
} }
} }
+46 -11
View File
@@ -143,8 +143,10 @@
"banner_prediction_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.", "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_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_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_expected": "According to records you should still have {qty} {unit}.",
"banner_finished_check": "Can you check?", "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_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_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", "banner_anomaly_untracked_title": "stock not recorded as an entry",
@@ -164,7 +166,11 @@
"banner_opened_detail": "{when} in {location} · you still have <strong>{qty}</strong>.", "banner_opened_detail": "{when} in {location} · you still have <strong>{qty}</strong>.",
"banner_explain_title": "Ask Gemini for an explanation", "banner_explain_title": "Ask Gemini for an explanation",
"banner_explain_btn": "Explain", "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": { "inventory": {
"title": "Pantry", "title": "Pantry",
@@ -243,7 +249,8 @@
"ai_match_none": "No similar pantry products found.", "ai_match_none": "No similar pantry products found.",
"ai_match_use_btn": "Use this", "ai_match_use_btn": "Use this",
"ai_match_add_btn": "Add \"{name}\"", "ai_match_add_btn": "Add \"{name}\"",
"ai_detected_label": "AI detected" "ai_detected_label": "AI detected",
"mode_shopping_activated": "🛒 Shopping mode activated!"
}, },
"action": { "action": {
"title": "What do you want to do?", "title": "What do you want to do?",
@@ -316,14 +323,17 @@
"toast_bring": "🛒 Product finished → added to Bring!", "toast_bring": "🛒 Product finished → added to Bring!",
"toast_opened_finished": "🔓 Opened package of {name} finished!", "toast_opened_finished": "🔓 Opened package of {name} finished!",
"disambiguation_hint": "What do you mean by \"all done\"?", "disambiguation_hint": "What do you mean by \"all done\"?",
"disambiguation_one_conf": "Finished <strong>1 package</strong> ({qty})",
"disambiguation_all": "🗑️ Finish EVERYTHING ({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!", "error_exceeds_stock": "⚠️ You cannot use more than you have available!",
"use_all_confirm_title": "✅ Finish everything", "use_all_confirm_title": "✅ Finish everything",
"use_all_confirm_msg": "Confirm that you have finished the product:", "use_all_confirm_msg": "Confirm that you have finished the product:",
"use_all_confirm_btn": "✅ Yes, finished", "use_all_confirm_btn": "✅ Yes, finished",
"throw_all_confirm_title": "🗑️ Discard everything", "throw_all_confirm_title": "🗑️ Discard everything",
"throw_all_confirm_msg": "Do you really want to throw away the whole product?", "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": { "product": {
"title_new": "New Product", "title_new": "New Product",
@@ -363,7 +373,9 @@
"weight_label": "Weight", "weight_label": "Weight",
"origin_label": "Origin", "origin_label": "Origin",
"labels_label": "Labels", "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": { "products": {
"title": "📦 All Products", "title": "📦 All Products",
@@ -425,7 +437,9 @@
"nutrition_per_serving": "Estimated values per serving", "nutrition_per_serving": "Estimated values per serving",
"storage_title": "How to store leftovers", "storage_title": "How to store leftovers",
"storage_days": "{n} days", "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": { "shopping": {
"title": "🛒 Shopping List", "title": "🛒 Shopping List",
@@ -512,6 +526,7 @@
"remove_error": "Removal error", "remove_error": "Removal error",
"btn_fetch_prices": "Find prices", "btn_fetch_prices": "Find prices",
"price_total_label": "💰 Estimated total:", "price_total_label": "💰 Estimated total:",
"price_total_short": "estimated total",
"price_loading": "Looking up prices…", "price_loading": "Looking up prices…",
"price_not_found": "price n/a", "price_not_found": "price n/a",
"suggest_loading": "Analyzing...", "suggest_loading": "Analyzing...",
@@ -521,7 +536,8 @@
"priority_low": "Low", "priority_low": "Low",
"smart_last_update": "Updated {time}", "smart_last_update": "Updated {time}",
"names_already_updated": "All names are already up to date", "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": { "ai": {
"title": "🤖 AI Identification", "title": "🤖 AI Identification",
@@ -532,7 +548,8 @@
"no_api_key": "⚠️ Gemini API key not configured.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>", "no_api_key": "⚠️ Gemini API key not configured.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>",
"fields_filled": "✅ Fields filled by AI", "fields_filled": "✅ Fields filled by AI",
"use_data": "✅ Use AI data", "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": { "log": {
"title": "📒 Operations Log", "title": "📒 Operations Log",
@@ -787,7 +804,13 @@
"kiosk_title": "📡 BLE Scale integrated in Kiosk", "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_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", "kiosk_reconfigure": "🔄 Reconfigure BLE Scale",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic &mdash; automatic heuristic for 100+ models</li></ul>" "ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic &mdash; automatic heuristic for 100+ models</li></ul>",
"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": { "kiosk": {
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.", "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!", "sensor_copied": "YAML copied to clipboard!",
"save_btn": "Save HA settings", "save_btn": "Save HA settings",
"ha_hint": "If you use Home Assistant, use the Home Assistant tab to configure TTS, webhooks and sensors." "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": { "expiry": {
"today": "TODAY", "today": "TODAY",
@@ -1048,6 +1072,7 @@
"finished_all": "📤 {name} finished!", "finished_all": "📤 {name} finished!",
"vacuum_sealed": "{name} saved as vacuum sealed", "vacuum_sealed": "{name} saved as vacuum sealed",
"product_finished_confirmed": "✅ Removed — add it again when you restock", "product_finished_confirmed": "✅ Removed — add it again when you restock",
"ghost_restored": "✅ {name}: restored {qty} {unit} to inventory",
"appliance_added": "Appliance added", "appliance_added": "Appliance added",
"item_added": "{name} added" "item_added": "{name} added"
}, },
@@ -1119,7 +1144,9 @@
"offline_ops_pending": "{n} operations pending", "offline_ops_pending": "{n} operations pending",
"offline_synced": "{n} operations synced", "offline_synced": "{n} operations synced",
"offline_ai_disabled": "Not available offline", "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_placeholder_search": null,
"confirm": { "confirm": {
@@ -1241,7 +1268,8 @@
"stay_btn": "No, stay in {location}", "stay_btn": "No, stay in {location}",
"moved_toast": "📦 Opened package moved to {location}", "moved_toast": "📦 Opened package moved to {location}",
"vacuum_restore": "Restore vacuum sealed", "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": { "nova": {
"1": "Unprocessed", "1": "Unprocessed",
@@ -1494,5 +1522,12 @@
"top_used": "top used", "top_used": "top used",
"top_cats": "Top categories", "top_cats": "Top categories",
"source": "Transaction history · current month" "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"
} }
} }
+108 -16
View File
@@ -141,8 +141,10 @@
"banner_prediction_more": "estimación anterior: {expected} {unit}{time}; cantidad actual: {actual} {unit}.", "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_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_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_expected": "Según los registros deberías tener todavía {qty} {unit}.",
"banner_finished_check": "¿Puedes comprobarlo?", "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_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_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", "banner_anomaly_untracked_title": "stock no registrado como entrada",
@@ -162,7 +164,13 @@
"banner_opened_detail": "{when} en {location} · aún tienes <strong>{qty}</strong>.", "banner_opened_detail": "{when} en {location} · aún tienes <strong>{qty}</strong>.",
"banner_explain_title": "Pedir explicación a Gemini", "banner_explain_title": "Pedir explicación a Gemini",
"banner_explain_btn": "Explicar", "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": { "inventory": {
"title": "Despensa", "title": "Despensa",
@@ -240,7 +248,9 @@
"ai_match_none": "No se encontraron productos similares en despensa.", "ai_match_none": "No se encontraron productos similares en despensa.",
"ai_match_use_btn": "Usar este", "ai_match_use_btn": "Usar este",
"ai_match_add_btn": "Agregar \"{name}\"", "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": { "action": {
"title": "¿Qué quieres hacer?", "title": "¿Qué quieres hacer?",
@@ -254,7 +264,8 @@
"throw_btn": "🗑️ DESECHAR", "throw_btn": "🗑️ DESECHAR",
"throw_sub": "tirar", "throw_sub": "tirar",
"edit_sub": "caducidad, ubicación…", "edit_sub": "caducidad, ubicación…",
"create_recipe_btn": "Receta" "create_recipe_btn": "Receta",
"related_stock_title": "También en casa"
}, },
"add": { "add": {
"title": "Añadir a la despensa", "title": "Añadir a la despensa",
@@ -312,14 +323,17 @@
"toast_bring": "🛒 Producto terminado → añadido a Bring!", "toast_bring": "🛒 Producto terminado → añadido a Bring!",
"toast_opened_finished": "🔓 ¡Paquete abierto de {name} terminado!", "toast_opened_finished": "🔓 ¡Paquete abierto de {name} terminado!",
"disambiguation_hint": "¿Qué quieres decir con «todo terminado»?", "disambiguation_hint": "¿Qué quieres decir con «todo terminado»?",
"disambiguation_one_conf": "Terminado <strong>1 envase</strong> ({qty})",
"disambiguation_all": "🗑️ Terminar TODO ({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!", "error_exceeds_stock": "⚠️ ¡No puedes usar más de lo que tienes disponible!",
"use_all_confirm_title": "✅ Terminar todo", "use_all_confirm_title": "✅ Terminar todo",
"use_all_confirm_msg": "Confirma que has terminado el producto:", "use_all_confirm_msg": "Confirma que has terminado el producto:",
"use_all_confirm_btn": "✅ Sí, terminado", "use_all_confirm_btn": "✅ Sí, terminado",
"throw_all_confirm_title": "🗑️ Desechar todo", "throw_all_confirm_title": "🗑️ Desechar todo",
"throw_all_confirm_msg": "¿Realmente quieres tirar todo el producto?", "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": { "product": {
"title_new": "Nuevo producto", "title_new": "Nuevo producto",
@@ -359,7 +373,9 @@
"weight_label": "Peso", "weight_label": "Peso",
"origin_label": "Origen", "origin_label": "Origen",
"labels_label": "Etiquetas", "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": { "products": {
"title": "📦 Todos los productos", "title": "📦 Todos los productos",
@@ -420,7 +436,10 @@
"nutrition_per_serving": "Valores estimados por ración", "nutrition_per_serving": "Valores estimados por ración",
"storage_title": "Cómo conservar las sobras", "storage_title": "Cómo conservar las sobras",
"storage_days": "{n} días", "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": { "shopping": {
"title": "🛒 Lista de la compra", "title": "🛒 Lista de la compra",
@@ -507,6 +526,7 @@
"remove_error": "Error al eliminar", "remove_error": "Error al eliminar",
"btn_fetch_prices": "Buscar precios", "btn_fetch_prices": "Buscar precios",
"price_total_label": "💰 Total estimado:", "price_total_label": "💰 Total estimado:",
"price_total_short": "total estimado",
"price_loading": "Buscando precios…", "price_loading": "Buscando precios…",
"price_not_found": "precio n/d", "price_not_found": "precio n/d",
"suggest_loading": "Analizando...", "suggest_loading": "Analizando...",
@@ -515,7 +535,9 @@
"priority_medium": "Media", "priority_medium": "Media",
"priority_low": "Baja", "priority_low": "Baja",
"smart_last_update": "Actualizado {time}", "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": { "ai": {
"title": "🤖 Identificación IA", "title": "🤖 Identificación IA",
@@ -526,7 +548,8 @@
"no_api_key": "⚠️ Clave API de Gemini no configurada.\n<small>Añade GEMINI_API_KEY al archivo .env en el servidor.</small>", "no_api_key": "⚠️ Clave API de Gemini no configurada.\n<small>Añade GEMINI_API_KEY al archivo .env en el servidor.</small>",
"fields_filled": "✅ Campos rellenados por IA", "fields_filled": "✅ Campos rellenados por IA",
"use_data": "✅ Usar datos de 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": { "log": {
"title": "📒 Registro de operaciones", "title": "📒 Registro de operaciones",
@@ -742,7 +765,8 @@
"heard_yes": "Sí, la escuché", "heard_yes": "Sí, la escuché",
"heard_no": "No, no escuché nada", "heard_no": "No, no escuché nada",
"test_ok_kiosk": "TTS funcionando.", "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": { "language": {
"title": "🌐 Idioma", "title": "🌐 Idioma",
@@ -780,7 +804,13 @@
"kiosk_title": "📡 Báscula BLE integrada en el kiosco", "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_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", "kiosk_reconfigure": "🔄 Reconfigurar báscula BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolos BLE soportados:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico &mdash; heurística automática para 100+ modelos</li></ul>" "ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolos BLE soportados:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico &mdash; heurística automática para 100+ modelos</li></ul>",
"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": { "kiosk": {
"hint": "Convierte una tableta Android en un panel EverShelf permanente con pasarela BLE integrada.", "hint": "Convierte una tableta Android en un panel EverShelf permanente con pasarela BLE integrada.",
@@ -926,7 +956,49 @@
"sensor_copied": "¡YAML copiado al portapapeles!", "sensor_copied": "¡YAML copiado al portapapeles!",
"save_btn": "Guardar ajustes HA", "save_btn": "Guardar ajustes HA",
"ha_hint": "Si usas Home Assistant, utiliza la pestaña Home Assistant para configurar TTS, webhooks y sensores." "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": { "expiry": {
"today": "HOY", "today": "HOY",
@@ -999,8 +1071,10 @@
"thrown_away_partial": "🗑️ {qty} {unit} de {name} tirado(s)", "thrown_away_partial": "🗑️ {qty} {unit} de {name} tirado(s)",
"finished_all": "📤 ¡{name} terminado!", "finished_all": "📤 ¡{name} terminado!",
"product_finished_confirmed": "✅ Eliminado — añádelo de nuevo cuando reabastezcas", "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", "appliance_added": "Electrodoméstico añadido",
"item_added": "{name} añadido" "item_added": "{name} añadido",
"vacuum_sealed": "{name} guardado al vacío"
}, },
"antiwaste": { "antiwaste": {
"title": "🌱 Informe anti-desperdicio", "title": "🌱 Informe anti-desperdicio",
@@ -1070,7 +1144,9 @@
"offline_ops_pending": "{n} operaciones pendientes", "offline_ops_pending": "{n} operaciones pendientes",
"offline_synced": "{n} operaciones sincronizadas", "offline_synced": "{n} operaciones sincronizadas",
"offline_ai_disabled": "No disponible sin conexión", "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_placeholder_search": null,
"confirm": { "confirm": {
@@ -1171,7 +1247,10 @@
"retake_btn": "🔄 Repetir", "retake_btn": "🔄 Repetir",
"camera_error_hint": "Asegúrate de usar HTTPS y haber concedido los permisos de cámara.<br>Puedes introducir el código de barras manualmente o usar la identificación IA.", "camera_error_hint": "Asegúrate de usar HTTPS y haber concedido los permisos de cámara.<br>Puedes introducir el código de barras manualmente o usar la identificación IA.",
"no_barcode": "Sin código de barras", "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": { "lowstock": {
"title": "⚠️ ¡Stock bajo!", "title": "⚠️ ¡Stock bajo!",
@@ -1189,7 +1268,8 @@
"stay_btn": "No, quedarse en {location}", "stay_btn": "No, quedarse en {location}",
"moved_toast": "📦 Paquete abierto movido a {location}", "moved_toast": "📦 Paquete abierto movido a {location}",
"vacuum_restore": "🫙 Restaurar al vacío", "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": { "nova": {
"1": "Sin procesar", "1": "Sin procesar",
@@ -1424,7 +1504,12 @@
"token_autoconfig": "Configurando acceso...", "token_autoconfig": "Configurando acceso...",
"token_prompt_title": "🔒 Token API", "token_prompt_title": "🔒 Token API",
"token_prompt_hint": "Introduce el valor API_TOKEN del archivo .env del servidor.", "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": { "stats_monthly": {
"title": "Estadísticas Mensuales", "title": "Estadísticas Mensuales",
@@ -1437,5 +1522,12 @@
"top_used": "más usado", "top_used": "más usado",
"top_cats": "Categorías principales", "top_cats": "Categorías principales",
"source": "Historial de transacciones · mes actual" "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"
} }
} }
+108 -16
View File
@@ -141,8 +141,10 @@
"banner_prediction_more": "estimation précédente : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}.", "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_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_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_expected": "D'après les enregistrements vous devriez avoir encore {qty} {unit}.",
"banner_finished_check": "Pouvez-vous vérifier ?", "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_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_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", "banner_anomaly_untracked_title": "stock non enregistré comme entrée",
@@ -162,7 +164,13 @@
"banner_opened_detail": "{when} dans {location} · il vous reste encore <strong>{qty}</strong>.", "banner_opened_detail": "{when} dans {location} · il vous reste encore <strong>{qty}</strong>.",
"banner_explain_title": "Demander une explication à Gemini", "banner_explain_title": "Demander une explication à Gemini",
"banner_explain_btn": "Expliquer", "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": { "inventory": {
"title": "Garde-manger", "title": "Garde-manger",
@@ -240,7 +248,9 @@
"ai_match_none": "Aucun produit similaire trouve dans le stock.", "ai_match_none": "Aucun produit similaire trouve dans le stock.",
"ai_match_use_btn": "Utiliser celui-ci", "ai_match_use_btn": "Utiliser celui-ci",
"ai_match_add_btn": "Ajouter \"{name}\"", "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": { "action": {
"title": "Que voulez-vous faire ?", "title": "Que voulez-vous faire ?",
@@ -254,7 +264,8 @@
"throw_btn": "🗑️ JETER", "throw_btn": "🗑️ JETER",
"throw_sub": "jeter", "throw_sub": "jeter",
"edit_sub": "péremption, emplacement…", "edit_sub": "péremption, emplacement…",
"create_recipe_btn": "Recette" "create_recipe_btn": "Recette",
"related_stock_title": "Aussi à la maison"
}, },
"add": { "add": {
"title": "Ajouter au garde-manger", "title": "Ajouter au garde-manger",
@@ -312,14 +323,17 @@
"toast_bring": "🛒 Produit terminé → ajouté à Bring !", "toast_bring": "🛒 Produit terminé → ajouté à Bring !",
"toast_opened_finished": "🔓 Emballage ouvert de {name} terminé !", "toast_opened_finished": "🔓 Emballage ouvert de {name} terminé !",
"disambiguation_hint": "Que voulez-vous dire par « tout fini » ?", "disambiguation_hint": "Que voulez-vous dire par « tout fini » ?",
"disambiguation_one_conf": "Terminer <strong>1 emballage</strong> ({qty})",
"disambiguation_all": "🗑️ Tout finir ({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 !", "error_exceeds_stock": "⚠️ Vous ne pouvez pas utiliser plus que ce que vous avez disponible !",
"use_all_confirm_title": "✅ Tout terminer", "use_all_confirm_title": "✅ Tout terminer",
"use_all_confirm_msg": "Confirmez que vous avez terminé le produit :", "use_all_confirm_msg": "Confirmez que vous avez terminé le produit :",
"use_all_confirm_btn": "✅ Oui, terminé", "use_all_confirm_btn": "✅ Oui, terminé",
"throw_all_confirm_title": "🗑️ Tout jeter", "throw_all_confirm_title": "🗑️ Tout jeter",
"throw_all_confirm_msg": "Voulez-vous vraiment jeter tout le produit ?", "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": { "product": {
"title_new": "Nouveau produit", "title_new": "Nouveau produit",
@@ -359,7 +373,9 @@
"weight_label": "Poids", "weight_label": "Poids",
"origin_label": "Origine", "origin_label": "Origine",
"labels_label": "Labels", "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": { "products": {
"title": "📦 Tous les produits", "title": "📦 Tous les produits",
@@ -420,7 +436,10 @@
"nutrition_per_serving": "Valeurs estimées par portion", "nutrition_per_serving": "Valeurs estimées par portion",
"storage_title": "Comment conserver les restes", "storage_title": "Comment conserver les restes",
"storage_days": "{n} jours", "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": { "shopping": {
"title": "🛒 Liste de courses", "title": "🛒 Liste de courses",
@@ -507,6 +526,7 @@
"remove_error": "Erreur de suppression", "remove_error": "Erreur de suppression",
"btn_fetch_prices": "Trouver les prix", "btn_fetch_prices": "Trouver les prix",
"price_total_label": "💰 Total estimé :", "price_total_label": "💰 Total estimé :",
"price_total_short": "total estimé",
"price_loading": "Recherche des prix…", "price_loading": "Recherche des prix…",
"price_not_found": "prix n/d", "price_not_found": "prix n/d",
"suggest_loading": "Analyse en cours...", "suggest_loading": "Analyse en cours...",
@@ -515,7 +535,9 @@
"priority_medium": "Moyenne", "priority_medium": "Moyenne",
"priority_low": "Faible", "priority_low": "Faible",
"smart_last_update": "Mis à jour {time}", "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": { "ai": {
"title": "🤖 Identification IA", "title": "🤖 Identification IA",
@@ -526,7 +548,8 @@
"no_api_key": "⚠️ Clé API Gemini non configurée.\n<small>Ajoutez GEMINI_API_KEY au fichier .env sur le serveur.</small>", "no_api_key": "⚠️ Clé API Gemini non configurée.\n<small>Ajoutez GEMINI_API_KEY au fichier .env sur le serveur.</small>",
"fields_filled": "✅ Champs remplis par l'IA", "fields_filled": "✅ Champs remplis par l'IA",
"use_data": "✅ Utiliser les données 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": { "log": {
"title": "📒 Journal des opérations", "title": "📒 Journal des opérations",
@@ -742,7 +765,8 @@
"heard_yes": "Oui, je l'ai entendu", "heard_yes": "Oui, je l'ai entendu",
"heard_no": "Non, je n'ai rien entendu", "heard_no": "Non, je n'ai rien entendu",
"test_ok_kiosk": "TTS fonctionne.", "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": { "language": {
"title": "🌐 Langue", "title": "🌐 Langue",
@@ -780,7 +804,13 @@
"kiosk_title": "📡 Balance BLE intégrée dans le kiosque", "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_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", "kiosk_reconfigure": "🔄 Reconfigurer la balance BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocoles BLE supportés :</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique &mdash; heuristique automatique pour 100+ modèles</li></ul>" "ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocoles BLE supportés :</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique &mdash; heuristique automatique pour 100+ modèles</li></ul>",
"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": { "kiosk": {
"hint": "Transformez une tablette Android en panneau EverShelf permanent avec passerelle BLE intégrée.", "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 !", "sensor_copied": "YAML copié dans le presse-papiers !",
"save_btn": "Enregistrer les paramètres HA", "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." "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": { "expiry": {
"today": "AUJOURD'HUI", "today": "AUJOURD'HUI",
@@ -999,8 +1071,10 @@
"thrown_away_partial": "🗑️ {qty} {unit} de {name} jeté(s)", "thrown_away_partial": "🗑️ {qty} {unit} de {name} jeté(s)",
"finished_all": "📤 {name} terminé !", "finished_all": "📤 {name} terminé !",
"product_finished_confirmed": "✅ Supprimé — ajoutez-le à nouveau lors du réapprovisionnement", "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é", "appliance_added": "Appareil ajouté",
"item_added": "{name} ajouté" "item_added": "{name} ajouté",
"vacuum_sealed": "{name} enregistré sous vide"
}, },
"antiwaste": { "antiwaste": {
"title": "🌱 Rapport anti-gaspi", "title": "🌱 Rapport anti-gaspi",
@@ -1070,7 +1144,9 @@
"offline_ops_pending": "{n} opérations en attente", "offline_ops_pending": "{n} opérations en attente",
"offline_synced": "{n} opérations synchronisées", "offline_synced": "{n} opérations synchronisées",
"offline_ai_disabled": "Indisponible hors ligne", "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_placeholder_search": null,
"confirm": { "confirm": {
@@ -1171,7 +1247,10 @@
"retake_btn": "🔄 Reprendre", "retake_btn": "🔄 Reprendre",
"camera_error_hint": "Assurez-vous d'utiliser HTTPS et d'avoir accordé les permissions caméra.<br>Vous pouvez entrer le code-barres manuellement ou utiliser l'identification IA.", "camera_error_hint": "Assurez-vous d'utiliser HTTPS et d'avoir accordé les permissions caméra.<br>Vous pouvez entrer le code-barres manuellement ou utiliser l'identification IA.",
"no_barcode": "Pas de code-barres", "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": { "lowstock": {
"title": "⚠️ Stock faible !", "title": "⚠️ Stock faible !",
@@ -1189,7 +1268,8 @@
"stay_btn": "Non, rester dans {location}", "stay_btn": "Non, rester dans {location}",
"moved_toast": "📦 Emballage ouvert déplacé vers {location}", "moved_toast": "📦 Emballage ouvert déplacé vers {location}",
"vacuum_restore": "🫙 Restaurer sous vide", "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": { "nova": {
"1": "Non transformé", "1": "Non transformé",
@@ -1424,7 +1504,12 @@
"token_autoconfig": "Configuration de l'accès...", "token_autoconfig": "Configuration de l'accès...",
"token_prompt_title": "🔒 Jeton API", "token_prompt_title": "🔒 Jeton API",
"token_prompt_hint": "Saisissez la valeur API_TOKEN du fichier .env du serveur.", "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": { "stats_monthly": {
"title": "Statistiques Mensuelles", "title": "Statistiques Mensuelles",
@@ -1437,5 +1522,12 @@
"top_used": "le plus utilisé", "top_used": "le plus utilisé",
"top_cats": "Catégories principales", "top_cats": "Catégories principales",
"source": "Historique des transactions · mois en cours" "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"
} }
} }
+46 -11
View File
@@ -143,8 +143,10 @@
"banner_prediction_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.", "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_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_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_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.",
"banner_finished_check": "Puoi controllare?", "banner_finished_check": "Puoi controllare?",
"banner_finished_action_restore": "Ripristina {qty} {unit}",
"banner_anomaly_phantom_title": "hai più scorte del previsto", "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_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", "banner_anomaly_untracked_title": "scorte non registrate come entrata",
@@ -164,7 +166,11 @@
"banner_opened_detail": "{when} in {location} · hai ancora <strong>{qty}</strong>.", "banner_opened_detail": "{when} in {location} · hai ancora <strong>{qty}</strong>.",
"banner_explain_title": "Chiedi a Gemini una spiegazione", "banner_explain_title": "Chiedi a Gemini una spiegazione",
"banner_explain_btn": "Spiega", "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": { "inventory": {
"title": "Dispensa", "title": "Dispensa",
@@ -243,7 +249,8 @@
"ai_match_none": "Nessun prodotto simile trovato in dispensa.", "ai_match_none": "Nessun prodotto simile trovato in dispensa.",
"ai_match_use_btn": "Usa questo", "ai_match_use_btn": "Usa questo",
"ai_match_add_btn": "Aggiungi \"{name}\"", "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": { "action": {
"title": "Cosa vuoi fare?", "title": "Cosa vuoi fare?",
@@ -316,14 +323,17 @@
"toast_bring": "🛒 Prodotto finito → aggiunto a Bring!", "toast_bring": "🛒 Prodotto finito → aggiunto a Bring!",
"toast_opened_finished": "🔓 Confezione aperta di {name} finita!", "toast_opened_finished": "🔓 Confezione aperta di {name} finita!",
"disambiguation_hint": "Cosa intendi con \"finito tutto\"?", "disambiguation_hint": "Cosa intendi con \"finito tutto\"?",
"disambiguation_one_conf": "Finita <strong>1 confezione</strong> ({qty})",
"disambiguation_all": "🗑️ Finito TUTTO ({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!", "error_exceeds_stock": "⚠️ Non puoi usare più di quanto hai disponibile!",
"use_all_confirm_title": "✅ Finisci tutto", "use_all_confirm_title": "✅ Finisci tutto",
"use_all_confirm_msg": "Conferma che hai finito tutto il prodotto:", "use_all_confirm_msg": "Conferma che hai finito tutto il prodotto:",
"use_all_confirm_btn": "✅ Sì, finito", "use_all_confirm_btn": "✅ Sì, finito",
"throw_all_confirm_title": "🗑️ Butta tutto", "throw_all_confirm_title": "🗑️ Butta tutto",
"throw_all_confirm_msg": "Vuoi davvero buttare via tutto il prodotto?", "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": { "product": {
"title_new": "Nuovo Prodotto", "title_new": "Nuovo Prodotto",
@@ -363,7 +373,9 @@
"weight_label": "Peso", "weight_label": "Peso",
"origin_label": "Origine", "origin_label": "Origine",
"labels_label": "Etichette", "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": { "products": {
"title": "📦 Tutti i Prodotti", "title": "📦 Tutti i Prodotti",
@@ -425,7 +437,9 @@
"nutrition_per_serving": "Valori stimati per porzione", "nutrition_per_serving": "Valori stimati per porzione",
"storage_title": "Come conservare gli avanzi", "storage_title": "Come conservare gli avanzi",
"storage_days": "{n} giorni", "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": { "shopping": {
"title": "🛒 Lista della Spesa", "title": "🛒 Lista della Spesa",
@@ -512,6 +526,7 @@
"remove_error": "Errore nella rimozione", "remove_error": "Errore nella rimozione",
"btn_fetch_prices": "Cerca i prezzi", "btn_fetch_prices": "Cerca i prezzi",
"price_total_label": "💰 Spesa stimata:", "price_total_label": "💰 Spesa stimata:",
"price_total_short": "spesa stimata",
"price_loading": "Ricerca prezzi…", "price_loading": "Ricerca prezzi…",
"price_not_found": "prezzo n/d", "price_not_found": "prezzo n/d",
"suggest_loading": "Analisi in corso...", "suggest_loading": "Analisi in corso...",
@@ -521,7 +536,8 @@
"priority_low": "Bassa", "priority_low": "Bassa",
"smart_last_update": "Aggiornato {time}", "smart_last_update": "Aggiornato {time}",
"names_already_updated": "Tutti i nomi sono già aggiornati", "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": { "ai": {
"title": "🤖 Identificazione AI", "title": "🤖 Identificazione AI",
@@ -532,7 +548,8 @@
"no_api_key": "⚠️ Chiave API Gemini non configurata.\n<small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small>", "no_api_key": "⚠️ Chiave API Gemini non configurata.\n<small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small>",
"fields_filled": "✅ Campi compilati dall'AI", "fields_filled": "✅ Campi compilati dall'AI",
"use_data": "✅ Usa dati 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": { "log": {
"title": "📒 Storico", "title": "📒 Storico",
@@ -787,7 +804,13 @@
"kiosk_title": "📡 Bilancia BLE integrata nel Kiosk", "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_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", "kiosk_reconfigure": "🔄 Riconfigura bilancia BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico &mdash; heuristica automatica su 100+ modelli</li></ul>" "ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico &mdash; heuristica automatica su 100+ modelli</li></ul>",
"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": { "kiosk": {
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.", "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!", "sensor_copied": "YAML copiato negli appunti!",
"save_btn": "Salva impostazioni HA", "save_btn": "Salva impostazioni HA",
"ha_hint": "Se usi Home Assistant, usa il tab Home Assistant per configurare TTS, webhook e sensori." "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": { "expiry": {
"today": "OGGI", "today": "OGGI",
@@ -1048,6 +1072,7 @@
"finished_all": "📤 {name} terminato!", "finished_all": "📤 {name} terminato!",
"vacuum_sealed": "{name} salvato come sottovuoto", "vacuum_sealed": "{name} salvato come sottovuoto",
"product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri", "product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri",
"ghost_restored": "✅ {name}: ripristinati {qty} {unit} in inventario",
"appliance_added": "Elettrodomestico aggiunto", "appliance_added": "Elettrodomestico aggiunto",
"item_added": "{name} aggiunto" "item_added": "{name} aggiunto"
}, },
@@ -1119,7 +1144,9 @@
"offline_ops_pending": "{n} operazioni in attesa", "offline_ops_pending": "{n} operazioni in attesa",
"offline_synced": "{n} operazioni sincronizzate", "offline_synced": "{n} operazioni sincronizzate",
"offline_ai_disabled": "Non disponibile offline", "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": { "confirm": {
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?", "remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
@@ -1240,7 +1267,8 @@
"stay_btn": "No, resta in {location}", "stay_btn": "No, resta in {location}",
"moved_toast": "📦 Confezione aperta spostata in {location}", "moved_toast": "📦 Confezione aperta spostata in {location}",
"vacuum_restore": "Torna sotto vuoto", "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": { "nova": {
"1": "Non trasformato", "1": "Non trasformato",
@@ -1493,5 +1521,12 @@
"top_used": "più usato", "top_used": "più usato",
"top_cats": "Categorie principali", "top_cats": "Categorie principali",
"source": "Storico transazioni · mese corrente" "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"
} }
} }