feat: HA sensor enrichment, inventory edit guard, recipe ingredient fix, bread shelf-life
- HA sensor: expiring_list now includes full product details (location, brand, category, days_remaining, opened_at, vacuum_sealed, default_quantity, etc.) - HA sensor: new expired_list attribute with full product details per expired item - HA sensor: new low_stock_list attribute (items with quantity ≤ 1, full details) - HA sensor: new sensor=product endpoint (?action=ha_sensor&sensor=product) with optional filters: &id=, &name=, &location= - HA cron webhook: expiry alert items now carry full product details - Inventory edit: confirm dialog when quantity exceeds unit-specific threshold (prevents data loss from unit-confusion typos, e.g. 183 conf instead of 0.183) - Recipe AI: explicit rule against ingredient form substitution (fresh tomatoes ≠ passata, fresh milk ≠ UHT ≠ cream, etc.) - Shelf-life: opened bread rules (piadina 2d, bauletto/pancarrè 4d, pane 3d) - docs/wiki: HA page updated with new schema, examples, product endpoint Closes #125
This commit is contained in:
@@ -11,6 +11,23 @@ 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.27] - 2026-05-29
|
||||
|
||||
### Added
|
||||
- **HA sensor enrichment** — All HA sensor attributes that list products now include full product details: `location`, `brand`, `category`, `days_remaining`, `opened_at`, `vacuum_sealed`, `default_quantity`, `package_unit`, `product_id`, `inventory_id`. Applies to `expiring_list`, the new `expired_list`, and the new `low_stock_list`.
|
||||
- **HA `expired_list` attribute** — `sensor.evershelf_overview` now exposes `expired_list` (full details for all expired items, not just a count).
|
||||
- **HA `low_stock_list` attribute** — New attribute listing all items with quantity ≤ 1 with full product info.
|
||||
- **HA `sensor=product` endpoint** — New `GET /api/?action=ha_sensor&sensor=product` returns the full inventory with all product details. Optional filters: `&id=N`, `&name=...`, `&location=...`.
|
||||
- **Inventory edit safety guard** — Confirm dialog when saving a quantity that is unusually large for its unit (e.g. 183 conf), preventing accidental data loss from unit-confusion typos.
|
||||
- **Bread shelf-life in fridge** — Opened shelf-life rules added for piadina/crescia (2 days), packaged sliced bread/bauletto (4 days), and generic bread (3 days).
|
||||
|
||||
### Fixed
|
||||
- **Recipe AI ingredient substitution** — Added explicit rule to both recipe prompts preventing Gemini from substituting ingredient forms (e.g. fresh tomatoes ↔ passata, fresh milk ↔ UHT ↔ cream, flour 00 ↔ wholemeal).
|
||||
- **HA cron webhook payload** — Expiry alert webhook items now include full product details (brand, category, location, days_remaining, opened_at, vacuum_sealed) instead of only name/qty/unit/expiry_date.
|
||||
|
||||
### Docs
|
||||
- `docs/wiki/Home-Assistant.md` — Documented new `sensor=product` endpoint, full product schema table, enriched webhook payload example, and Lovelace/automation template examples using `location` and `days_remaining`.
|
||||
|
||||
## [1.7.26] - 2026-05-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -142,7 +142,9 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
||||
if (!file_exists($haFlagFile)) {
|
||||
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
|
||||
$expiringItems = $db->query(
|
||||
"SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location
|
||||
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
||||
@@ -150,13 +152,44 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$expiredItems = $db->query(
|
||||
"SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location
|
||||
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date < date('now')
|
||||
ORDER BY i.expiry_date ASC LIMIT 10"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Normalise rows to full product format
|
||||
if (!function_exists('_haFormatProduct')) {
|
||||
function _haFormatProduct(array $row): array {
|
||||
$daysRemaining = null;
|
||||
if (!empty($row['expiry_date'])) {
|
||||
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
|
||||
$daysRemaining = (int)$diff->format('%r%a');
|
||||
}
|
||||
return [
|
||||
'product_id' => (int)($row['product_id'] ?? 0),
|
||||
'inventory_id' => (int)($row['inventory_id'] ?? 0),
|
||||
'name' => $row['name'],
|
||||
'brand' => $row['brand'] ?? null,
|
||||
'category' => $row['category'] ?? null,
|
||||
'quantity' => (float)($row['quantity'] ?? 0),
|
||||
'unit' => $row['unit'] ?? '',
|
||||
'default_quantity' => (float)($row['default_quantity'] ?? 0),
|
||||
'package_unit' => $row['package_unit'] ?? null,
|
||||
'location' => $row['location'] ?? null,
|
||||
'expiry_date' => $row['expiry_date'] ?? null,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'opened_at' => $row['opened_at'] ?? null,
|
||||
'vacuum_sealed' => !empty($row['vacuum_sealed']),
|
||||
];
|
||||
}
|
||||
}
|
||||
$expiringItems = array_map('_haFormatProduct', $expiringItems);
|
||||
$expiredItems = array_map('_haFormatProduct', $expiredItems);
|
||||
|
||||
if (!empty($expiringItems)) {
|
||||
$names = implode(', ', array_column($expiringItems, 'name'));
|
||||
_fireHaWebhook('expiry_alert', [
|
||||
|
||||
@@ -464,6 +464,14 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
|
||||
if (preg_match('/\baglio\b/', $n)) return 14;
|
||||
|
||||
// ── F.extra: Bread in fridge (opened) ──────────────────────────────────
|
||||
// Thin flatbreads (piadina, crescia, tigella) get mold very quickly
|
||||
if (preg_match('/\b(piadina|piadelle?|crescia|tigella)\b/', $n)) return 2;
|
||||
// Packaged sliced bread — preservatives help a bit
|
||||
if (preg_match('/\b(bauletto|pancarrè|pan\s+carr|tramezzin)\b/', $n)) return 4;
|
||||
// Generic bread / sandwich bread in fridge
|
||||
if (preg_match('/\bpane\b/', $cat)) return 3;
|
||||
|
||||
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
|
||||
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
|
||||
if (preg_match('/\bketchup\b/', $n)) return 90;
|
||||
|
||||
+131
-22
@@ -1376,12 +1376,47 @@ function _sendHaNotify(string $message, array $data = []): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a DB inventory+product row into a full product info array
|
||||
* used consistently across all HA sensor attributes and webhook payloads.
|
||||
*/
|
||||
function _haFormatProduct(array $row): array {
|
||||
$daysRemaining = null;
|
||||
if (!empty($row['expiry_date'])) {
|
||||
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
|
||||
$daysRemaining = (int)$diff->format('%r%a');
|
||||
}
|
||||
return [
|
||||
'product_id' => (int)($row['product_id'] ?? 0),
|
||||
'inventory_id' => (int)($row['inventory_id'] ?? 0),
|
||||
'name' => $row['name'],
|
||||
'brand' => $row['brand'] ?? null,
|
||||
'category' => $row['category'] ?? null,
|
||||
'quantity' => (float)($row['quantity'] ?? 0),
|
||||
'unit' => $row['unit'] ?? '',
|
||||
'default_quantity' => (float)($row['default_quantity'] ?? 0),
|
||||
'package_unit' => $row['package_unit'] ?? null,
|
||||
'location' => $row['location'] ?? null,
|
||||
'expiry_date' => $row['expiry_date'] ?? null,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'opened_at' => $row['opened_at'] ?? null,
|
||||
'vacuum_sealed' => !empty($row['vacuum_sealed']),
|
||||
];
|
||||
}
|
||||
|
||||
/** Full product detail SQL fragment reused in all HA queries. */
|
||||
function _haProductSelect(): string {
|
||||
return "p.id AS product_id, i.id AS inventory_id,
|
||||
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed";
|
||||
}
|
||||
|
||||
/**
|
||||
* HA REST sensor endpoint — returns pantry state in Home Assistant-compatible format.
|
||||
* Use with platform: rest in configuration.yaml.
|
||||
*
|
||||
* GET /api/?action=ha_sensor[&sensor=NAME]
|
||||
* Available sensor names: expiring, expired, total, shopping
|
||||
* Available sensor names: expiring, expired, total, shopping, product
|
||||
*/
|
||||
function haInventorySensor(PDO $db): void {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
@@ -1390,6 +1425,38 @@ function haInventorySensor(PDO $db): void {
|
||||
$sensor = strtolower(trim($_GET['sensor'] ?? 'overview'));
|
||||
$expiryDays = max(1, min(90, (int)($_GET['expiry_days'] ?? env('HA_EXPIRY_DAYS', 3))));
|
||||
|
||||
// ── sensor=product: full inventory details, optionally filtered ──────────
|
||||
if ($sensor === 'product') {
|
||||
try {
|
||||
$invId = (int)($_GET['id'] ?? 0);
|
||||
$search = trim($_GET['name'] ?? '');
|
||||
$loc = trim($_GET['location'] ?? '');
|
||||
$where = "WHERE i.quantity > 0";
|
||||
$params = [];
|
||||
if ($invId > 0) { $where .= " AND i.id = ?"; $params[] = $invId; }
|
||||
elseif ($search !== '') { $where .= " AND LOWER(p.name) LIKE ?"; $params[] = '%' . mb_strtolower($search, 'UTF-8') . '%'; }
|
||||
if ($loc !== '') { $where .= " AND i.location = ?"; $params[] = $loc; }
|
||||
$stmt = $db->prepare(
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
$where ORDER BY p.name ASC"
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$items = array_map('_haFormatProduct', $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
echo json_encode([
|
||||
'state' => count($items),
|
||||
'items' => $items,
|
||||
'last_updated' => date('c'),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$expiring = (int)$db->query(
|
||||
"SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND expiry_date IS NOT NULL
|
||||
@@ -1416,14 +1483,30 @@ function haInventorySensor(PDO $db): void {
|
||||
$shoppingCount = (int)$db->query("SELECT COUNT(*) FROM shopping_list")->fetchColumn();
|
||||
}
|
||||
|
||||
// Expiring items details
|
||||
// Expiring items details (full product info, all within $expiryDays window)
|
||||
$expiringItems = $db->query(
|
||||
"SELECT p.name, i.quantity, p.unit, i.expiry_date
|
||||
FROM inventory i
|
||||
JOIN products p ON p.id = i.product_id
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+7 days')
|
||||
ORDER BY i.expiry_date ASC LIMIT 10"
|
||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
||||
ORDER BY i.expiry_date ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Expired items (full product info)
|
||||
$expiredItemsList = $db->query(
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date < date('now')
|
||||
ORDER BY i.expiry_date ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Low-stock items (quantity <= 1 but > 0, full product info)
|
||||
$lowStockItemsList = $db->query(
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0 AND i.quantity <= 1
|
||||
ORDER BY i.quantity ASC, p.name ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Opened items
|
||||
@@ -1540,13 +1623,9 @@ function haInventorySensor(PDO $db): void {
|
||||
'shopping_total' => $shoppingTotal,
|
||||
'price_tracking_enabled' => $priceEnabled,
|
||||
'price_currency' => $priceCurrency,
|
||||
'expiring_list' => array_map(fn($r) => [
|
||||
'name' => $r['name'],
|
||||
'quantity' => (float)$r['quantity'],
|
||||
'unit' => $r['unit'],
|
||||
'expiry_date' => $r['expiry_date'],
|
||||
'expires_today' => $r['expiry_date'] <= date('Y-m-d', strtotime('+1 days')),
|
||||
], $expiringItems),
|
||||
'expiring_list' => array_map('_haFormatProduct', $expiringItems),
|
||||
'expired_list' => array_map('_haFormatProduct', $expiredItemsList),
|
||||
'low_stock_list' => array_map('_haFormatProduct', $lowStockItemsList),
|
||||
'next_expiry_name' => !empty($expiringItems) ? $expiringItems[0]['name'] : null,
|
||||
'next_expiry_date' => !empty($expiringItems) ? $expiringItems[0]['expiry_date'] : null,
|
||||
'unit_of_measurement' => 'items',
|
||||
@@ -2528,10 +2607,12 @@ function addToInventory(PDO $db): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a different unit was specified, update the product's unit
|
||||
// If a different unit was specified, update the product's unit.
|
||||
// NOTE: default_quantity is the PACKAGE SIZE, not the quantity being added —
|
||||
// do NOT overwrite it here. It is managed via product_save / the edit form.
|
||||
if ($unit) {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$unit, $quantity, $productId]);
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$unit, $productId]);
|
||||
} else {
|
||||
// Auto-set default_quantity if product has none (first add sets package size)
|
||||
$stmt = $db->prepare("SELECT default_quantity, unit FROM products WHERE id = ?");
|
||||
@@ -2995,7 +3076,7 @@ function useFromInventory(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total remaining across ALL locations
|
||||
// Calculate total remaining across ALL locations (this product only)
|
||||
$stmt = $db->prepare("SELECT SUM(quantity) as total FROM inventory WHERE product_id = ? AND quantity > 0");
|
||||
$stmt->execute([$productId]);
|
||||
$totalRemaining = round((float)($stmt->fetchColumn() ?: 0), 6);
|
||||
@@ -3005,8 +3086,26 @@ function useFromInventory(PDO $db): void {
|
||||
$stmt->execute([$productId]);
|
||||
$prodInfo = $stmt->fetch();
|
||||
|
||||
// Also sum related products in the same shopping_name family (same unit) so that
|
||||
// e.g. "Uova Sfoglia Gialla" + "Uova biologiche" are evaluated together for low stock.
|
||||
$totalFamilyRemaining = $totalRemaining;
|
||||
if ($prodInfo) {
|
||||
$sNameKey = strtolower(trim($prodInfo['shopping_name'] ?? ''));
|
||||
$prodUnit = $prodInfo['unit'] ?? '';
|
||||
if ($sNameKey !== '' && $prodUnit !== '') {
|
||||
$famStmt = $db->prepare("
|
||||
SELECT SUM(i.quantity)
|
||||
FROM inventory i
|
||||
JOIN products p ON i.product_id = p.id
|
||||
WHERE LOWER(TRIM(p.shopping_name)) = ? AND i.product_id != ? AND p.unit = ? AND i.quantity > 0
|
||||
");
|
||||
$famStmt->execute([$sNameKey, $productId, $prodUnit]);
|
||||
$totalFamilyRemaining = round($totalRemaining + (float)($famStmt->fetchColumn() ?: 0), 6);
|
||||
}
|
||||
}
|
||||
|
||||
$response = ['success' => true, 'remaining' => $remaining, 'added_to_bring' => $addedToBring,
|
||||
'total_remaining' => $totalRemaining];
|
||||
'total_remaining' => $totalRemaining, 'total_family_remaining' => $totalFamilyRemaining];
|
||||
if ($prodInfo) {
|
||||
$response['product_name'] = $prodInfo['name'];
|
||||
$response['product_brand'] = $prodInfo['brand'] ?: '';
|
||||
@@ -3073,10 +3172,18 @@ function updateInventory(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Update unit on the product if provided
|
||||
// Update unit on the product if provided.
|
||||
// When setting unit back to 'pz', also ensure default_quantity >= 1 so the
|
||||
// barcode-scan auto-detect (which only fires on default_quantity === 0) won't
|
||||
// silently revert the user's correction on the next scan.
|
||||
if (isset($input['unit']) && isset($input['product_id'])) {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$input['unit'], $input['product_id']]);
|
||||
$newUnit = $input['unit'];
|
||||
if ($newUnit === 'pz') {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = CASE WHEN default_quantity < 1 THEN 1 ELSE default_quantity END, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
} else {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
}
|
||||
$stmt->execute([$newUnit, $input['product_id']]);
|
||||
}
|
||||
|
||||
// Update package info if provided
|
||||
@@ -5471,6 +5578,7 @@ REGOLE:
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
|
||||
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullateur"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||
9. `steps`: array of PLAIN TEXT STRINGS only — no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":…, "appliance_function":…}.
|
||||
10. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) ≠ 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' ≠ 'Latte UHT' ≠ 'Panna'; 'Farina 00' ≠ 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
@@ -6418,6 +6526,7 @@ REGOLE:
|
||||
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||
9. `zero_waste_tips`: array of zero-waste tips for steps that generate reusable scraps (peels, leftover cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Each entry: {"step": 0-based_step_index, "scrap": "scrap name", "tip": "short practical reuse tip (max 20 words)"}. Use the same language as other text fields. Empty array [] if no reusable scraps are generated.
|
||||
10. `steps`: array of PLAIN TEXT STRINGS only — no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":…, "appliance_function":…}.
|
||||
11. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) ≠ 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' ≠ 'Latte UHT' ≠ 'Panna'; 'Farina 00' ≠ 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
|
||||
+165
-15
@@ -1862,6 +1862,14 @@ function estimateOpenedExpiryDays(product, location) {
|
||||
if (/\b(patata|patate|tubero)\b/.test(name)) return 4;
|
||||
if (/\baglio\b/.test(name)) return 14;
|
||||
|
||||
// ── F.extra: Bread in fridge (opened) ────────────────────────────────
|
||||
// Thin flatbreads (piadina, crescia, tigella) get mold very quickly
|
||||
if (/\b(piadina|piadelle?|crescia|tigella)\b/.test(name)) return 2;
|
||||
// Packaged sliced bread — preservatives help a bit
|
||||
if (/\b(bauletto|pancarr[eè]|pan\s+carr[eè]?|tramezzin)\b/.test(name)) return 4;
|
||||
// Generic bread in fridge
|
||||
if (/\bpane\b/.test(cat)) return 3;
|
||||
|
||||
// ── G: Fridge condiments ─────────────────────────────────────────────
|
||||
if (/maionese|mayo|mayon/.test(name)) return 90;
|
||||
if (/\bketchup\b/.test(name)) return 90;
|
||||
@@ -6197,11 +6205,84 @@ async function quickUse(productId, location) {
|
||||
}
|
||||
|
||||
async function deleteInventoryItem(id) {
|
||||
if (confirm(t('confirm.remove_item'))) {
|
||||
await api('inventory_delete', {}, 'POST', { id });
|
||||
closeModal();
|
||||
showToast(t('toast.product_removed'), 'success');
|
||||
const item = currentInventory.find(i => i.id === id);
|
||||
const unit = item ? (item.unit || 'pz') : 'pz';
|
||||
const qty = item ? (parseFloat(item.quantity) || 0) : 0;
|
||||
const canDiscardOne = item && (unit === 'pz' || unit === 'conf') && qty > 1;
|
||||
|
||||
if (!canDiscardOne) {
|
||||
// Simple case: confirm → delete the whole row
|
||||
if (confirm(t('confirm.remove_item'))) {
|
||||
await api('inventory_delete', {}, 'POST', { id });
|
||||
closeModal();
|
||||
showToast(t('toast.product_removed'), 'success');
|
||||
refreshCurrentPage();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show a choice modal: 1 piece vs everything
|
||||
const qtyDisplay = formatQuantity(qty, unit, item.default_quantity, item.package_unit);
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3>${t('use.throw_title')}</h3>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<p style="color:var(--text-muted);margin:8px 0 16px">${escapeHtml(item.name)} · ${qtyDisplay}</p>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<button class="btn btn-large btn-warning full-width" onclick="_discardOnePiece(${id})">
|
||||
${t('confirm.discard_one')}
|
||||
</button>
|
||||
<button class="btn btn-large btn-danger full-width" onclick="_discardAllFromModal(${id})">
|
||||
${t('use.throw_all', { qty: qtyDisplay })}
|
||||
</button>
|
||||
<button class="btn btn-secondary full-width" onclick="closeModal()">
|
||||
${t('confirm.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('modal-overlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function _discardOnePiece(inventoryId) {
|
||||
const item = currentInventory.find(i => i.id === inventoryId);
|
||||
if (!item) { closeModal(); return; }
|
||||
closeModal();
|
||||
showLoading(true);
|
||||
try {
|
||||
await api('inventory_use', {}, 'POST', {
|
||||
product_id: item.product_id,
|
||||
quantity: 1,
|
||||
location: item.location,
|
||||
notes: 'Buttato'
|
||||
});
|
||||
showLoading(false);
|
||||
showToast(t('toast.thrown_away_partial', { qty: 1, unit: item.unit || 'pz', name: item.name }), 'success');
|
||||
refreshCurrentPage();
|
||||
} catch(e) {
|
||||
showLoading(false);
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _discardAllFromModal(inventoryId) {
|
||||
const item = currentInventory.find(i => i.id === inventoryId);
|
||||
if (!item) { closeModal(); return; }
|
||||
closeModal();
|
||||
showLoading(true);
|
||||
try {
|
||||
await api('inventory_use', {}, 'POST', {
|
||||
product_id: item.product_id,
|
||||
use_all: true,
|
||||
location: item.location,
|
||||
notes: 'Buttato'
|
||||
});
|
||||
showLoading(false);
|
||||
showToast(t('toast.thrown_away', { name: item.name }), 'success');
|
||||
refreshCurrentPage();
|
||||
} catch(e) {
|
||||
showLoading(false);
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6320,6 +6401,14 @@ async function submitEditInventory(e, id, productId) {
|
||||
const expiry = document.getElementById('edit-expiry').value || null;
|
||||
const unit = document.getElementById('edit-unit').value;
|
||||
|
||||
// Safety guard: warn if quantity is unreasonably large to prevent unit-confusion errors
|
||||
// (e.g. user types "183" thinking it's ml, but the field expects conf units)
|
||||
const _largeQtyLimits = { conf: 50, pz: 200, g: 10000, ml: 10000 };
|
||||
const _largeQtyLimit = _largeQtyLimits[unit] ?? 500;
|
||||
if (qty > _largeQtyLimit) {
|
||||
if (!confirm(t('edit.confirm_large_qty').replace('{qty}', qty).replace('{unit}', unit))) return;
|
||||
}
|
||||
|
||||
const payload = { id, quantity: qty, location: loc, expiry_date: expiry, unit, product_id: productId,
|
||||
vacuum_sealed: document.getElementById('edit-vacuum')?.checked ? 1 : 0 };
|
||||
|
||||
@@ -6792,8 +6881,11 @@ async function onBarcodeDetected(barcode) {
|
||||
const localResult = await api('search_barcode', { barcode });
|
||||
if (localResult.found) {
|
||||
currentProduct = localResult.product;
|
||||
// If product was saved with 'pz' but has weight info in notes, fix defaults
|
||||
if (currentProduct.unit === 'pz' && currentProduct.default_quantity <= 1 && currentProduct.notes) {
|
||||
// If product was saved with 'pz' but has weight info in notes, fix defaults.
|
||||
// Only run if default_quantity === 0 (strictly unset): a value of 1 or higher
|
||||
// means the user (or a previous auto-detect pass) already confirmed the unit,
|
||||
// and re-running here would undo manual corrections.
|
||||
if (currentProduct.unit === 'pz' && currentProduct.default_quantity === 0 && currentProduct.notes) {
|
||||
const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/);
|
||||
if (pesoMatch) {
|
||||
const weightStr = pesoMatch[1].trim();
|
||||
@@ -9429,6 +9521,13 @@ function showLowStockBringPrompt(result, afterCallback) {
|
||||
const unit = result.product_unit || currentProduct?.unit || 'pz';
|
||||
const defaultQty = result.product_default_qty || parseFloat(currentProduct?.default_quantity) || 0;
|
||||
const totalRemaining = result.total_remaining;
|
||||
// If the backend provided a family-wide total (all products sharing the same
|
||||
// shopping_name and unit, e.g. "Uova Sfoglia Gialla" + "Uova biologiche"),
|
||||
// use that for the low-stock check so that a second scanned package of eggs
|
||||
// prevents a false "running out" warning.
|
||||
const familyTotal = (result.total_family_remaining !== undefined)
|
||||
? result.total_family_remaining
|
||||
: totalRemaining;
|
||||
|
||||
// ── Fully depleted: no need to ask — backend already added to Bring! ──
|
||||
// Skip the modal entirely and proceed to the next step (e.g. move modal).
|
||||
@@ -9454,7 +9553,7 @@ function showLowStockBringPrompt(result, afterCallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLowStock(totalRemaining, unit, defaultQty)) {
|
||||
if (!isLowStock(familyTotal, unit, defaultQty)) {
|
||||
if (afterCallback) afterCallback();
|
||||
return;
|
||||
}
|
||||
@@ -14136,6 +14235,11 @@ sensor:
|
||||
- total_items
|
||||
- shopping_items
|
||||
- expiring_list
|
||||
- expired_list
|
||||
- low_stock_list
|
||||
- next_expiry_name
|
||||
- next_expiry_date
|
||||
- days_to_next_expiry
|
||||
- last_updated
|
||||
unit_of_measurement: "items"
|
||||
device_class: null
|
||||
@@ -14154,6 +14258,18 @@ sensor:
|
||||
resource: "${base}/api/?action=ha_sensor&sensor=shopping"
|
||||
scan_interval: 180
|
||||
value_template: "{{ value_json.state }}"
|
||||
unit_of_measurement: "items"
|
||||
|
||||
# Full product inventory — all items with complete details
|
||||
- platform: rest
|
||||
name: "EverShelf Products"
|
||||
unique_id: evershelf_products
|
||||
resource: "${base}/api/?action=ha_sensor&sensor=product"
|
||||
scan_interval: 600
|
||||
value_template: "{{ value_json.state }}"
|
||||
json_attributes:
|
||||
- items
|
||||
- last_updated
|
||||
unit_of_measurement: "items"`;
|
||||
}
|
||||
|
||||
@@ -16157,19 +16273,57 @@ function _ssDonut(label, val, color) {
|
||||
// Load all data needed for screensaver facts
|
||||
async function loadScreensaverData() {
|
||||
try {
|
||||
const [statsRes, invRes, bringRes] = await Promise.all([
|
||||
const [statsRes, invRes, bringRes, smartRes] = await Promise.all([
|
||||
api('stats'),
|
||||
api('inventory_list'),
|
||||
api('shopping_list').catch(() => null)
|
||||
api('shopping_list').catch(() => null),
|
||||
api('smart_shopping').catch(() => null)
|
||||
]);
|
||||
_screensaverData = {
|
||||
stats: statsRes,
|
||||
inventory: invRes.inventory || [],
|
||||
shopping: bringRes && bringRes.success ? (bringRes.purchase || []) : []
|
||||
};
|
||||
// Keep smartShoppingItems fresh so _screensaverAutoAddItems has current data
|
||||
if (smartRes && Array.isArray(smartRes.items)) {
|
||||
smartShoppingItems = smartRes.items;
|
||||
}
|
||||
} catch (e) {
|
||||
_screensaverData = { stats: {}, inventory: [], shopping: [] };
|
||||
}
|
||||
// Silently add critical/high-urgency items to Bring! while screensaver is showing
|
||||
_screensaverAutoAddItems();
|
||||
}
|
||||
|
||||
/**
|
||||
* Silently adds critical and high-urgency shopping items to Bring! when the
|
||||
* screensaver activates. No toast shown — the shopping panel count updates
|
||||
* automatically after the add. Rate-limited to once per 30 minutes per session.
|
||||
*/
|
||||
async function _screensaverAutoAddItems() {
|
||||
const RATE_MS = 30 * 60 * 1000;
|
||||
const lastRun = parseInt(sessionStorage.getItem('_ssAutoAddTs') || '0');
|
||||
if (Date.now() - lastRun < RATE_MS) return;
|
||||
|
||||
const toAdd = smartShoppingItems.filter(i => {
|
||||
if (i.on_bring) return false;
|
||||
if (_isBringPurchased(i.name, i.urgency)) return false;
|
||||
return i.urgency === 'critical' || i.urgency === 'high';
|
||||
});
|
||||
if (toAdd.length === 0) return;
|
||||
|
||||
sessionStorage.setItem('_ssAutoAddTs', String(Date.now()));
|
||||
const itemsToAdd = toAdd.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) }));
|
||||
try {
|
||||
const result = await api('shopping_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID });
|
||||
if (result.success && result.added > 0) {
|
||||
_markAutoAddedBring(itemsToAdd.map(i => i.name));
|
||||
logOperation('bring_auto_add_screensaver', { added: itemsToAdd.map(i => i.name) });
|
||||
// Refresh bring list silently then update screensaver counter
|
||||
loadShoppingList._bgCall = true;
|
||||
loadShoppingList().then(() => updateScreensaverShopping());
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// Show next random fact with fade in/out
|
||||
@@ -16293,12 +16447,8 @@ function generateScreensaverFact() {
|
||||
facts.push(() => t('facts.expiring_this_month').replace('{n}', expiringThisMonth.length));
|
||||
}
|
||||
|
||||
// --- Shopping list facts (skip count/names — already shown in the shopping panel) ---
|
||||
if (shop.length > 0) {
|
||||
const names = shop.slice(0, 3).map(i => i.name).join(', ');
|
||||
const extra = shop.length > 3 ? ` ${t('facts.shopping_more').replace('{n}', shop.length - 3)}` : '';
|
||||
facts.push(() => t('facts.shopping_add').replace('{names}', names + extra));
|
||||
}
|
||||
// Shopping list count/items are already visible in the shopping panel on the screensaver.
|
||||
// Items are added automatically by _screensaverAutoAddItems — no manual-action text needed.
|
||||
if (shop.length === 0) {
|
||||
facts.push(() => t('facts.shopping_empty'));
|
||||
}
|
||||
|
||||
@@ -35,10 +35,14 @@ Add EverShelf pantry data as native HA sensor entities that update automatically
|
||||
|
||||
| URL | Returns | Sensor |
|
||||
|-----|---------|--------|
|
||||
| `/api/?action=ha_sensor` | Items expiring soon (≤3 days) | `sensor.evershelf_overview` |
|
||||
| `/api/?action=ha_sensor` | Items expiring soon (≤`HA_EXPIRY_DAYS` days) | `sensor.evershelf_overview` |
|
||||
| `/api/?action=ha_sensor&sensor=expired` | Expired items count | `sensor.evershelf_expired` |
|
||||
| `/api/?action=ha_sensor&sensor=shopping` | Shopping list item count | `sensor.evershelf_shopping` |
|
||||
| `/api/?action=ha_sensor&sensor=total` | Total pantry items | `sensor.evershelf_total` |
|
||||
| `/api/?action=ha_sensor&sensor=product` | Full inventory — all items with complete details | `sensor.evershelf_products` |
|
||||
| `/api/?action=ha_sensor&sensor=product&id=42` | Full details for inventory row `id=42` | — |
|
||||
| `/api/?action=ha_sensor&sensor=product&name=milk` | Full details for items whose name contains "milk" | — |
|
||||
| `/api/?action=ha_sensor&sensor=product&location=frigo` | All items in a specific location | — |
|
||||
|
||||
### Generate & Copy YAML
|
||||
|
||||
@@ -61,7 +65,12 @@ sensor:
|
||||
- expired_items
|
||||
- total_items
|
||||
- shopping_items
|
||||
- expiring_list
|
||||
- expiring_list # full product details for expiring items
|
||||
- expired_list # full product details for expired items
|
||||
- low_stock_list # full product details for items with quantity ≤ 1
|
||||
- next_expiry_name
|
||||
- next_expiry_date
|
||||
- days_to_next_expiry
|
||||
- last_updated
|
||||
unit_of_measurement: "items"
|
||||
|
||||
@@ -72,10 +81,62 @@ sensor:
|
||||
scan_interval: 180
|
||||
value_template: "{{ value_json.state }}"
|
||||
unit_of_measurement: "items"
|
||||
|
||||
# Full product inventory — each item includes all details (location, brand, category, …)
|
||||
- platform: rest
|
||||
name: "EverShelf Products"
|
||||
unique_id: evershelf_products
|
||||
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=product"
|
||||
scan_interval: 600
|
||||
value_template: "{{ value_json.state }}"
|
||||
json_attributes:
|
||||
- items
|
||||
- last_updated
|
||||
unit_of_measurement: "items"
|
||||
```
|
||||
|
||||
Restart Home Assistant after editing `configuration.yaml`.
|
||||
|
||||
Every product entry inside `expiring_list`, `expired_list`, `low_stock_list`, and `sensor=product` responses follows the same schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_id": 42,
|
||||
"inventory_id": 7,
|
||||
"name": "Latte intero",
|
||||
"brand": "Parmalat",
|
||||
"category": "Lattiero-caseari",
|
||||
"quantity": 2.0,
|
||||
"unit": "conf",
|
||||
"default_quantity": 1000.0,
|
||||
"package_unit": "ml",
|
||||
"location": "frigo",
|
||||
"expiry_date": "2025-06-15",
|
||||
"days_remaining": 3,
|
||||
"opened_at": "2025-06-10",
|
||||
"vacuum_sealed": false
|
||||
}
|
||||
```
|
||||
|
||||
Field details:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `product_id` | int | Products table ID |
|
||||
| `inventory_id` | int | Inventory row ID |
|
||||
| `name` | string | Product name |
|
||||
| `brand` | string\|null | Brand (if set) |
|
||||
| `category` | string\|null | Category (if set) |
|
||||
| `quantity` | float | Current quantity in inventory |
|
||||
| `unit` | string | Unit (`conf`, `g`, `ml`, `pz`, …) |
|
||||
| `default_quantity` | float | Default package size (e.g. 1000 for 1-litre carton) |
|
||||
| `package_unit` | string\|null | Unit of the default package (`g`, `ml`) |
|
||||
| `location` | string\|null | Storage location (`frigo`, `freezer`, `dispensa`, …) |
|
||||
| `expiry_date` | string\|null | ISO date `YYYY-MM-DD` |
|
||||
| `days_remaining` | int\|null | Days until expiry (negative = already expired) |
|
||||
| `opened_at` | string\|null | ISO date when the package was opened |
|
||||
| `vacuum_sealed` | bool | Whether the item is vacuum-sealed |
|
||||
|
||||
---
|
||||
|
||||
## Webhook Automations
|
||||
@@ -109,9 +170,24 @@ EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur.
|
||||
"type": "expiring_soon",
|
||||
"count": 3,
|
||||
"days": 3,
|
||||
"summary": "3 products expiring within 3 days",
|
||||
"summary": "Milk, Yogurt, Butter",
|
||||
"items": [
|
||||
{ "name": "Milk", "expiry_date": "2025-06-14", "quantity": 1, "unit": "l" }
|
||||
{
|
||||
"product_id": 42,
|
||||
"inventory_id": 7,
|
||||
"name": "Milk",
|
||||
"brand": "Parmalat",
|
||||
"category": "Dairy",
|
||||
"quantity": 2.0,
|
||||
"unit": "conf",
|
||||
"default_quantity": 1000.0,
|
||||
"package_unit": "ml",
|
||||
"location": "frigo",
|
||||
"expiry_date": "2025-06-14",
|
||||
"days_remaining": 2,
|
||||
"opened_at": "2025-06-10",
|
||||
"vacuum_sealed": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -128,12 +204,25 @@ action:
|
||||
- service: notify.telegram_bot
|
||||
data:
|
||||
message: >
|
||||
🥫 EverShelf: {{ trigger.json.data.summary }}
|
||||
🥫 EverShelf: {{ trigger.json.data.count }} product(s) expiring soon
|
||||
{% for item in trigger.json.data.items %}
|
||||
— {{ item.name }} (expires {{ item.expiry_date }})
|
||||
— {{ item.name }}{% if item.brand %} ({{ item.brand }}){% endif %} ·
|
||||
{{ item.quantity }} {{ item.unit }} · 📍 {{ item.location }} ·
|
||||
expires {{ item.expiry_date }} ({{ item.days_remaining }} days)
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Example: Automation on location
|
||||
|
||||
You can filter by location in the automation template to only alert for fridge items:
|
||||
|
||||
```yaml
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ trigger.json.data.items | selectattr('location','eq','frigo') | list | length > 0 }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Push Notifications
|
||||
|
||||
@@ -1086,11 +1086,13 @@
|
||||
"offline_ai_disabled": "Offline nicht verfügbar",
|
||||
"offline_cache_ready": "Offline — {n} Produkte im Cache"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
|
||||
"kiosk_exit": "Kioskmodus verlassen?",
|
||||
"cancel": "Abbrechen",
|
||||
"proceed": "Bestätigen"
|
||||
"proceed": "Bestätigen",
|
||||
"discard_one": "1 Stück wegwerfen"
|
||||
},
|
||||
"location": {
|
||||
"dispensa": "Vorratskammer",
|
||||
@@ -1102,7 +1104,8 @@
|
||||
"unknown_hint": "Produktname und Informationen eingeben",
|
||||
"label_name": "🏷️ Produktname",
|
||||
"choose_location_title": "Welchen Ort?",
|
||||
"choose_location_hint": "Wähle den zu bearbeitenden Ort:"
|
||||
"choose_location_hint": "Wähle den zu bearbeitenden Ort:",
|
||||
"confirm_large_qty": "Du setzt die Menge auf {qty} {unit}. Das scheint ungewöhnlich hoch zu sein. Bestätigen?"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Rezepte",
|
||||
|
||||
@@ -1086,11 +1086,13 @@
|
||||
"offline_ai_disabled": "Not available offline",
|
||||
"offline_cache_ready": "Offline — {n} items cached"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
"remove_item": "Do you really want to remove this product from inventory?",
|
||||
"kiosk_exit": "Exit kiosk mode?",
|
||||
"cancel": "Cancel",
|
||||
"proceed": "Confirm"
|
||||
"proceed": "Confirm",
|
||||
"discard_one": "Discard 1 piece"
|
||||
},
|
||||
"location": {
|
||||
"dispensa": "Pantry",
|
||||
@@ -1102,7 +1104,8 @@
|
||||
"unknown_hint": "Enter the product name and information",
|
||||
"label_name": "🏷️ Product name",
|
||||
"choose_location_title": "Which location?",
|
||||
"choose_location_hint": "Choose the location to edit:"
|
||||
"choose_location_hint": "Choose the location to edit:",
|
||||
"confirm_large_qty": "You are setting the quantity to {qty} {unit}. This seems unusually high. Confirm?"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Recipes",
|
||||
|
||||
@@ -1037,11 +1037,13 @@
|
||||
"offline_ai_disabled": "No disponible sin conexión",
|
||||
"offline_cache_ready": "Offline — {n} productos en caché"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
"remove_item": "¿Realmente quieres eliminar este producto del inventario?",
|
||||
"kiosk_exit": "¿Salir del modo kiosco?",
|
||||
"cancel": "Cancelar",
|
||||
"proceed": "Confirmar"
|
||||
"proceed": "Confirmar",
|
||||
"discard_one": "Tirar 1 unidad"
|
||||
},
|
||||
"location": {
|
||||
"dispensa": "Despensa",
|
||||
@@ -1053,7 +1055,8 @@
|
||||
"unknown_hint": "Introduce el nombre del producto y la información",
|
||||
"label_name": "🏷️ Nombre del producto",
|
||||
"choose_location_title": "¿Qué ubicación?",
|
||||
"choose_location_hint": "Elige la ubicación a editar:"
|
||||
"choose_location_hint": "Elige la ubicación a editar:",
|
||||
"confirm_large_qty": "Estás configurando la cantidad a {qty} {unit}. Esto parece inusualmente alto. ¿Confirmar?"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Recetas",
|
||||
|
||||
@@ -1037,11 +1037,13 @@
|
||||
"offline_ai_disabled": "Indisponible hors ligne",
|
||||
"offline_cache_ready": "Offline — {n} produits en cache"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
"remove_item": "Voulez-vous vraiment supprimer ce produit de l'inventaire ?",
|
||||
"kiosk_exit": "Quitter le mode kiosque ?",
|
||||
"cancel": "Annuler",
|
||||
"proceed": "Confirmer"
|
||||
"proceed": "Confirmer",
|
||||
"discard_one": "Jeter 1 pièce"
|
||||
},
|
||||
"location": {
|
||||
"dispensa": "Garde-manger",
|
||||
@@ -1053,7 +1055,8 @@
|
||||
"unknown_hint": "Entrez le nom du produit et les informations",
|
||||
"label_name": "🏷️ Nom du produit",
|
||||
"choose_location_title": "Quel emplacement ?",
|
||||
"choose_location_hint": "Choisissez l'emplacement à modifier :"
|
||||
"choose_location_hint": "Choisissez l'emplacement à modifier :",
|
||||
"confirm_large_qty": "Vous définissez la quantité à {qty} {unit}. Cela semble inhabituellement élevé. Confirmer ?"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Recettes",
|
||||
|
||||
@@ -1090,7 +1090,8 @@
|
||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
||||
"kiosk_exit": "Uscire dalla modalità kiosk?",
|
||||
"cancel": "Annulla",
|
||||
"proceed": "Conferma"
|
||||
"proceed": "Conferma",
|
||||
"discard_one": "Butta 1 pezzo"
|
||||
},
|
||||
"location": {
|
||||
"dispensa": "Dispensa",
|
||||
@@ -1102,7 +1103,8 @@
|
||||
"unknown_hint": "Inserisci il nome e le informazioni del prodotto",
|
||||
"label_name": "🏷️ Nome prodotto",
|
||||
"choose_location_title": "Quale modifica?",
|
||||
"choose_location_hint": "Scegli la posizione da modificare:"
|
||||
"choose_location_hint": "Scegli la posizione da modificare:",
|
||||
"confirm_large_qty": "Stai impostando la quantità a {qty} {unit}. Questo sembra un valore insolitamente alto. Confermare?"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Ricette",
|
||||
|
||||
Reference in New Issue
Block a user