chore: auto-merge develop → main
Triggered by: cf65e79 Release v1.7.36: recipe stock hints, ghost products, and shopping total fix.
This commit is contained in:
@@ -11,6 +11,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [1.7.36] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations.
|
||||
- **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages).
|
||||
- **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input.
|
||||
- **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history.
|
||||
- **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product.
|
||||
- **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge).
|
||||
|
||||
### Fixed
|
||||
- **Unified shopping total** — Dashboard, Spesa page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too.
|
||||
- **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation.
|
||||
- **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups.
|
||||
- **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs.
|
||||
- **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust.
|
||||
- **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation.
|
||||
- **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches.
|
||||
- **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name.
|
||||
- **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock.
|
||||
|
||||
## [1.7.35] - 2026-06-02
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
@@ -86,6 +86,7 @@ Connect your pantry to your smart home in minutes — no YAML, no manual sensor
|
||||
- **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones
|
||||
- **Storage & shelf-life hint** — When adding a new product, Gemini suggests the optimal storage location and shelf-life in the background; shown as an inline AI badge next to the expiry estimate
|
||||
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
|
||||
- **Recipe stock hints** — Each pantry ingredient shows how much you have and what remains after use; when the leftover would be less than 5% of the full sealed package (10% for an already-opened partial pack), the recipe automatically uses everything on hand to avoid waste
|
||||
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
|
||||
- **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip
|
||||
- **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
|
||||
|
||||
+470
-55
@@ -650,6 +650,7 @@ if ($rateLimitAction) {
|
||||
// the explicit header is an additional defence-in-depth check for POST writes.
|
||||
$_writeActions = [
|
||||
'inventory_add','inventory_use','inventory_update','inventory_remove',
|
||||
'inventory_confirm_finished','inventory_restore_ghost',
|
||||
'product_save','product_delete','product_merge',
|
||||
'bring_add','bring_remove','bring_sync','bring_set_spec','bring_migrate_names',
|
||||
'shopping_add','shopping_remove',
|
||||
@@ -715,6 +716,9 @@ try {
|
||||
case 'product_delete':
|
||||
deleteProduct($db);
|
||||
break;
|
||||
case 'product_merge':
|
||||
mergeProduct($db);
|
||||
break;
|
||||
case 'products_list':
|
||||
listProducts($db);
|
||||
break;
|
||||
@@ -747,6 +751,9 @@ try {
|
||||
case 'inventory_confirm_finished':
|
||||
confirmFinished($db);
|
||||
break;
|
||||
case 'inventory_restore_ghost':
|
||||
restoreGhostInventory($db);
|
||||
break;
|
||||
case 'inventory_summary':
|
||||
inventorySummary($db);
|
||||
break;
|
||||
@@ -2512,8 +2519,18 @@ function saveProduct(PDO $db): void {
|
||||
? $input['shopping_name']
|
||||
: computeShoppingName($input['name'], $input['category'] ?? '', $input['brand'] ?? '');
|
||||
|
||||
if (!empty($input['id'])) {
|
||||
// Update existing
|
||||
$id = !empty($input['id']) ? (int)$input['id'] : 0;
|
||||
$merged = false;
|
||||
if (!$id) {
|
||||
$dupId = findDuplicateProductId($db, $input['name'], $input['brand'] ?? '', $input['barcode'] ?? null, null);
|
||||
if ($dupId) {
|
||||
$id = $dupId;
|
||||
$merged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
// Update existing (or matched duplicate)
|
||||
$stmt = $db->prepare("
|
||||
UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?,
|
||||
default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?,
|
||||
@@ -2526,9 +2543,9 @@ function saveProduct(PDO $db): void {
|
||||
$input['image_url'] ?? '', $input['unit'] ?? 'pz',
|
||||
$input['default_quantity'] ?? 1, $input['notes'] ?? '',
|
||||
$input['barcode'] ?? null, $input['package_unit'] ?? '',
|
||||
$shoppingName, $nutriJson, $input['id']
|
||||
$shoppingName, $nutriJson, $id
|
||||
]);
|
||||
echo json_encode(['success' => true, 'id' => $input['id']]);
|
||||
echo json_encode(['success' => true, 'id' => $id, 'merged' => $merged]);
|
||||
} else {
|
||||
// Insert new
|
||||
$stmt = $db->prepare("
|
||||
@@ -2863,8 +2880,19 @@ function useFromInventory(PDO $db): void {
|
||||
// Guard against accidental double-consume triggers (scale jitter, double tap,
|
||||
// delayed/offline replay burst). We only apply this stricter gate to manual
|
||||
// uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected.
|
||||
if (!$useAll) {
|
||||
$dedupWindow = ($notes === '') ? 120 : 12;
|
||||
$dedupWindow = $useAll ? 60 : (($notes === '') ? 120 : 12);
|
||||
if ($useAll) {
|
||||
$dedup = $db->prepare(
|
||||
"SELECT id, quantity, created_at FROM transactions
|
||||
WHERE product_id = ?
|
||||
AND type IN ('out','waste')
|
||||
AND undone = 0
|
||||
AND created_at >= datetime('now', '-' || ? || ' seconds')
|
||||
ORDER BY id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$dedup->execute([$productId, $dedupWindow]);
|
||||
} else {
|
||||
$dedup = $db->prepare(
|
||||
"SELECT id, quantity, created_at FROM transactions
|
||||
WHERE product_id = ?
|
||||
@@ -2877,25 +2905,26 @@ function useFromInventory(PDO $db): void {
|
||||
LIMIT 1"
|
||||
);
|
||||
$dedup->execute([$productId, $location, $notes, $dedupWindow]);
|
||||
$recent = $dedup->fetch();
|
||||
if ($recent) {
|
||||
EverLog::warn('useFromInventory duplicate blocked', [
|
||||
'product_id' => $productId,
|
||||
'location' => $location,
|
||||
'window_s' => $dedupWindow,
|
||||
'recent_tx_id' => $recent['id'] ?? null,
|
||||
'recent_qty' => $recent['quantity'] ?? null,
|
||||
'recent_created_at' => $recent['created_at'] ?? null,
|
||||
'requested_qty' => $quantity,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.',
|
||||
'duplicate' => true,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
$recent = $dedup->fetch();
|
||||
if ($recent) {
|
||||
EverLog::warn('useFromInventory duplicate blocked', [
|
||||
'product_id' => $productId,
|
||||
'location' => $location,
|
||||
'use_all' => $useAll,
|
||||
'window_s' => $dedupWindow,
|
||||
'recent_tx_id' => $recent['id'] ?? null,
|
||||
'recent_qty' => $recent['quantity'] ?? null,
|
||||
'recent_created_at' => $recent['created_at'] ?? null,
|
||||
'requested_qty' => $quantity,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.',
|
||||
'duplicate' => true,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3325,17 +3354,168 @@ function updateInventory(PDO $db): void {
|
||||
function deleteInventory(PDO $db): void {
|
||||
EverLog::info('deleteInventory');
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = $input['id'] ?? 0;
|
||||
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
if (!$id) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Inventory ID required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("SELECT id, product_id, quantity, location FROM inventory WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Inventory row not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
$qty = (float)$row['quantity'];
|
||||
if ($qty > 0.0001) {
|
||||
$db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, ?)")
|
||||
->execute([(int)$row['product_id'], $qty, $row['location'], '[Eliminazione inventario]']);
|
||||
}
|
||||
|
||||
$db->prepare("DELETE FROM inventory WHERE id = ?")->execute([$id]);
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
|
||||
function productQtyThreshold(string $unit): float {
|
||||
static $thresholds = ['g' => 20, 'ml' => 20, 'kg' => 0.02, 'l' => 0.02, 'conf' => 0.1, 'pz' => 0.5];
|
||||
return $thresholds[$unit] ?? 0.5;
|
||||
}
|
||||
|
||||
function normalizeProductName(string $name): string {
|
||||
return mb_strtolower(trim($name));
|
||||
}
|
||||
|
||||
function normalizeProductBrand(string $brand): string {
|
||||
return mb_strtolower(trim($brand));
|
||||
}
|
||||
|
||||
function brandsCompatible(string $a, string $b): bool {
|
||||
$na = normalizeProductBrand($a);
|
||||
$nb = normalizeProductBrand($b);
|
||||
return $na === $nb || $na === '' || $nb === '';
|
||||
}
|
||||
|
||||
function findDuplicateProductId(PDO $db, string $name, string $brand, ?string $barcode, ?int $excludeId = null): ?int {
|
||||
if ($barcode !== null && trim($barcode) !== '') {
|
||||
$sql = "SELECT id FROM products WHERE barcode = ? AND barcode IS NOT NULL AND TRIM(barcode) != ''";
|
||||
$params = [$barcode];
|
||||
if ($excludeId) {
|
||||
$sql .= " AND id != ?";
|
||||
$params[] = $excludeId;
|
||||
}
|
||||
$sql .= " ORDER BY id ASC LIMIT 1";
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$id = $stmt->fetchColumn();
|
||||
if ($id) {
|
||||
return (int)$id;
|
||||
}
|
||||
}
|
||||
|
||||
$nName = normalizeProductName($name);
|
||||
if ($nName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = "SELECT id, brand FROM products WHERE lower(trim(name)) = ?";
|
||||
$params = [$nName];
|
||||
if ($excludeId) {
|
||||
$sql .= " AND id != ?";
|
||||
$params[] = $excludeId;
|
||||
}
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$candidates = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!$candidates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetBrand = normalizeProductBrand($brand);
|
||||
$compatible = null;
|
||||
foreach ($candidates as $c) {
|
||||
$cBrand = normalizeProductBrand($c['brand'] ?? '');
|
||||
if ($cBrand === $targetBrand) {
|
||||
return (int)$c['id'];
|
||||
}
|
||||
if ($compatible === null && brandsCompatible($brand, $c['brand'] ?? '')) {
|
||||
$compatible = (int)$c['id'];
|
||||
}
|
||||
}
|
||||
return $compatible;
|
||||
}
|
||||
|
||||
function getProductLedgerBalance(PDO $db, int $productId): array {
|
||||
$stmt = $db->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN type = 'in' AND undone = 0 THEN quantity ELSE 0 END), 0) AS total_in,
|
||||
COALESCE(SUM(CASE WHEN type IN ('out','waste') AND undone = 0 THEN quantity ELSE 0 END), 0) AS total_out
|
||||
FROM transactions
|
||||
WHERE product_id = ?
|
||||
");
|
||||
$stmt->execute([$productId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
|
||||
$stockStmt = $db->prepare("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ?");
|
||||
$stockStmt->execute([$productId]);
|
||||
return [
|
||||
'total_in' => (float)$row['total_in'],
|
||||
'total_out' => (float)$row['total_out'],
|
||||
'stock' => (float)$stockStmt->fetchColumn(),
|
||||
];
|
||||
}
|
||||
|
||||
function mergeProducts(PDO $db, int $keepId, int $dropId): void {
|
||||
if ($keepId === $dropId) {
|
||||
return;
|
||||
}
|
||||
$check = $db->prepare("SELECT id FROM products WHERE id IN (?, ?)");
|
||||
$check->execute([$keepId, $dropId]);
|
||||
if ($check->rowCount() < 2) {
|
||||
throw new RuntimeException('One or both products not found');
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->prepare("UPDATE inventory SET product_id = ? WHERE product_id = ?")->execute([$keepId, $dropId]);
|
||||
$db->prepare("UPDATE transactions SET product_id = ? WHERE product_id = ?")->execute([$keepId, $dropId]);
|
||||
$db->prepare("DELETE FROM products WHERE id = ?")->execute([$dropId]);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProduct(PDO $db): void {
|
||||
EverLog::info('mergeProduct');
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$keepId = (int)($input['keep_id'] ?? $input['canonical_id'] ?? 0);
|
||||
$dropId = (int)($input['drop_id'] ?? $input['duplicate_id'] ?? 0);
|
||||
if (!$keepId || !$dropId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'keep_id and drop_id required']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
mergeProducts($db, $keepId, $dropId);
|
||||
echo json_encode(['success' => true, 'keep_id' => $keepId, 'drop_id' => $dropId]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns products whose entire inventory is at quantity = 0 AND whose
|
||||
* Returns products whose ledger balance exceeds stock (including vanished rows).
|
||||
* transaction balance (total_in - total_out) is still significantly positive —
|
||||
* meaning the system suspects the product ran out prematurely (scale drift,
|
||||
* missed registration, etc.).
|
||||
* missed registration, deleted inventory row, etc.).
|
||||
*
|
||||
* Products where the balance is at/near zero are legitimately finished by the
|
||||
* user; those rows are silently deleted here (no banner needed).
|
||||
@@ -3344,30 +3524,27 @@ function getFinishedItems(PDO $db): void {
|
||||
EverLog::debug('getFinishedItems');
|
||||
$rows = $db->query("
|
||||
SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, p.image_url, p.barcode,
|
||||
MIN(i.location) AS location,
|
||||
MAX(i.updated_at) AS updated_at,
|
||||
COALESCE(SUM(CASE WHEN t.type = 'in' AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_in,
|
||||
COALESCE(SUM(CASE WHEN t.type IN ('out','waste') AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_out
|
||||
COALESCE(SUM(CASE WHEN t.type IN ('out','waste') AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_out,
|
||||
COALESCE((SELECT SUM(i2.quantity) FROM inventory i2 WHERE i2.product_id = p.id), 0) AS stock_qty,
|
||||
(SELECT COUNT(*) FROM inventory i3 WHERE i3.product_id = p.id) AS inv_rows,
|
||||
(SELECT i4.location FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS inv_location,
|
||||
(SELECT i4.updated_at FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS inv_updated,
|
||||
(SELECT t2.location FROM transactions t2 WHERE t2.product_id = p.id AND t2.undone = 0 ORDER BY t2.created_at DESC LIMIT 1) AS tx_location
|
||||
FROM products p
|
||||
JOIN inventory i ON i.product_id = p.id
|
||||
LEFT JOIN transactions t ON t.product_id = p.id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM inventory i2 WHERE i2.product_id = p.id AND i2.quantity > 0
|
||||
)
|
||||
GROUP BY p.id
|
||||
ORDER BY MAX(i.updated_at) DESC
|
||||
HAVING stock_qty <= 0.001 AND total_in > 0
|
||||
ORDER BY (total_in - total_out) DESC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Per-unit threshold: residue below this is considered normal rounding/finish
|
||||
$thresholds = ['g' => 20, 'ml' => 20, 'kg' => 0.02, 'l' => 0.02, 'conf' => 0.1, 'pz' => 0.5];
|
||||
|
||||
$suspicious = [];
|
||||
foreach ($rows as $r) {
|
||||
$expected = (float)$r['total_in'] - (float)$r['total_out'];
|
||||
$threshold = $thresholds[$r['unit']] ?? 0.5;
|
||||
$threshold = productQtyThreshold($r['unit']);
|
||||
|
||||
if ($expected > $threshold) {
|
||||
// Transaction balance says stock should remain — show banner
|
||||
$location = $r['inv_location'] ?: $r['tx_location'] ?: 'dispensa';
|
||||
$suspicious[] = [
|
||||
'product_id' => (int)$r['product_id'],
|
||||
'name' => $r['name'],
|
||||
@@ -3377,13 +3554,14 @@ function getFinishedItems(PDO $db): void {
|
||||
'package_unit' => $r['package_unit'],
|
||||
'image_url' => $r['image_url'],
|
||||
'barcode' => $r['barcode'],
|
||||
'location' => $r['location'],
|
||||
'updated_at' => $r['updated_at'],
|
||||
'location' => $location,
|
||||
'updated_at' => $r['inv_updated'],
|
||||
'expected_qty' => round($expected, 3),
|
||||
'ghost' => true,
|
||||
'vanished' => ((int)$r['inv_rows']) === 0,
|
||||
];
|
||||
} else {
|
||||
// Legitimately finished — delete silently, no banner
|
||||
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0")
|
||||
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0")
|
||||
->execute([$r['product_id']]);
|
||||
}
|
||||
}
|
||||
@@ -3392,7 +3570,8 @@ function getFinishedItems(PDO $db): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete all qty=0 inventory rows for a product after user confirms it is finished.
|
||||
* Permanently reconcile a finished/ghost product: log the missing quantity as
|
||||
* an explicit out transaction, then delete any zero-qty inventory rows.
|
||||
*/
|
||||
function confirmFinished(PDO $db): void {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -3403,10 +3582,89 @@ function confirmFinished(PDO $db): void {
|
||||
echo json_encode(['error' => 'product_id required']);
|
||||
return;
|
||||
}
|
||||
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0")->execute([$productId]);
|
||||
|
||||
$prod = $db->prepare("SELECT unit FROM products WHERE id = ?");
|
||||
$prod->execute([$productId]);
|
||||
$unit = $prod->fetchColumn();
|
||||
if (!$unit) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Product not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
$bal = getProductLedgerBalance($db, $productId);
|
||||
$expected = $bal['total_in'] - $bal['total_out'];
|
||||
$threshold = productQtyThreshold((string)$unit);
|
||||
|
||||
if ($expected > $threshold) {
|
||||
$locStmt = $db->prepare("SELECT location FROM inventory WHERE product_id = ? ORDER BY updated_at DESC LIMIT 1");
|
||||
$locStmt->execute([$productId]);
|
||||
$location = $locStmt->fetchColumn();
|
||||
if (!$location) {
|
||||
$locStmt = $db->prepare("SELECT location FROM transactions WHERE product_id = ? AND undone = 0 ORDER BY created_at DESC LIMIT 1");
|
||||
$locStmt->execute([$productId]);
|
||||
$location = $locStmt->fetchColumn();
|
||||
}
|
||||
$location = $location ?: 'dispensa';
|
||||
$db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, ?)")
|
||||
->execute([$productId, round($expected, 3), $location, '[Riconciliazione] Confermato esaurito']);
|
||||
}
|
||||
|
||||
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0")->execute([$productId]);
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore stock for a ghost product without adding a new purchase (in) transaction.
|
||||
*/
|
||||
function restoreGhostInventory(PDO $db): void {
|
||||
EverLog::info('restoreGhostInventory');
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$productId = (int)($input['product_id'] ?? 0);
|
||||
$quantity = (float)($input['quantity'] ?? 0);
|
||||
$location = trim((string)($input['location'] ?? 'dispensa')) ?: 'dispensa';
|
||||
|
||||
if (!$productId || $quantity <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'product_id and quantity required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$prod = $db->prepare("SELECT id FROM products WHERE id = ?");
|
||||
$prod->execute([$productId]);
|
||||
if (!$prod->fetchColumn()) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Product not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, quantity FROM inventory
|
||||
WHERE product_id = ? AND location = ? AND opened_at IS NULL
|
||||
ORDER BY CASE WHEN quantity > 0 THEN 0 ELSE 1 END, updated_at DESC
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([$productId, $location]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row) {
|
||||
$db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
->execute([$quantity, (int)$row['id']]);
|
||||
$invId = (int)$row['id'];
|
||||
} else {
|
||||
$db->prepare("INSERT INTO inventory (product_id, location, quantity) VALUES (?, ?, ?)")
|
||||
->execute([$productId, $location, $quantity]);
|
||||
$invId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'inventory_id' => $invId,
|
||||
'quantity' => $quantity,
|
||||
'location' => $location,
|
||||
]);
|
||||
}
|
||||
|
||||
function inventorySummary(PDO $db): void {
|
||||
EverLog::debug('inventorySummary');
|
||||
$stmt = $db->query("
|
||||
@@ -5452,6 +5710,142 @@ PROMPT;
|
||||
return $text;
|
||||
}
|
||||
|
||||
/** Parse "200 g" / "2 pz" style recipe qty strings. */
|
||||
function recipeParseQtyString(string $qty): array {
|
||||
$val = 0.0;
|
||||
$unit = '';
|
||||
if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $qty, $qm)) {
|
||||
$val = (float)str_replace(',', '.', $qm[1]);
|
||||
$ru = strtolower($qm[2]);
|
||||
if (strpos($ru, 'g') === 0) $unit = 'g';
|
||||
elseif ($ru === 'kg') { $unit = 'g'; $val *= 1000; }
|
||||
elseif ($ru === 'ml') $unit = 'ml';
|
||||
elseif ($ru === 'cl') { $unit = 'ml'; $val *= 10; }
|
||||
elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $unit = 'ml'; $val *= 1000; }
|
||||
elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $unit = 'pz';
|
||||
elseif (strpos($ru, 'conf') === 0) $unit = 'conf';
|
||||
}
|
||||
return ['val' => $val, 'unit' => $unit];
|
||||
}
|
||||
|
||||
function recipeGetProductTotalStock(PDO $db, int $productId): float {
|
||||
$stmt = $db->prepare('SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ? AND quantity > 0');
|
||||
$stmt->execute([$productId]);
|
||||
return (float)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/** Full sealed unit size for % remainder (conf → default_quantity in g/ml per conf). */
|
||||
function recipeGetClosedProductBaseQty(array $ing): float {
|
||||
$unit = $ing['inventory_unit'] ?? 'pz';
|
||||
$pkgSize = (float)($ing['default_quantity'] ?? 0);
|
||||
$pkgUnit = strtolower($ing['package_unit'] ?? '');
|
||||
|
||||
if ($unit === 'conf' && $pkgSize > 0 && in_array($pkgUnit, ['g', 'ml'], true)) {
|
||||
return $pkgSize;
|
||||
}
|
||||
if ($unit === 'conf' && $pkgSize > 0) {
|
||||
return $pkgSize;
|
||||
}
|
||||
if ($pkgSize > 0 && in_array($unit, ['g', 'ml', 'pz'], true)) {
|
||||
return $pkgSize;
|
||||
}
|
||||
if ($unit === 'conf') {
|
||||
return 1.0;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/** Use-all when leftover is < 5% of the sealed package (not current stock). */
|
||||
function recipeShouldUseAllRemainder(float $remainDisp, array $ing, float $stockDisp = 0): bool {
|
||||
if ($remainDisp <= 0) {
|
||||
return false;
|
||||
}
|
||||
$packageBase = recipeGetClosedProductBaseQty($ing);
|
||||
if ($packageBase <= 0) {
|
||||
return false;
|
||||
}
|
||||
$pct = $remainDisp / $packageBase;
|
||||
if ($pct < 0.05) {
|
||||
return true;
|
||||
}
|
||||
// Opened/partial: less than one full sealed unit on hand — allow up to 10% tail waste
|
||||
if ($stockDisp > 0 && $stockDisp < $packageBase && $pct < 0.10) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Normalize use qty, apply <5% remainder → use-all, set stock_have/stock_remain hints. */
|
||||
function recipeFinalizeIngQty(array &$ing, float $totalStockQty): void {
|
||||
$parsed = recipeParseQtyString($ing['qty'] ?? '');
|
||||
$recipeVal = $parsed['val'];
|
||||
$recipeUnit = $parsed['unit'];
|
||||
$unit = $ing['inventory_unit'] ?? 'pz';
|
||||
$pkgSize = (float)($ing['default_quantity'] ?? 0);
|
||||
$pkgUnit = strtolower($ing['package_unit'] ?? '');
|
||||
$isConfSub = ($unit === 'conf' && $pkgSize > 0 && in_array($pkgUnit, ['g', 'ml'], true));
|
||||
|
||||
$useQty = (float)($ing['qty_number'] ?? 0);
|
||||
|
||||
// conf+weight: always prefer the recipe amount from the qty string (not inventory conf count)
|
||||
if ($isConfSub && $recipeVal > 0 && $recipeUnit === $pkgUnit) {
|
||||
$useQty = $recipeVal;
|
||||
$ing['qty_number'] = round($useQty, 3);
|
||||
$ing['qty'] = round($useQty) . ' ' . $pkgUnit;
|
||||
}
|
||||
|
||||
if ($isConfSub) {
|
||||
$stockDisp = $totalStockQty * $pkgSize;
|
||||
$useDisp = $useQty;
|
||||
$dispUnit = $pkgUnit;
|
||||
} else {
|
||||
$stockDisp = $totalStockQty;
|
||||
$useDisp = $useQty;
|
||||
$dispUnit = $unit;
|
||||
}
|
||||
|
||||
if ($stockDisp <= 0 || $useDisp <= 0) {
|
||||
$ing['stock_have'] = round($stockDisp, 2);
|
||||
$ing['stock_remain'] = max(0, round($stockDisp - $useDisp, 2));
|
||||
$ing['stock_unit'] = $dispUnit;
|
||||
return;
|
||||
}
|
||||
|
||||
$remainDisp = $stockDisp - $useDisp;
|
||||
if (recipeShouldUseAllRemainder($remainDisp, $ing, $stockDisp)) {
|
||||
$ing['use_all_suggested'] = true;
|
||||
$useDisp = $stockDisp;
|
||||
$remainDisp = 0;
|
||||
if ($isConfSub) {
|
||||
$ing['qty_number'] = round($useDisp, 1);
|
||||
$ing['qty'] = round($useDisp) . ' ' . $pkgUnit;
|
||||
} else {
|
||||
$ing['qty_number'] = round($totalStockQty, 3);
|
||||
if ($unit === 'pz') {
|
||||
$ing['qty'] = round($totalStockQty, 2) . ' pz';
|
||||
} else {
|
||||
$ing['qty'] = round($totalStockQty, ($unit === 'g' || $unit === 'ml') ? 0 : 2) . ' ' . $unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ing['stock_have'] = round($stockDisp, 2);
|
||||
$ing['stock_remain'] = round($remainDisp, 2);
|
||||
$ing['stock_unit'] = $dispUnit;
|
||||
}
|
||||
|
||||
function recipeApplyStockHintsToRecipe(PDO $db, array &$recipe): void {
|
||||
if (empty($recipe['ingredients']) || !is_array($recipe['ingredients'])) return;
|
||||
foreach ($recipe['ingredients'] as &$ing) {
|
||||
if (empty($ing['from_pantry']) || empty($ing['product_id'])) continue;
|
||||
$totalStock = recipeGetProductTotalStock($db, (int)$ing['product_id']);
|
||||
if ($totalStock <= 0) continue;
|
||||
$ing['inventory_qty_total'] = $totalStock;
|
||||
recipeFinalizeIngQty($ing, $totalStock);
|
||||
}
|
||||
unset($ing);
|
||||
}
|
||||
|
||||
// ===== RECIPE GENERATION WITH GEMINI =====
|
||||
function generateRecipe(PDO $db): void {
|
||||
EverLog::debug('generateRecipe start');
|
||||
@@ -6071,9 +6465,14 @@ PROMPT;
|
||||
if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) {
|
||||
$defQty = (float)($bestMatch['default_quantity'] ?? 0);
|
||||
$pkgUnitLC = strtolower($bestMatch['package_unit'] ?? '');
|
||||
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) {
|
||||
$qtyNum = round($qtyNum * $defQty);
|
||||
$ing['qty'] = $qtyNum . ' ' . $pkgUnitLC;
|
||||
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) {
|
||||
if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) {
|
||||
$qtyNum = $recipeVal;
|
||||
$ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC;
|
||||
} elseif ($qtyNum <= $invQty) {
|
||||
$qtyNum = round($qtyNum * $defQty);
|
||||
$ing['qty'] = $qtyNum . ' ' . $pkgUnitLC;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sanity check: qty_number should not exceed available
|
||||
@@ -6093,6 +6492,7 @@ PROMPT;
|
||||
}
|
||||
}
|
||||
unset($ing);
|
||||
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||
}
|
||||
|
||||
EverLog::info('recipe generated', ['title' => $recipe['title'] ?? '?', 'meal' => $mealType, 'persons' => $persons, 'ingredients' => count($recipe['ingredients'] ?? [])]);
|
||||
@@ -6191,6 +6591,7 @@ PROMPT;
|
||||
if (!empty($recipe['ingredients'])) {
|
||||
_enrichChatIngredients($recipe['ingredients'], $items);
|
||||
}
|
||||
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||
|
||||
echo json_encode(['success' => true, 'recipe' => $recipe]);
|
||||
}
|
||||
@@ -6305,6 +6706,7 @@ PROMPT;
|
||||
if (!empty($recipe['ingredients'])) {
|
||||
_enrichChatIngredients($recipe['ingredients'], $items);
|
||||
}
|
||||
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||
|
||||
EverLog::info('recipe_from_ingredient ok', ['ingredient' => $ingredientName, 'title' => $recipe['title'] ?? '?', 'persons' => $persons]);
|
||||
echo json_encode(['success' => true, 'recipe' => $recipe]);
|
||||
@@ -6461,8 +6863,14 @@ function _enrichChatIngredients(array &$ingredients, array $items): void {
|
||||
if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) {
|
||||
$defQty = (float)($bestMatch['default_quantity'] ?? 0);
|
||||
$pkgUnitLC = strtolower($bestMatch['package_unit'] ?? '');
|
||||
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) {
|
||||
$qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC;
|
||||
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) {
|
||||
if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) {
|
||||
$qtyNum = $recipeVal;
|
||||
$ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC;
|
||||
} elseif ($qtyNum <= $invQty) {
|
||||
$qtyNum = round($qtyNum * $defQty);
|
||||
$ing['qty'] = $qtyNum . ' ' . $pkgUnitLC;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($qtyNum > $invQty) $qtyNum = $invQty;
|
||||
@@ -7024,8 +7432,14 @@ PROMPT;
|
||||
if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) {
|
||||
$defQty = (float)($bestMatch['default_quantity'] ?? 0);
|
||||
$pkgUnitLC = strtolower($bestMatch['package_unit'] ?? '');
|
||||
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) {
|
||||
$qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC;
|
||||
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) {
|
||||
if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) {
|
||||
$qtyNum = $recipeVal;
|
||||
$ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC;
|
||||
} elseif ($qtyNum <= $invQty) {
|
||||
$qtyNum = round($qtyNum * $defQty);
|
||||
$ing['qty'] = $qtyNum . ' ' . $pkgUnitLC;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($qtyNum > $invQty) $qtyNum = $invQty;
|
||||
@@ -7035,6 +7449,7 @@ PROMPT;
|
||||
}
|
||||
}
|
||||
unset($ing);
|
||||
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||
}
|
||||
|
||||
$send('status', ['step' => 4, 'message' => '✅ Ricetta pronta!']);
|
||||
|
||||
@@ -4671,6 +4671,13 @@ body.server-offline .bottom-nav {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.recipe-ing-stock {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== SHOPPING SECTION (REPARTO) HEADERS ===== */
|
||||
.shopping-section-divider {
|
||||
display: flex;
|
||||
|
||||
+543
-157
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -72,7 +72,7 @@
|
||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.35</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.36</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.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>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -1970,6 +1970,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260603c"></script>
|
||||
<script src="assets/js/app.js?v=20260604c"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.35",
|
||||
"version": "1.7.36",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
+47
-12
@@ -143,8 +143,10 @@
|
||||
"banner_prediction_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.",
|
||||
"banner_prediction_less": "Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}. Wenn sich dein Verbrauch geändert hat, aktualisiert sich die Prognose automatisch.",
|
||||
"banner_finished_zero": "Bestand zeigt null, aber gespeicherte Buchungen deuten an, dass es nicht leer sein sollte.",
|
||||
"banner_finished_vanished": "Das Produkt erscheint nicht mehr im Bestand, aber die Buchungen deuten an, dass es nicht leer sein sollte.",
|
||||
"banner_finished_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.",
|
||||
"banner_finished_check": "Kannst du nachschauen?",
|
||||
"banner_finished_action_restore": "{qty} {unit} wiederherstellen",
|
||||
"banner_anomaly_phantom_title": "mehr Bestand als erwartet",
|
||||
"banner_anomaly_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?",
|
||||
"banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht",
|
||||
@@ -164,7 +166,11 @@
|
||||
"banner_opened_detail": "{when} in {location} · du hast noch <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Gemini um eine Erklärung bitten",
|
||||
"banner_explain_btn": "Erklären",
|
||||
"banner_analyzing": "🤖 Analysiere…"
|
||||
"banner_analyzing": "🤖 Analysiere…",
|
||||
"banner_prediction_confirmed": "✅ Bestätigt — Prognosen werden aus den nächsten Einträgen neu berechnet",
|
||||
"banner_anomaly_explain_fail": "KI-Erklärung konnte nicht abgerufen werden",
|
||||
"banner_anomaly_dismissed": "Anomalie ignoriert",
|
||||
"banner_finished_restore_prompt": "Wie viele {unit} {name} hast du noch? (Systemschätzung: {qty})"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Vorrat",
|
||||
@@ -243,7 +249,8 @@
|
||||
"ai_match_none": "Keine ahnlichen Produkte in der Vorratskammer gefunden.",
|
||||
"ai_match_use_btn": "Dieses nutzen",
|
||||
"ai_match_add_btn": "\"{name}\" hinzufugen",
|
||||
"ai_detected_label": "KI erkannt"
|
||||
"ai_detected_label": "KI erkannt",
|
||||
"mode_shopping_activated": "🛒 Einkaufsmodus aktiviert!"
|
||||
},
|
||||
"action": {
|
||||
"title": "Was möchtest du tun?",
|
||||
@@ -316,14 +323,17 @@
|
||||
"toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
|
||||
"toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!",
|
||||
"disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?",
|
||||
"disambiguation_one_conf": "<strong>1 Packung</strong> aufgebraucht ({qty})",
|
||||
"disambiguation_all": "🗑️ ALLES verbraucht ({qty})",
|
||||
"toast_one_conf_finished": "📦 1 Packung von {name} verbraucht!",
|
||||
"error_exceeds_stock": "⚠️ Du kannst nicht mehr verwenden als du verfügbar hast!",
|
||||
"use_all_confirm_title": "✅ Alles aufbrauchen",
|
||||
"use_all_confirm_msg": "Bestätige, dass du das Produkt vollständig aufgebraucht hast:",
|
||||
"use_all_confirm_btn": "✅ Ja, aufgebraucht",
|
||||
"throw_all_confirm_title": "🗑️ Alles entsorgen",
|
||||
"throw_all_confirm_msg": "Möchtest du wirklich das gesamte Produkt entsorgen?",
|
||||
"throw_all_confirm_btn": "🗑️ Ja, entsorgen"
|
||||
"throw_all_confirm_btn": "🗑️ Ja, entsorgen",
|
||||
"locations_short": "Orte"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Neues Produkt",
|
||||
@@ -363,7 +373,9 @@
|
||||
"weight_label": "Gewicht",
|
||||
"origin_label": "Herkunft",
|
||||
"labels_label": "Etiketten",
|
||||
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:"
|
||||
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:",
|
||||
"history_badge": "📊 Verlauf",
|
||||
"from_history": " (aus Verlauf)"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Alle Produkte",
|
||||
@@ -425,7 +437,9 @@
|
||||
"nutrition_per_serving": "Geschätzte Werte pro Portion",
|
||||
"storage_title": "Aufbewahrung von Resten",
|
||||
"storage_days": "{n} Tage",
|
||||
"storage_immediately": "Am besten sofort verzehren"
|
||||
"storage_immediately": "Am besten sofort verzehren",
|
||||
"ing_stock_line": "Du hast {have} · {remain} bleiben nach Gebrauch",
|
||||
"ing_use_all_note": "alles verwenden (<5% der Vollpackung)"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Einkaufsliste",
|
||||
@@ -512,6 +526,7 @@
|
||||
"remove_error": "Fehler beim Entfernen",
|
||||
"btn_fetch_prices": "Preise suchen",
|
||||
"price_total_label": "💰 Geschätzter Gesamtpreis:",
|
||||
"price_total_short": "geschätzte Ausgaben",
|
||||
"price_loading": "Preise werden gesucht…",
|
||||
"price_not_found": "Preis n/v",
|
||||
"suggest_loading": "Analyse läuft...",
|
||||
@@ -521,7 +536,8 @@
|
||||
"priority_low": "Niedrig",
|
||||
"smart_last_update": "Aktualisiert {time}",
|
||||
"names_already_updated": "Alle Namen sind bereits aktuell",
|
||||
"pantry_hint": "Bereits zuhause: {qty}"
|
||||
"pantry_hint": "Bereits zuhause: {qty}",
|
||||
"bring_names_migrated": "🔄 {n} Namen in Bring! verallgemeinert"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 KI-Identifikation",
|
||||
@@ -532,7 +548,8 @@
|
||||
"no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>",
|
||||
"fields_filled": "✅ Felder von KI ausgefüllt",
|
||||
"use_data": "✅ KI-Daten verwenden",
|
||||
"use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)"
|
||||
"use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)",
|
||||
"conservation_hint": "🤖 KI: lagere in {location}"
|
||||
},
|
||||
"log": {
|
||||
"title": "📒 Verlauf",
|
||||
@@ -787,7 +804,13 @@
|
||||
"kiosk_title": "📡 BLE-Waage im Kiosk integriert",
|
||||
"kiosk_hint": "Die Waage wird direkt vom internen BLE-Gateway des Kiosks verwaltet. Um ein neues Gerät zu koppeln, verwende den Konfigurationsassistenten.",
|
||||
"kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren",
|
||||
"ble_protocols": "<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) — Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch — 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) — Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch — 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": {
|
||||
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
|
||||
@@ -974,7 +997,8 @@
|
||||
"sensor_copied": "YAML in die Zwischenablage kopiert!",
|
||||
"save_btn": "HA-Einstellungen speichern",
|
||||
"ha_hint": "Wenn du Home Assistant verwendest, nutze den Home Assistant-Tab für TTS, Webhooks und Sensoren."
|
||||
}
|
||||
},
|
||||
"kiosk_update_required": "⚠️ Aktualisiere die Kiosk-App, um diese Funktion zu nutzen"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "HEUTE",
|
||||
@@ -1048,6 +1072,7 @@
|
||||
"finished_all": "📤 {name} aufgebraucht!",
|
||||
"vacuum_sealed": "{name} als vakuumversiegelt gespeichert",
|
||||
"product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst",
|
||||
"ghost_restored": "✅ {name}: {qty} {unit} im Bestand wiederhergestellt",
|
||||
"appliance_added": "Gerät hinzugefügt",
|
||||
"item_added": "{name} hinzugefügt"
|
||||
},
|
||||
@@ -1119,7 +1144,9 @@
|
||||
"offline_ops_pending": "{n} Aktionen ausstehend",
|
||||
"offline_synced": "{n} Aktionen synchronisiert",
|
||||
"offline_ai_disabled": "Offline nicht verfügbar",
|
||||
"offline_cache_ready": "Offline — {n} Produkte im Cache"
|
||||
"offline_cache_ready": "Offline — {n} Produkte im Cache",
|
||||
"copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen",
|
||||
"invalid_quantity": "Ungültige Menge"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
@@ -1241,7 +1268,8 @@
|
||||
"stay_btn": "Nein, bleibt in {location}",
|
||||
"moved_toast": "📦 Offene Packung bewegt nach {location}",
|
||||
"vacuum_restore": "Vakuum wiederherstellen",
|
||||
"vacuum_seal_rest": "Rest vakuumieren"
|
||||
"vacuum_seal_rest": "Rest vakuumieren",
|
||||
"moved_simple": "📦 Nach {location} verschoben"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Unverarbeitet",
|
||||
@@ -1494,5 +1522,12 @@
|
||||
"top_used": "meistbenutzt",
|
||||
"top_cats": "Hauptkategorien",
|
||||
"source": "Transaktionsverlauf · aktueller Monat"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "gerade eben",
|
||||
"seconds_ago": "vor {n}s",
|
||||
"minutes_ago": "vor {n} min",
|
||||
"hours_ago": "vor {n} h",
|
||||
"days_ago": "vor {n} T"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-12
@@ -143,8 +143,10 @@
|
||||
"banner_prediction_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.",
|
||||
"banner_prediction_less": "estimate: {expected} {unit}{time}; current quantity: {actual} {unit}. If your usage pace changed, the forecast updates automatically.",
|
||||
"banner_finished_zero": "Inventory shows zero, but recorded movements suggest it shouldn't be empty.",
|
||||
"banner_finished_vanished": "This product no longer appears in inventory, but recorded movements suggest it shouldn't be empty.",
|
||||
"banner_finished_expected": "According to records you should still have {qty} {unit}.",
|
||||
"banner_finished_check": "Can you check?",
|
||||
"banner_finished_action_restore": "Restore {qty} {unit}",
|
||||
"banner_anomaly_phantom_title": "you have more stock than expected",
|
||||
"banner_anomaly_phantom_detail": "Inventory shows {inv_qty} {unit}, but based on records you should only have {expected_qty} {unit}. Did you add stock without recording it?",
|
||||
"banner_anomaly_untracked_title": "stock not recorded as an entry",
|
||||
@@ -164,7 +166,11 @@
|
||||
"banner_opened_detail": "{when} in {location} · you still have <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Ask Gemini for an explanation",
|
||||
"banner_explain_btn": "Explain",
|
||||
"banner_analyzing": "🤖 Analyzing…"
|
||||
"banner_analyzing": "🤖 Analyzing…",
|
||||
"banner_prediction_confirmed": "✅ Confirmed — forecasts will recalculate from your next entries",
|
||||
"banner_anomaly_explain_fail": "Could not get AI explanation",
|
||||
"banner_anomaly_dismissed": "Anomaly dismissed",
|
||||
"banner_finished_restore_prompt": "How many {unit} of {name} do you still have? (system estimate: {qty})"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Pantry",
|
||||
@@ -243,7 +249,8 @@
|
||||
"ai_match_none": "No similar pantry products found.",
|
||||
"ai_match_use_btn": "Use this",
|
||||
"ai_match_add_btn": "Add \"{name}\"",
|
||||
"ai_detected_label": "AI detected"
|
||||
"ai_detected_label": "AI detected",
|
||||
"mode_shopping_activated": "🛒 Shopping mode activated!"
|
||||
},
|
||||
"action": {
|
||||
"title": "What do you want to do?",
|
||||
@@ -316,14 +323,17 @@
|
||||
"toast_bring": "🛒 Product finished → added to Bring!",
|
||||
"toast_opened_finished": "🔓 Opened package of {name} finished!",
|
||||
"disambiguation_hint": "What do you mean by \"all done\"?",
|
||||
"disambiguation_one_conf": "Finished <strong>1 package</strong> ({qty})",
|
||||
"disambiguation_all": "🗑️ Finish EVERYTHING ({qty})",
|
||||
"toast_one_conf_finished": "📦 1 package of {name} finished!",
|
||||
"error_exceeds_stock": "⚠️ You cannot use more than you have available!",
|
||||
"use_all_confirm_title": "✅ Finish everything",
|
||||
"use_all_confirm_msg": "Confirm that you have finished the product:",
|
||||
"use_all_confirm_btn": "✅ Yes, finished",
|
||||
"throw_all_confirm_title": "🗑️ Discard everything",
|
||||
"throw_all_confirm_msg": "Do you really want to throw away the whole product?",
|
||||
"throw_all_confirm_btn": "🗑️ Yes, discard"
|
||||
"throw_all_confirm_btn": "🗑️ Yes, discard",
|
||||
"locations_short": "places"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "New Product",
|
||||
@@ -363,7 +373,9 @@
|
||||
"weight_label": "Weight",
|
||||
"origin_label": "Origin",
|
||||
"labels_label": "Labels",
|
||||
"select_variant": "Select the exact variant or use AI data:"
|
||||
"select_variant": "Select the exact variant or use AI data:",
|
||||
"history_badge": "📊 history",
|
||||
"from_history": " (from history)"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 All Products",
|
||||
@@ -425,7 +437,9 @@
|
||||
"nutrition_per_serving": "Estimated values per serving",
|
||||
"storage_title": "How to store leftovers",
|
||||
"storage_days": "{n} days",
|
||||
"storage_immediately": "Best eaten immediately"
|
||||
"storage_immediately": "Best eaten immediately",
|
||||
"ing_stock_line": "You have {have} · {remain} left after use",
|
||||
"ing_use_all_note": "use all (<5% of full package left)"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Shopping List",
|
||||
@@ -512,6 +526,7 @@
|
||||
"remove_error": "Removal error",
|
||||
"btn_fetch_prices": "Find prices",
|
||||
"price_total_label": "💰 Estimated total:",
|
||||
"price_total_short": "estimated total",
|
||||
"price_loading": "Looking up prices…",
|
||||
"price_not_found": "price n/a",
|
||||
"suggest_loading": "Analyzing...",
|
||||
@@ -521,7 +536,8 @@
|
||||
"priority_low": "Low",
|
||||
"smart_last_update": "Updated {time}",
|
||||
"names_already_updated": "All names are already up to date",
|
||||
"pantry_hint": "Already at home: {qty}"
|
||||
"pantry_hint": "Already at home: {qty}",
|
||||
"bring_names_migrated": "🔄 {n} names generalized in Bring!"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 AI Identification",
|
||||
@@ -532,7 +548,8 @@
|
||||
"no_api_key": "⚠️ Gemini API key not configured.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>",
|
||||
"fields_filled": "✅ Fields filled by AI",
|
||||
"use_data": "✅ Use AI data",
|
||||
"use_data_no_barcode": "✅ Use AI data (no barcode)"
|
||||
"use_data_no_barcode": "✅ Use AI data (no barcode)",
|
||||
"conservation_hint": "🤖 AI: store in {location}"
|
||||
},
|
||||
"log": {
|
||||
"title": "📒 Operations Log",
|
||||
@@ -787,7 +804,13 @@
|
||||
"kiosk_title": "📡 BLE Scale integrated in Kiosk",
|
||||
"kiosk_hint": "The scale is directly managed by the internal BLE Gateway of the kiosk. To pair a new device, use the configuration wizard.",
|
||||
"kiosk_reconfigure": "🔄 Reconfigure BLE Scale",
|
||||
"ble_protocols": "<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) — weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic — 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) — weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic — 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": {
|
||||
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
|
||||
@@ -974,7 +997,8 @@
|
||||
"sensor_copied": "YAML copied to clipboard!",
|
||||
"save_btn": "Save HA settings",
|
||||
"ha_hint": "If you use Home Assistant, use the Home Assistant tab to configure TTS, webhooks and sensors."
|
||||
}
|
||||
},
|
||||
"kiosk_update_required": "⚠️ Update the kiosk app to use this feature"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "TODAY",
|
||||
@@ -1048,6 +1072,7 @@
|
||||
"finished_all": "📤 {name} finished!",
|
||||
"vacuum_sealed": "{name} saved as vacuum sealed",
|
||||
"product_finished_confirmed": "✅ Removed — add it again when you restock",
|
||||
"ghost_restored": "✅ {name}: restored {qty} {unit} to inventory",
|
||||
"appliance_added": "Appliance added",
|
||||
"item_added": "{name} added"
|
||||
},
|
||||
@@ -1119,7 +1144,9 @@
|
||||
"offline_ops_pending": "{n} operations pending",
|
||||
"offline_synced": "{n} operations synced",
|
||||
"offline_ai_disabled": "Not available offline",
|
||||
"offline_cache_ready": "Offline — {n} items cached"
|
||||
"offline_cache_ready": "Offline — {n} items cached",
|
||||
"copy_failed": "Copy to clipboard failed",
|
||||
"invalid_quantity": "Invalid quantity"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
@@ -1241,7 +1268,8 @@
|
||||
"stay_btn": "No, stay in {location}",
|
||||
"moved_toast": "📦 Opened package moved to {location}",
|
||||
"vacuum_restore": "Restore vacuum sealed",
|
||||
"vacuum_seal_rest": "Vacuum seal the rest"
|
||||
"vacuum_seal_rest": "Vacuum seal the rest",
|
||||
"moved_simple": "📦 Moved to {location}"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Unprocessed",
|
||||
@@ -1494,5 +1522,12 @@
|
||||
"top_used": "top used",
|
||||
"top_cats": "Top categories",
|
||||
"source": "Transaction history · current month"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "just now",
|
||||
"seconds_ago": "{n}s ago",
|
||||
"minutes_ago": "{n} min ago",
|
||||
"hours_ago": "{n} h ago",
|
||||
"days_ago": "{n} d ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+109
-17
@@ -141,8 +141,10 @@
|
||||
"banner_prediction_more": "estimación anterior: {expected} {unit}{time}; cantidad actual: {actual} {unit}.",
|
||||
"banner_prediction_less": "estimación: {expected} {unit}{time}; cantidad actual: {actual} {unit}. Si tu ritmo de uso cambió, la previsión se actualiza automáticamente.",
|
||||
"banner_finished_zero": "El inventario muestra cero, pero los movimientos registrados sugieren que no debería estar vacío.",
|
||||
"banner_finished_vanished": "Este producto ya no aparece en el inventario, pero los movimientos registrados sugieren que no debería estar vacío.",
|
||||
"banner_finished_expected": "Según los registros deberías tener todavía {qty} {unit}.",
|
||||
"banner_finished_check": "¿Puedes comprobarlo?",
|
||||
"banner_finished_action_restore": "Restaurar {qty} {unit}",
|
||||
"banner_anomaly_phantom_title": "tienes más stock del esperado",
|
||||
"banner_anomaly_phantom_detail": "El inventario indica {inv_qty} {unit}, pero según los registros solo deberías tener {expected_qty} {unit}. ¿Añadiste stock sin registrarlo?",
|
||||
"banner_anomaly_untracked_title": "stock no registrado como entrada",
|
||||
@@ -162,7 +164,13 @@
|
||||
"banner_opened_detail": "{when} en {location} · aún tienes <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Pedir explicación a Gemini",
|
||||
"banner_explain_btn": "Explicar",
|
||||
"banner_analyzing": "🤖 Analizando…"
|
||||
"banner_analyzing": "🤖 Analizando…",
|
||||
"banner_expired_action_modify": "Editar",
|
||||
"banner_expired_action_vacuum": "Poner al vacío",
|
||||
"banner_prediction_confirmed": "✅ Confirmado — las previsiones se recalcularán con tus próximos registros",
|
||||
"banner_anomaly_explain_fail": "No se pudo obtener la explicación de IA",
|
||||
"banner_anomaly_dismissed": "Anomalía descartada",
|
||||
"banner_finished_restore_prompt": "¿Cuántas {unit} de {name} te quedan? (estimación del sistema: {qty})"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Despensa",
|
||||
@@ -240,7 +248,9 @@
|
||||
"ai_match_none": "No se encontraron productos similares en despensa.",
|
||||
"ai_match_use_btn": "Usar este",
|
||||
"ai_match_add_btn": "Agregar \"{name}\"",
|
||||
"ai_detected_label": "IA detecto"
|
||||
"ai_detected_label": "IA detecto",
|
||||
"stock_in_pantry": "Ya en despensa:",
|
||||
"mode_shopping_activated": "🛒 ¡Modo compras activado!"
|
||||
},
|
||||
"action": {
|
||||
"title": "¿Qué quieres hacer?",
|
||||
@@ -254,7 +264,8 @@
|
||||
"throw_btn": "🗑️ DESECHAR",
|
||||
"throw_sub": "tirar",
|
||||
"edit_sub": "caducidad, ubicación…",
|
||||
"create_recipe_btn": "Receta"
|
||||
"create_recipe_btn": "Receta",
|
||||
"related_stock_title": "También en casa"
|
||||
},
|
||||
"add": {
|
||||
"title": "Añadir a la despensa",
|
||||
@@ -312,14 +323,17 @@
|
||||
"toast_bring": "🛒 Producto terminado → añadido a Bring!",
|
||||
"toast_opened_finished": "🔓 ¡Paquete abierto de {name} terminado!",
|
||||
"disambiguation_hint": "¿Qué quieres decir con «todo terminado»?",
|
||||
"disambiguation_one_conf": "Terminado <strong>1 envase</strong> ({qty})",
|
||||
"disambiguation_all": "🗑️ Terminar TODO ({qty})",
|
||||
"toast_one_conf_finished": "📦 1 envase de {name} terminado!",
|
||||
"error_exceeds_stock": "⚠️ ¡No puedes usar más de lo que tienes disponible!",
|
||||
"use_all_confirm_title": "✅ Terminar todo",
|
||||
"use_all_confirm_msg": "Confirma que has terminado el producto:",
|
||||
"use_all_confirm_btn": "✅ Sí, terminado",
|
||||
"throw_all_confirm_title": "🗑️ Desechar todo",
|
||||
"throw_all_confirm_msg": "¿Realmente quieres tirar todo el producto?",
|
||||
"throw_all_confirm_btn": "🗑️ Sí, desechar"
|
||||
"throw_all_confirm_btn": "🗑️ Sí, desechar",
|
||||
"locations_short": "ubicaciones"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nuevo producto",
|
||||
@@ -359,7 +373,9 @@
|
||||
"weight_label": "Peso",
|
||||
"origin_label": "Origen",
|
||||
"labels_label": "Etiquetas",
|
||||
"select_variant": "Selecciona la variante exacta o usa los datos de IA:"
|
||||
"select_variant": "Selecciona la variante exacta o usa los datos de IA:",
|
||||
"history_badge": "📊 historial",
|
||||
"from_history": " (del historial)"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Todos los productos",
|
||||
@@ -420,7 +436,10 @@
|
||||
"nutrition_per_serving": "Valores estimados por ración",
|
||||
"storage_title": "Cómo conservar las sobras",
|
||||
"storage_days": "{n} días",
|
||||
"storage_immediately": "Mejor consumir de inmediato"
|
||||
"storage_immediately": "Mejor consumir de inmediato",
|
||||
"stream_interrupted": "Generación interrumpida (respuesta del servidor incompleta). Revisa los logs o inténtalo de nuevo.",
|
||||
"ing_stock_line": "Tienes {have} · quedan {remain} después del uso",
|
||||
"ing_use_all_note": "usar todo (<5% del envase completo)"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Lista de la compra",
|
||||
@@ -507,6 +526,7 @@
|
||||
"remove_error": "Error al eliminar",
|
||||
"btn_fetch_prices": "Buscar precios",
|
||||
"price_total_label": "💰 Total estimado:",
|
||||
"price_total_short": "total estimado",
|
||||
"price_loading": "Buscando precios…",
|
||||
"price_not_found": "precio n/d",
|
||||
"suggest_loading": "Analizando...",
|
||||
@@ -515,7 +535,9 @@
|
||||
"priority_medium": "Media",
|
||||
"priority_low": "Baja",
|
||||
"smart_last_update": "Actualizado {time}",
|
||||
"names_already_updated": "Todos los nombres ya están actualizados"
|
||||
"names_already_updated": "Todos los nombres ya están actualizados",
|
||||
"pantry_hint": "Ya en casa: {qty}",
|
||||
"bring_names_migrated": "🔄 {n} nombres generalizados en Bring!"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 Identificación IA",
|
||||
@@ -526,7 +548,8 @@
|
||||
"no_api_key": "⚠️ Clave API de Gemini no configurada.\n<small>Añade GEMINI_API_KEY al archivo .env en el servidor.</small>",
|
||||
"fields_filled": "✅ Campos rellenados por IA",
|
||||
"use_data": "✅ Usar datos de IA",
|
||||
"use_data_no_barcode": "✅ Usar datos de IA (sin código de barras)"
|
||||
"use_data_no_barcode": "✅ Usar datos de IA (sin código de barras)",
|
||||
"conservation_hint": "🤖 IA: conserva en {location}"
|
||||
},
|
||||
"log": {
|
||||
"title": "📒 Registro de operaciones",
|
||||
@@ -742,7 +765,8 @@
|
||||
"heard_yes": "Sí, la escuché",
|
||||
"heard_no": "No, no escuché nada",
|
||||
"test_ok_kiosk": "TTS funcionando.",
|
||||
"test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android."
|
||||
"test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android.",
|
||||
"test_sound_btn": "🔔 Prueba de sonido"
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Idioma",
|
||||
@@ -780,7 +804,13 @@
|
||||
"kiosk_title": "📡 Báscula BLE integrada en el kiosco",
|
||||
"kiosk_hint": "La báscula está gestionada directamente por la pasarela BLE interna del kiosco. Para vincular un nuevo dispositivo, usa el asistente de configuración.",
|
||||
"kiosk_reconfigure": "🔄 Reconfigurar báscula BLE",
|
||||
"ble_protocols": "<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) — peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico — 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) — peso, grasa, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Genérico — 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": {
|
||||
"hint": "Convierte una tableta Android en un panel EverShelf permanente con pasarela BLE integrada.",
|
||||
@@ -926,7 +956,49 @@
|
||||
"sensor_copied": "¡YAML copiado al portapapeles!",
|
||||
"save_btn": "Guardar ajustes HA",
|
||||
"ha_hint": "Si usas Home Assistant, utiliza la pestaña Home Assistant para configurar TTS, webhooks y sensores."
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"tab": "Info",
|
||||
"ai_title": "Gemini AI — Uso de tokens",
|
||||
"ai_hint": "Consumo mensual y coste estimado para la clave API actual.",
|
||||
"loading": "Cargando…",
|
||||
"total_tokens": "Tokens totales",
|
||||
"est_cost": "Coste est.",
|
||||
"input_tok": "Tokens de entrada",
|
||||
"output_tok": "Tokens de salida",
|
||||
"ai_calls": "Llamadas",
|
||||
"by_action": "Desglose por función",
|
||||
"by_model": "Desglose por modelo",
|
||||
"pricing_note": "Precios Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
|
||||
"system_title": "Sistema",
|
||||
"db_size": "Base de datos",
|
||||
"log_size": "Logs",
|
||||
"log_level": "Nivel de log",
|
||||
"ai_overview": "Resumen de IA, inventario y estado del sistema",
|
||||
"calls_unit": "llamadas",
|
||||
"inv_title": "Inventario",
|
||||
"inv_active": "Activos",
|
||||
"inv_products": "Productos totales",
|
||||
"inv_expiring": "Caducan (7d)",
|
||||
"inv_expired": "Caducados",
|
||||
"inv_finished": "Agotados",
|
||||
"act_title": "Actividad mensual",
|
||||
"act_tx_month": "Movimientos",
|
||||
"act_restock": "Reabastecimientos",
|
||||
"act_use": "Usos",
|
||||
"act_new_products": "Productos nuevos",
|
||||
"act_tx_year": "Movimientos anuales",
|
||||
"price_cache": "Caché de precios",
|
||||
"cache_entries": "productos",
|
||||
"last_backup": "Última copia",
|
||||
"bring_days": "token expira en {n} días",
|
||||
"bring_expired": "token expirado",
|
||||
"year_label": "Año {year}",
|
||||
"currency_title": "Moneda",
|
||||
"currency_hint": "Moneda usada para todos los costes y precios en la app."
|
||||
},
|
||||
"tab_general": "General",
|
||||
"kiosk_update_required": "⚠️ Actualiza la app kiosk para usar esta función"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "HOY",
|
||||
@@ -999,8 +1071,10 @@
|
||||
"thrown_away_partial": "🗑️ {qty} {unit} de {name} tirado(s)",
|
||||
"finished_all": "📤 ¡{name} terminado!",
|
||||
"product_finished_confirmed": "✅ Eliminado — añádelo de nuevo cuando reabastezcas",
|
||||
"ghost_restored": "✅ {name}: restaurados {qty} {unit} en el inventario",
|
||||
"appliance_added": "Electrodoméstico añadido",
|
||||
"item_added": "{name} añadido"
|
||||
"item_added": "{name} añadido",
|
||||
"vacuum_sealed": "{name} guardado al vacío"
|
||||
},
|
||||
"antiwaste": {
|
||||
"title": "🌱 Informe anti-desperdicio",
|
||||
@@ -1070,7 +1144,9 @@
|
||||
"offline_ops_pending": "{n} operaciones pendientes",
|
||||
"offline_synced": "{n} operaciones sincronizadas",
|
||||
"offline_ai_disabled": "No disponible sin conexión",
|
||||
"offline_cache_ready": "Offline — {n} productos en caché"
|
||||
"offline_cache_ready": "Offline — {n} productos en caché",
|
||||
"copy_failed": "Error al copiar al portapapeles",
|
||||
"invalid_quantity": "Cantidad no válida"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
@@ -1171,7 +1247,10 @@
|
||||
"retake_btn": "🔄 Repetir",
|
||||
"camera_error_hint": "Asegúrate de usar HTTPS y haber concedido los permisos de cámara.<br>Puedes introducir el código de barras manualmente o usar la identificación IA.",
|
||||
"no_barcode": "Sin código de barras",
|
||||
"save_new_btn": "🆕 Ninguno de estos — guardar como nuevo"
|
||||
"save_new_btn": "🆕 Ninguno de estos — guardar como nuevo",
|
||||
"expiry_found": "Fecha encontrada",
|
||||
"expiry_read_fail": "No se puede leer la fecha.",
|
||||
"expiry_raw_label": "Leído"
|
||||
},
|
||||
"lowstock": {
|
||||
"title": "⚠️ ¡Stock bajo!",
|
||||
@@ -1189,7 +1268,8 @@
|
||||
"stay_btn": "No, quedarse en {location}",
|
||||
"moved_toast": "📦 Paquete abierto movido a {location}",
|
||||
"vacuum_restore": "🫙 Restaurar al vacío",
|
||||
"vacuum_seal_rest": "🔒 Sellar el resto al vacío"
|
||||
"vacuum_seal_rest": "🔒 Sellar el resto al vacío",
|
||||
"moved_simple": "📦 Movido a {location}"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Sin procesar",
|
||||
@@ -1424,7 +1504,12 @@
|
||||
"token_autoconfig": "Configurando acceso...",
|
||||
"token_prompt_title": "🔒 Token API",
|
||||
"token_prompt_hint": "Introduce el valor API_TOKEN del archivo .env del servidor.",
|
||||
"token_prompt_btn": "Continuar"
|
||||
"token_prompt_btn": "Continuar",
|
||||
"check_db_legacy": "BD antigua (dispensa.db)",
|
||||
"check_tts": "URL texto a voz",
|
||||
"check_scale": "Pasarela báscula",
|
||||
"critical_error_intro": "La app no puede iniciarse por los siguientes problemas:",
|
||||
"error_network_detail": "El navegador no puede conectar con el servidor PHP.\n\nPosibles causas:\n• Apache/PHP no está en ejecución\n• Problema de red o firewall\n• URL incorrecta\n\nInicia el servidor e inténtalo de nuevo."
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Estadísticas Mensuales",
|
||||
@@ -1437,5 +1522,12 @@
|
||||
"top_used": "más usado",
|
||||
"top_cats": "Categorías principales",
|
||||
"source": "Historial de transacciones · mes actual"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "ahora",
|
||||
"seconds_ago": "hace {n}s",
|
||||
"minutes_ago": "hace {n} min",
|
||||
"hours_ago": "hace {n} h",
|
||||
"days_ago": "hace {n} d"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+109
-17
@@ -141,8 +141,10 @@
|
||||
"banner_prediction_more": "estimation précédente : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}.",
|
||||
"banner_prediction_less": "estimation : {expected} {unit}{time} ; quantité actuelle : {actual} {unit}. Si votre rythme d'utilisation a changé, la prévision se met à jour automatiquement.",
|
||||
"banner_finished_zero": "L'inventaire indique zéro, mais les mouvements enregistrés suggèrent qu'il ne devrait pas être vide.",
|
||||
"banner_finished_vanished": "Ce produit n'apparaît plus dans l'inventaire, mais les mouvements enregistrés suggèrent qu'il ne devrait pas être vide.",
|
||||
"banner_finished_expected": "D'après les enregistrements vous devriez avoir encore {qty} {unit}.",
|
||||
"banner_finished_check": "Pouvez-vous vérifier ?",
|
||||
"banner_finished_action_restore": "Restaurer {qty} {unit}",
|
||||
"banner_anomaly_phantom_title": "vous avez plus de stock que prévu",
|
||||
"banner_anomaly_phantom_detail": "L'inventaire indique {inv_qty} {unit}, mais selon les enregistrements vous ne devriez avoir que {expected_qty} {unit}. Avez-vous ajouté du stock sans l'enregistrer ?",
|
||||
"banner_anomaly_untracked_title": "stock non enregistré comme entrée",
|
||||
@@ -162,7 +164,13 @@
|
||||
"banner_opened_detail": "{when} dans {location} · il vous reste encore <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Demander une explication à Gemini",
|
||||
"banner_explain_btn": "Expliquer",
|
||||
"banner_analyzing": "🤖 Analyse en cours…"
|
||||
"banner_analyzing": "🤖 Analyse en cours…",
|
||||
"banner_expired_action_modify": "Modifier",
|
||||
"banner_expired_action_vacuum": "Mettre sous vide",
|
||||
"banner_prediction_confirmed": "✅ Confirmé — les prévisions seront recalculées à partir de vos prochains enregistrements",
|
||||
"banner_anomaly_explain_fail": "Impossible d'obtenir l'explication IA",
|
||||
"banner_anomaly_dismissed": "Anomalie ignorée",
|
||||
"banner_finished_restore_prompt": "Combien de {unit} de {name} vous reste-t-il ? (estimation : {qty})"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Garde-manger",
|
||||
@@ -240,7 +248,9 @@
|
||||
"ai_match_none": "Aucun produit similaire trouve dans le stock.",
|
||||
"ai_match_use_btn": "Utiliser celui-ci",
|
||||
"ai_match_add_btn": "Ajouter \"{name}\"",
|
||||
"ai_detected_label": "IA a detecte"
|
||||
"ai_detected_label": "IA a detecte",
|
||||
"stock_in_pantry": "Déjà à la maison :",
|
||||
"mode_shopping_activated": "🛒 Mode courses activé !"
|
||||
},
|
||||
"action": {
|
||||
"title": "Que voulez-vous faire ?",
|
||||
@@ -254,7 +264,8 @@
|
||||
"throw_btn": "🗑️ JETER",
|
||||
"throw_sub": "jeter",
|
||||
"edit_sub": "péremption, emplacement…",
|
||||
"create_recipe_btn": "Recette"
|
||||
"create_recipe_btn": "Recette",
|
||||
"related_stock_title": "Aussi à la maison"
|
||||
},
|
||||
"add": {
|
||||
"title": "Ajouter au garde-manger",
|
||||
@@ -312,14 +323,17 @@
|
||||
"toast_bring": "🛒 Produit terminé → ajouté à Bring !",
|
||||
"toast_opened_finished": "🔓 Emballage ouvert de {name} terminé !",
|
||||
"disambiguation_hint": "Que voulez-vous dire par « tout fini » ?",
|
||||
"disambiguation_one_conf": "Terminer <strong>1 emballage</strong> ({qty})",
|
||||
"disambiguation_all": "🗑️ Tout finir ({qty})",
|
||||
"toast_one_conf_finished": "📦 1 emballage de {name} terminé !",
|
||||
"error_exceeds_stock": "⚠️ Vous ne pouvez pas utiliser plus que ce que vous avez disponible !",
|
||||
"use_all_confirm_title": "✅ Tout terminer",
|
||||
"use_all_confirm_msg": "Confirmez que vous avez terminé le produit :",
|
||||
"use_all_confirm_btn": "✅ Oui, terminé",
|
||||
"throw_all_confirm_title": "🗑️ Tout jeter",
|
||||
"throw_all_confirm_msg": "Voulez-vous vraiment jeter tout le produit ?",
|
||||
"throw_all_confirm_btn": "🗑️ Oui, jeter"
|
||||
"throw_all_confirm_btn": "🗑️ Oui, jeter",
|
||||
"locations_short": "emplacements"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nouveau produit",
|
||||
@@ -359,7 +373,9 @@
|
||||
"weight_label": "Poids",
|
||||
"origin_label": "Origine",
|
||||
"labels_label": "Labels",
|
||||
"select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :"
|
||||
"select_variant": "Sélectionnez la variante exacte ou utilisez les données IA :",
|
||||
"history_badge": "📊 historique",
|
||||
"from_history": " (historique)"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Tous les produits",
|
||||
@@ -420,7 +436,10 @@
|
||||
"nutrition_per_serving": "Valeurs estimées par portion",
|
||||
"storage_title": "Comment conserver les restes",
|
||||
"storage_days": "{n} jours",
|
||||
"storage_immediately": "À consommer immédiatement"
|
||||
"storage_immediately": "À consommer immédiatement",
|
||||
"stream_interrupted": "Génération interrompue (réponse serveur incomplète). Vérifiez les logs ou réessayez.",
|
||||
"ing_stock_line": "Vous avez {have} · il reste {remain} après usage",
|
||||
"ing_use_all_note": "tout utiliser (<5% du conditionnement entier)"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Liste de courses",
|
||||
@@ -507,6 +526,7 @@
|
||||
"remove_error": "Erreur de suppression",
|
||||
"btn_fetch_prices": "Trouver les prix",
|
||||
"price_total_label": "💰 Total estimé :",
|
||||
"price_total_short": "total estimé",
|
||||
"price_loading": "Recherche des prix…",
|
||||
"price_not_found": "prix n/d",
|
||||
"suggest_loading": "Analyse en cours...",
|
||||
@@ -515,7 +535,9 @@
|
||||
"priority_medium": "Moyenne",
|
||||
"priority_low": "Faible",
|
||||
"smart_last_update": "Mis à jour {time}",
|
||||
"names_already_updated": "Tous les noms sont déjà à jour"
|
||||
"names_already_updated": "Tous les noms sont déjà à jour",
|
||||
"pantry_hint": "Déjà à la maison : {qty}",
|
||||
"bring_names_migrated": "🔄 {n} noms généralisés dans Bring !"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 Identification IA",
|
||||
@@ -526,7 +548,8 @@
|
||||
"no_api_key": "⚠️ Clé API Gemini non configurée.\n<small>Ajoutez GEMINI_API_KEY au fichier .env sur le serveur.</small>",
|
||||
"fields_filled": "✅ Champs remplis par l'IA",
|
||||
"use_data": "✅ Utiliser les données IA",
|
||||
"use_data_no_barcode": "✅ Utiliser les données IA (sans code-barres)"
|
||||
"use_data_no_barcode": "✅ Utiliser les données IA (sans code-barres)",
|
||||
"conservation_hint": "🤖 IA : conserve dans {location}"
|
||||
},
|
||||
"log": {
|
||||
"title": "📒 Journal des opérations",
|
||||
@@ -742,7 +765,8 @@
|
||||
"heard_yes": "Oui, je l'ai entendu",
|
||||
"heard_no": "Non, je n'ai rien entendu",
|
||||
"test_ok_kiosk": "TTS fonctionne.",
|
||||
"test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android."
|
||||
"test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android.",
|
||||
"test_sound_btn": "🔔 Test sonore"
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Langue",
|
||||
@@ -780,7 +804,13 @@
|
||||
"kiosk_title": "📡 Balance BLE intégrée dans le kiosque",
|
||||
"kiosk_hint": "La balance est directement gérée par la passerelle BLE interne du kiosque. Pour associer un nouvel appareil, utilisez l'assistant de configuration.",
|
||||
"kiosk_reconfigure": "🔄 Reconfigurer la balance BLE",
|
||||
"ble_protocols": "<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) — poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique — 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) — poids, graisse, IMC</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Générique — 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": {
|
||||
"hint": "Transformez une tablette Android en panneau EverShelf permanent avec passerelle BLE intégrée.",
|
||||
@@ -926,7 +956,49 @@
|
||||
"sensor_copied": "YAML copié dans le presse-papiers !",
|
||||
"save_btn": "Enregistrer les paramètres HA",
|
||||
"ha_hint": "Si vous utilisez Home Assistant, utilisez l'onglet Home Assistant pour configurer TTS, webhooks et capteurs."
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"tab": "Info",
|
||||
"ai_title": "Gemini AI — Utilisation des tokens",
|
||||
"ai_hint": "Consommation mensuelle et coût estimé pour la clé API actuelle.",
|
||||
"loading": "Chargement…",
|
||||
"total_tokens": "Tokens totaux",
|
||||
"est_cost": "Coût est.",
|
||||
"input_tok": "Tokens entrée",
|
||||
"output_tok": "Tokens sortie",
|
||||
"ai_calls": "Appels",
|
||||
"by_action": "Répartition par fonction",
|
||||
"by_model": "Répartition par modèle",
|
||||
"pricing_note": "Tarifs Gemini : 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
|
||||
"system_title": "Système",
|
||||
"db_size": "Base de données",
|
||||
"log_size": "Logs",
|
||||
"log_level": "Niveau de log",
|
||||
"ai_overview": "Aperçu IA, inventaire et état du système",
|
||||
"calls_unit": "appels",
|
||||
"inv_title": "Inventaire",
|
||||
"inv_active": "Actifs",
|
||||
"inv_products": "Produits totaux",
|
||||
"inv_expiring": "Expirent (7j)",
|
||||
"inv_expired": "Expirés",
|
||||
"inv_finished": "Terminés",
|
||||
"act_title": "Activité mensuelle",
|
||||
"act_tx_month": "Mouvements",
|
||||
"act_restock": "Réapprovisionnements",
|
||||
"act_use": "Utilisations",
|
||||
"act_new_products": "Nouveaux produits",
|
||||
"act_tx_year": "Mouvements annuels",
|
||||
"price_cache": "Cache prix",
|
||||
"cache_entries": "produits",
|
||||
"last_backup": "Dernière sauvegarde",
|
||||
"bring_days": "jeton expire dans {n} jours",
|
||||
"bring_expired": "jeton expiré",
|
||||
"year_label": "Année {year}",
|
||||
"currency_title": "Devise",
|
||||
"currency_hint": "Devise utilisée pour tous les coûts et prix dans l'app."
|
||||
},
|
||||
"tab_general": "Général",
|
||||
"kiosk_update_required": "⚠️ Mettez à jour l'application kiosk pour utiliser cette fonction"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "AUJOURD'HUI",
|
||||
@@ -999,8 +1071,10 @@
|
||||
"thrown_away_partial": "🗑️ {qty} {unit} de {name} jeté(s)",
|
||||
"finished_all": "📤 {name} terminé !",
|
||||
"product_finished_confirmed": "✅ Supprimé — ajoutez-le à nouveau lors du réapprovisionnement",
|
||||
"ghost_restored": "✅ {name} : {qty} {unit} restaurés dans l'inventaire",
|
||||
"appliance_added": "Appareil ajouté",
|
||||
"item_added": "{name} ajouté"
|
||||
"item_added": "{name} ajouté",
|
||||
"vacuum_sealed": "{name} enregistré sous vide"
|
||||
},
|
||||
"antiwaste": {
|
||||
"title": "🌱 Rapport anti-gaspi",
|
||||
@@ -1070,7 +1144,9 @@
|
||||
"offline_ops_pending": "{n} opérations en attente",
|
||||
"offline_synced": "{n} opérations synchronisées",
|
||||
"offline_ai_disabled": "Indisponible hors ligne",
|
||||
"offline_cache_ready": "Offline — {n} produits en cache"
|
||||
"offline_cache_ready": "Offline — {n} produits en cache",
|
||||
"copy_failed": "Échec de la copie dans le presse-papiers",
|
||||
"invalid_quantity": "Quantité invalide"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
@@ -1171,7 +1247,10 @@
|
||||
"retake_btn": "🔄 Reprendre",
|
||||
"camera_error_hint": "Assurez-vous d'utiliser HTTPS et d'avoir accordé les permissions caméra.<br>Vous pouvez entrer le code-barres manuellement ou utiliser l'identification IA.",
|
||||
"no_barcode": "Pas de code-barres",
|
||||
"save_new_btn": "🆕 Aucun de ceux-ci — enregistrer comme nouveau"
|
||||
"save_new_btn": "🆕 Aucun de ceux-ci — enregistrer comme nouveau",
|
||||
"expiry_found": "Date trouvée",
|
||||
"expiry_read_fail": "Impossible de lire la date.",
|
||||
"expiry_raw_label": "Lu"
|
||||
},
|
||||
"lowstock": {
|
||||
"title": "⚠️ Stock faible !",
|
||||
@@ -1189,7 +1268,8 @@
|
||||
"stay_btn": "Non, rester dans {location}",
|
||||
"moved_toast": "📦 Emballage ouvert déplacé vers {location}",
|
||||
"vacuum_restore": "🫙 Restaurer sous vide",
|
||||
"vacuum_seal_rest": "🔒 Mettre le reste sous vide"
|
||||
"vacuum_seal_rest": "🔒 Mettre le reste sous vide",
|
||||
"moved_simple": "📦 Déplacé vers {location}"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Non transformé",
|
||||
@@ -1424,7 +1504,12 @@
|
||||
"token_autoconfig": "Configuration de l'accès...",
|
||||
"token_prompt_title": "🔒 Jeton API",
|
||||
"token_prompt_hint": "Saisissez la valeur API_TOKEN du fichier .env du serveur.",
|
||||
"token_prompt_btn": "Continuer"
|
||||
"token_prompt_btn": "Continuer",
|
||||
"check_db_legacy": "Ancienne BD (dispensa.db)",
|
||||
"check_tts": "URL synthèse vocale",
|
||||
"check_scale": "Passerelle balance",
|
||||
"critical_error_intro": "L'application ne peut pas démarrer en raison des problèmes suivants :",
|
||||
"error_network_detail": "Le navigateur ne peut pas joindre le serveur PHP.\n\nCauses possibles :\n• Apache/PHP n'est pas démarré\n• Problème réseau ou pare-feu\n• URL incorrecte\n\nDémarrez le serveur et réessayez."
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Statistiques Mensuelles",
|
||||
@@ -1437,5 +1522,12 @@
|
||||
"top_used": "le plus utilisé",
|
||||
"top_cats": "Catégories principales",
|
||||
"source": "Historique des transactions · mois en cours"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "à l'instant",
|
||||
"seconds_ago": "il y a {n}s",
|
||||
"minutes_ago": "il y a {n} min",
|
||||
"hours_ago": "il y a {n} h",
|
||||
"days_ago": "il y a {n} j"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-12
@@ -143,8 +143,10 @@
|
||||
"banner_prediction_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.",
|
||||
"banner_prediction_less": "stima: {expected} {unit}{time}; quantità attuale: {actual} {unit}. Se hai cambiato ritmo d'uso, la previsione si aggiorna automaticamente.",
|
||||
"banner_finished_zero": "L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.",
|
||||
"banner_finished_vanished": "Il prodotto non compare più in inventario, ma i movimenti registrati dicono che non dovrebbe essere finito.",
|
||||
"banner_finished_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.",
|
||||
"banner_finished_check": "Puoi controllare?",
|
||||
"banner_finished_action_restore": "Ripristina {qty} {unit}",
|
||||
"banner_anomaly_phantom_title": "hai più scorte del previsto",
|
||||
"banner_anomaly_phantom_detail": "L'inventario segna {inv_qty} {unit}, ma in base alle registrazioni ne dovresti avere solo {expected_qty} {unit}. Hai aggiunto scorte senza registrarle?",
|
||||
"banner_anomaly_untracked_title": "scorte non registrate come entrata",
|
||||
@@ -164,7 +166,11 @@
|
||||
"banner_opened_detail": "{when} in {location} · hai ancora <strong>{qty}</strong>.",
|
||||
"banner_explain_title": "Chiedi a Gemini una spiegazione",
|
||||
"banner_explain_btn": "Spiega",
|
||||
"banner_analyzing": "🤖 Analizzo…"
|
||||
"banner_analyzing": "🤖 Analizzo…",
|
||||
"banner_prediction_confirmed": "✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni",
|
||||
"banner_anomaly_explain_fail": "Impossibile ottenere spiegazione AI",
|
||||
"banner_anomaly_dismissed": "Anomalia ignorata",
|
||||
"banner_finished_restore_prompt": "Quante {unit} di {name} hai ancora? (stima sistema: {qty})"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Dispensa",
|
||||
@@ -243,7 +249,8 @@
|
||||
"ai_match_none": "Nessun prodotto simile trovato in dispensa.",
|
||||
"ai_match_use_btn": "Usa questo",
|
||||
"ai_match_add_btn": "Aggiungi \"{name}\"",
|
||||
"ai_detected_label": "AI ha trovato"
|
||||
"ai_detected_label": "AI ha trovato",
|
||||
"mode_shopping_activated": "🛒 Modalità Spesa attivata!"
|
||||
},
|
||||
"action": {
|
||||
"title": "Cosa vuoi fare?",
|
||||
@@ -316,14 +323,17 @@
|
||||
"toast_bring": "🛒 Prodotto finito → aggiunto a Bring!",
|
||||
"toast_opened_finished": "🔓 Confezione aperta di {name} finita!",
|
||||
"disambiguation_hint": "Cosa intendi con \"finito tutto\"?",
|
||||
"disambiguation_one_conf": "Finita <strong>1 confezione</strong> ({qty})",
|
||||
"disambiguation_all": "🗑️ Finito TUTTO ({qty})",
|
||||
"toast_one_conf_finished": "📦 1 confezione di {name} terminata!",
|
||||
"error_exceeds_stock": "⚠️ Non puoi usare più di quanto hai disponibile!",
|
||||
"use_all_confirm_title": "✅ Finisci tutto",
|
||||
"use_all_confirm_msg": "Conferma che hai finito tutto il prodotto:",
|
||||
"use_all_confirm_btn": "✅ Sì, finito",
|
||||
"throw_all_confirm_title": "🗑️ Butta tutto",
|
||||
"throw_all_confirm_msg": "Vuoi davvero buttare via tutto il prodotto?",
|
||||
"throw_all_confirm_btn": "🗑️ Sì, butta"
|
||||
"throw_all_confirm_btn": "🗑️ Sì, butta",
|
||||
"locations_short": "posti"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nuovo Prodotto",
|
||||
@@ -363,7 +373,9 @@
|
||||
"weight_label": "Peso",
|
||||
"origin_label": "Origine",
|
||||
"labels_label": "Etichette",
|
||||
"select_variant": "Seleziona la variante esatta o usa i dati AI:"
|
||||
"select_variant": "Seleziona la variante esatta o usa i dati AI:",
|
||||
"history_badge": "📊 storico",
|
||||
"from_history": " (da storico)"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Tutti i Prodotti",
|
||||
@@ -425,7 +437,9 @@
|
||||
"nutrition_per_serving": "Valori stimati per porzione",
|
||||
"storage_title": "Come conservare gli avanzi",
|
||||
"storage_days": "{n} giorni",
|
||||
"storage_immediately": "Da consumare subito"
|
||||
"storage_immediately": "Da consumare subito",
|
||||
"ing_stock_line": "Hai {have} · restano {remain} dopo l'uso",
|
||||
"ing_use_all_note": "uso totale (<5% della confezione intera)"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Lista della Spesa",
|
||||
@@ -512,6 +526,7 @@
|
||||
"remove_error": "Errore nella rimozione",
|
||||
"btn_fetch_prices": "Cerca i prezzi",
|
||||
"price_total_label": "💰 Spesa stimata:",
|
||||
"price_total_short": "spesa stimata",
|
||||
"price_loading": "Ricerca prezzi…",
|
||||
"price_not_found": "prezzo n/d",
|
||||
"suggest_loading": "Analisi in corso...",
|
||||
@@ -521,7 +536,8 @@
|
||||
"priority_low": "Bassa",
|
||||
"smart_last_update": "Aggiornato {time}",
|
||||
"names_already_updated": "Tutti i nomi sono già aggiornati",
|
||||
"pantry_hint": "Hai gia {qty} in dispensa"
|
||||
"pantry_hint": "Hai gia {qty} in dispensa",
|
||||
"bring_names_migrated": "🔄 {n} nomi generalizzati in Bring!"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 Identificazione AI",
|
||||
@@ -532,7 +548,8 @@
|
||||
"no_api_key": "⚠️ Chiave API Gemini non configurata.\n<small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small>",
|
||||
"fields_filled": "✅ Campi compilati dall'AI",
|
||||
"use_data": "✅ Usa dati AI",
|
||||
"use_data_no_barcode": "✅ Usa dati AI (senza barcode)"
|
||||
"use_data_no_barcode": "✅ Usa dati AI (senza barcode)",
|
||||
"conservation_hint": "🤖 AI: conserva in {location}"
|
||||
},
|
||||
"log": {
|
||||
"title": "📒 Storico",
|
||||
@@ -787,7 +804,13 @@
|
||||
"kiosk_title": "📡 Bilancia BLE integrata nel Kiosk",
|
||||
"kiosk_hint": "La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.",
|
||||
"kiosk_reconfigure": "🔄 Riconfigura bilancia BLE",
|
||||
"ble_protocols": "<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) — peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico — 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) — peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico — 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": {
|
||||
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
|
||||
@@ -974,7 +997,8 @@
|
||||
"sensor_copied": "YAML copiato negli appunti!",
|
||||
"save_btn": "Salva impostazioni HA",
|
||||
"ha_hint": "Se usi Home Assistant, usa il tab Home Assistant per configurare TTS, webhook e sensori."
|
||||
}
|
||||
},
|
||||
"kiosk_update_required": "⚠️ Aggiorna il kiosk per usare questa funzione"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "OGGI",
|
||||
@@ -1048,6 +1072,7 @@
|
||||
"finished_all": "📤 {name} terminato!",
|
||||
"vacuum_sealed": "{name} salvato come sottovuoto",
|
||||
"product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri",
|
||||
"ghost_restored": "✅ {name}: ripristinati {qty} {unit} in inventario",
|
||||
"appliance_added": "Elettrodomestico aggiunto",
|
||||
"item_added": "{name} aggiunto"
|
||||
},
|
||||
@@ -1119,7 +1144,9 @@
|
||||
"offline_ops_pending": "{n} operazioni in attesa",
|
||||
"offline_synced": "{n} operazioni sincronizzate",
|
||||
"offline_ai_disabled": "Non disponibile offline",
|
||||
"offline_cache_ready": "Offline — {n} prodotti in cache"
|
||||
"offline_cache_ready": "Offline — {n} prodotti in cache",
|
||||
"copy_failed": "Copia negli appunti non riuscita",
|
||||
"invalid_quantity": "Quantità non valida"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
||||
@@ -1240,7 +1267,8 @@
|
||||
"stay_btn": "No, resta in {location}",
|
||||
"moved_toast": "📦 Confezione aperta spostata in {location}",
|
||||
"vacuum_restore": "Torna sotto vuoto",
|
||||
"vacuum_seal_rest": "Metti sotto vuoto il resto"
|
||||
"vacuum_seal_rest": "Metti sotto vuoto il resto",
|
||||
"moved_simple": "📦 Spostato in {location}"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Non trasformato",
|
||||
@@ -1493,5 +1521,12 @@
|
||||
"top_used": "più usato",
|
||||
"top_cats": "Categorie principali",
|
||||
"source": "Storico transazioni · mese corrente"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "adesso",
|
||||
"seconds_ago": "{n}s fa",
|
||||
"minutes_ago": "{n} min fa",
|
||||
"hours_ago": "{n} h fa",
|
||||
"days_ago": "{n} gg fa"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user