v1.7.8: usa ingredienti dalla chat

- Nuovo endpoint chat_extract_recipe: Gemini estrae solo nomi+quantità
  con prompt minimo (nessun inventario nel prompt → niente troncamento),
  poi PHP fuzzy-match contro l'inventario completo identico a generateRecipe
- Frontend: _looksLikeRecipe() rileva risposte chat con ricetta;
  bottone '🥄 Usa ingredienti' appare sotto la bubble, chiama chatExtractIngredients()
  che mostra pannello inline con pulsanti '📦 Usa' per ogni ingrediente in dispensa
- useRecipeIngredient() riusato 1:1 con fallback _chatRecipeTitle per le note
- Stili CSS: btn-chat-use-recipe, chat-recipe-panel, chat-recipe-panel-container
- Chiavi i18n: use_ingredients_btn, recipe_ingredients_from_pantry (it/en/de)
This commit is contained in:
dadaloop82
2026-05-10 14:40:25 +00:00
parent 5462879783
commit 9973edf463
12 changed files with 547 additions and 58 deletions
+5
View File
@@ -5,6 +5,11 @@ All notable changes to EverShelf will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.8] - 2026-05-10
### Added
- **Usa ingredienti dalla chat** — Quando la chat con Gemini Chef genera una ricetta, compare il bottone "🥄 Usa ingredienti" sotto la risposta. Premendolo, il backend analizza la ricetta e abbina gli ingredienti all'inventario tramite fuzzy matching (stessa logica della sezione Ricette). Vengono mostrati i pulsanti "📦 Usa" per ogni ingrediente disponibile in dispensa, con posizione e scadenza. Il flusso di scalatura inventario è identico a quello della sezione Ricette.
## [1.7.7] - 2026-05-10 ## [1.7.7] - 2026-05-10
### Fixed ### Fixed
+231 -1
View File
@@ -111,7 +111,7 @@ function checkRateLimit(string $action): void {
} }
// Determine limit based on action // Determine limit based on action
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping']; $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_extract_recipe'];
$loginActions = []; $loginActions = [];
$recipeActions = ['generate_recipe', 'generate_recipe_stream']; $recipeActions = ['generate_recipe', 'generate_recipe_stream'];
$errorActions = ['report_error', 'check_update']; $errorActions = ['report_error', 'check_update'];
@@ -335,6 +335,10 @@ try {
geminiChat($db); geminiChat($db);
break; break;
case 'chat_extract_recipe':
chatExtractRecipe($db);
break;
// ===== BRING! SHOPPING LIST ===== // ===== BRING! SHOPPING LIST =====
case 'bring_list': case 'bring_list':
bringGetList(); bringGetList();
@@ -3591,6 +3595,232 @@ PROMPT;
} }
} }
// ===== CHAT: EXTRACT RECIPE INGREDIENTS =====
function chatExtractRecipe(PDO $db): void {
$apiKey = env('GEMINI_API_KEY');
if (empty($apiKey)) {
echo json_encode(['success' => false, 'error' => 'no_api_key']);
return;
}
$input = json_decode(file_get_contents('php://input'), true);
$replyText = trim($input['text'] ?? '');
if (empty($replyText)) {
echo json_encode(['success' => false, 'error' => 'empty_text']);
return;
}
// Fetch full inventory — same query as generateRecipe
$stmt = $db->query("
SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at,
CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.quantity > 0
ORDER BY days_left ASC
");
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
// --- Gemini call: extract ingredient list only (no inventory in prompt → tiny output) ---
// The PHP fuzzy-matching below does all the inventory matching, exactly like generateRecipe.
$prompt = "Extract all ingredients from the recipe text below.\nReturn ONLY a compact JSON array — no markdown, no extra text.\nEach element: {\"name\":\"...\",\"qty\":\"...\",\"qty_number\":0.0,\"unit\":\"g|ml|pz|conf|kg|l\"}\n\nRECIPE:\n{$replyText}";
$payload = [
'contents' => [['role' => 'user', 'parts' => [['text' => $prompt]]]],
'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 2048]
];
$result = callGeminiWithFallback($apiKey, $payload, 30);
if ($result['http_code'] !== 200) {
echo json_encode(['success' => false, 'error' => $result['data']['error']['message'] ?? 'gemini_error']);
return;
}
$text = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? '';
if (empty($text)) {
echo json_encode(['success' => false, 'error' => 'gemini_error']);
return;
}
// Strip markdown code fences if present
$text = preg_replace('/^```(?:json)?\s*/i', '', $text);
$text = preg_replace('/\s*```$/i', '', $text);
$text = trim($text);
$ingredients = json_decode($text, true);
if (!is_array($ingredients) || empty($ingredients)) {
echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => mb_substr($text, 0, 500)]);
return;
}
// Mark all extracted ingredients as from_pantry=true so the enrichment logic tries to match them all
foreach ($ingredients as &$ing) {
$ing['from_pantry'] = true;
}
unset($ing);
// PHP fuzzy-match against full inventory — same logic as generateRecipe
_enrichChatIngredients($ingredients, $items);
// Extract recipe title from the reply text (look for bold title at start)
$recipeTitle = '';
if (preg_match('/\*\*([^*\n]{3,60})\*\*/u', $replyText, $m)) {
$recipeTitle = trim($m[1]);
}
echo json_encode(['success' => true, 'ingredients' => $ingredients, 'title' => $recipeTitle]);
}
function _enrichChatIngredients(array &$ingredients, array $items): void {
if (empty($ingredients) || empty($items)) return;
// Build lookup
$itemsLookup = [];
foreach ($items as $item) {
$itemsLookup[] = [
'item' => $item,
'lower' => mb_strtolower(trim($item['name']), 'UTF-8'),
'words' => preg_split('/[\s,.\-\/]+/', mb_strtolower(trim($item['name']), 'UTF-8')),
];
}
$aliases = [
'uovo' => ['uova','uovo','egg'],
'uova' => ['uovo','uova','egg'],
'latte' => ['latte','milk'],
'formaggio' => ['formaggio','cheese','philadelphia','mozzarella','parmigiano','grana','pecorino','ricotta','mascarpone','stracchino','gorgonzola'],
'pasta' => ['pasta','spaghetti','penne','fusilli','rigatoni','farfalle','tagliatelle','linguine','bucatini','orecchiette','paccheri','maccheroni'],
'pomodoro' => ['pomodoro','pomodori','tomato','passata','pelati','polpa'],
'cipolla' => ['cipolla','cipolle','onion'],
'aglio' => ['aglio','garlic'],
'burro' => ['burro','butter'],
'panna' => ['panna','cream','crema'],
'zucchero' => ['zucchero','sugar'],
'farina' => ['farina','flour'],
'olio' => ['olio','oil'],
'patata' => ['patata','patate','potato'],
'carota' => ['carota','carote','carrot'],
'sedano' => ['sedano','celery'],
'prezzemolo' => ['prezzemolo','parsley'],
'basilico' => ['basilico','basil'],
];
foreach ($ingredients as &$ing) {
// Try to match ALL ingredients — from_pantry was set to true for all by chatExtractRecipe
// If no match is found, product_id stays unset → shown as 🛒 in frontend
$ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8');
$ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower);
$bestMatch = null;
$bestScore = 0;
foreach ($itemsLookup as $entry) {
$itemNameLower = $entry['lower'];
$itemWords = $entry['words'];
$score = 0;
if ($ingNameLower === $itemNameLower) {
$score = 100;
} elseif (mb_strpos($itemNameLower, $ingNameLower) !== false) {
$score = 80;
} elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) {
$score = 70;
} else {
$expandedIngWords = $ingWords;
foreach ($ingWords as $w) {
foreach ($aliases as $key => $group) {
if (in_array($w, $group) || mb_strpos($w, $key) === 0 || mb_strpos($key, $w) === 0) {
$expandedIngWords = array_merge($expandedIngWords, $group);
}
}
}
$expandedIngWords = array_unique($expandedIngWords);
$common = 0;
foreach ($expandedIngWords as $ew) {
foreach ($itemWords as $iw) {
$minLen = min(mb_strlen($ew), mb_strlen($iw));
if ($minLen >= 3) {
$prefixLen = 0;
for ($c = 0; $c < $minLen; $c++) {
if (mb_substr($ew, $c, 1) === mb_substr($iw, $c, 1)) $prefixLen++;
else break;
}
if ($prefixLen >= min(4, $minLen)) { $common++; break; }
}
if ($ew === $iw) { $common++; break; }
}
}
if ($common > 0) {
$score = ($common / max(count($ingWords), 1)) * 65;
if (count($ingWords) > 0) {
foreach ($itemWords as $iw) {
if (mb_strpos($iw, $ingWords[0]) === 0 || mb_strpos($ingWords[0], $iw) === 0) {
$score += 10; break;
}
}
}
}
}
if ($score > $bestScore) {
$bestScore = $score;
$bestMatch = $entry['item'];
}
}
if ($bestMatch && $bestScore > 30) {
$ing['product_id'] = (int)$bestMatch['product_id'];
$ing['location'] = $bestMatch['location'];
$ing['inventory_unit'] = $bestMatch['unit'];
$ing['inventory_qty'] = (float)$bestMatch['quantity'];
$ing['default_quantity'] = (float)($bestMatch['default_quantity'] ?? 0);
$ing['package_unit'] = $bestMatch['package_unit'] ?? '';
$ing['available_qty'] = $bestMatch['quantity'] . ' ' . $bestMatch['unit'];
$ing['vacuum_sealed'] = !empty($bestMatch['vacuum_sealed']) ? 1 : 0;
if (!empty($bestMatch['brand'])) $ing['brand'] = $bestMatch['brand'];
if (!empty($bestMatch['expiry_date'])) $ing['expiry_date'] = $bestMatch['expiry_date'];
// Validate and convert qty_number to inventory unit
$qtyNum = (float)($ing['qty_number'] ?? 0);
$invUnit = $bestMatch['unit'] ?? 'pz';
$invQty = (float)$bestMatch['quantity'];
if ($qtyNum > 0) {
$recipeQty = $ing['qty'] ?? '';
$recipeUnit = '';
$recipeVal = 0;
if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $recipeQty, $qm)) {
$recipeVal = (float)str_replace(',', '.', $qm[1]);
$ru = strtolower($qm[2]);
if (strpos($ru, 'g') === 0) $recipeUnit = 'g';
elseif ($ru === 'kg') { $recipeUnit = 'g'; $recipeVal *= 1000; }
elseif ($ru === 'ml') $recipeUnit = 'ml';
elseif ($ru === 'cl') { $recipeUnit = 'ml'; $recipeVal *= 10; }
elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $recipeUnit = 'ml'; $recipeVal *= 1000; }
elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $recipeUnit = 'pz';
elseif (strpos($ru, 'conf') === 0) $recipeUnit = 'conf';
}
if ($recipeUnit && $recipeUnit !== $invUnit) {
if ($recipeUnit === 'g' && $invUnit === 'g') $qtyNum = $recipeVal;
elseif ($recipeUnit === 'g' && $invUnit === 'kg') $qtyNum = $recipeVal / 1000;
elseif ($recipeUnit === 'ml' && $invUnit === 'ml') $qtyNum = $recipeVal;
elseif ($recipeUnit === 'ml' && $invUnit === 'l') $qtyNum = $recipeVal / 1000;
elseif ($invUnit === 'pz' || $invUnit === 'conf') {
$defQty = (float)($bestMatch['default_quantity'] ?? 0);
$qtyNum = $defQty > 0 ? max(0.25, round(($recipeVal / $defQty) * 4) / 4) : max(1, round($recipeVal / 100));
}
}
if ($qtyNum > $invQty) $qtyNum = $invQty;
if ($recipeVal > 0 && $recipeUnit === $invUnit && $qtyNum < $recipeVal * 0.01) $qtyNum = $recipeVal;
$ing['qty_number'] = round($qtyNum, 3);
}
}
}
unset($ing);
}
// ===== RECIPE GENERATION — STREAMING AGENT ===== // ===== RECIPE GENERATION — STREAMING AGENT =====
function generateRecipeStream(PDO $db): void { function generateRecipeStream(PDO $db): void {
// Override content-type for SSE before any output is sent // Override content-type for SSE before any output is sent
+45
View File
@@ -5630,6 +5630,51 @@ body.cooking-mode-active .app-header {
30% { transform: translateY(-6px); opacity: 1; } 30% { transform: translateY(-6px); opacity: 1; }
} }
/* ====== Chat Recipe Ingredient Panel ====== */
.btn-chat-use-recipe {
display: inline-block;
margin-top: 10px;
padding: 7px 14px;
background: var(--card-bg, #1e293b);
border: 1px solid #6366f1;
color: #a5b4fc;
border-radius: 20px;
font-size: 0.82rem;
cursor: pointer;
transition: all 0.15s;
}
.btn-chat-use-recipe:hover {
background: #312e81;
color: white;
}
.btn-chat-use-recipe:disabled {
opacity: 0.6;
cursor: default;
}
.chat-recipe-panel-container {
margin-top: 8px;
}
.chat-recipe-panel {
background: var(--card-bg, #1e293b);
border: 1px solid rgba(99,102,241,0.3);
border-radius: 12px;
padding: 12px 14px;
margin-top: 4px;
}
.chat-recipe-panel-title {
font-size: 0.9rem;
margin-bottom: 4px;
}
.chat-recipe-panel-subtitle {
font-size: 0.75rem;
color: var(--text-muted, #94a3b8);
margin-bottom: 10px;
}
/* ====== Recipe Archive ====== */ /* ====== Recipe Archive ====== */
.recipe-archive { .recipe-archive {
display: flex; display: flex;
+99 -2
View File
@@ -11355,7 +11355,7 @@ async function submitRecipeUse(useAll) {
btn.textContent = '⏳...'; btn.textContent = '⏳...';
try { try {
const recipeTitle = _cachedRecipe?.recipe?.title || ''; const recipeTitle = _cachedRecipe?.recipe?.title || _chatRecipeTitle || '';
const result = await api('inventory_use', {}, 'POST', { const result = await api('inventory_use', {}, 'POST', {
product_id: productId, product_id: productId,
quantity: qty, quantity: qty,
@@ -12382,6 +12382,7 @@ async function generateRecipe() {
let chatHistory = []; let chatHistory = [];
let chatInventoryContext = null; let chatInventoryContext = null;
let _chatSavedCount = 0; // track how many messages already saved to DB let _chatSavedCount = 0; // track how many messages already saved to DB
let _chatRecipeTitle = ''; // title of last recipe extracted from chat (used in confirmRecipeUse notes)
function initChat() { function initChat() {
// Load chat history from DB // Load chat history from DB
@@ -12415,6 +12416,89 @@ function sendChatSuggestion(text) {
sendChatMessage(); sendChatMessage();
} }
/** Returns true if a chat reply looks like it contains a recipe with ingredients */
function _looksLikeRecipe(text) {
// Must have an "Ingredienti" section header AND a step/preparation section
const hasIngredients = /ingredi[e|ë]nti/i.test(text);
const hasPreparation = /preparazi[o|ó]ne|procedimento|istruzioni|passaggi|how to|steps|zubereitung/i.test(text);
const hasStepNumbers = /^\d+[\.\)]/m.test(text);
return hasIngredients && (hasPreparation || hasStepNumbers);
}
async function chatExtractIngredients(btn, replyText) {
btn.disabled = true;
btn.textContent = '⏳ Analisi in corso...';
const panel = btn.nextElementSibling;
try {
const settings = getSettings();
const result = await api('chat_extract_recipe', {}, 'POST', {
text: replyText,
lang: settings.lang || 'it'
});
if (!result.success || !result.ingredients) {
btn.textContent = '⚠️ ' + (result.error || t('error.generic'));
btn.disabled = false;
return;
}
const matchedIngredients = result.ingredients.filter(i => i.product_id);
if (matchedIngredients.length === 0) {
btn.textContent = '📦 Nessun ingrediente trovato in dispensa';
return;
}
_chatRecipeTitle = result.title || '';
// Render inline ingredient panel
btn.style.display = 'none';
panel.innerHTML = _buildChatIngredientPanelHTML(result.ingredients, result.title);
panel.style.display = 'block';
} catch(err) {
btn.textContent = '⚠️ ' + t('error.connection');
btn.disabled = false;
}
}
function _buildChatIngredientPanelHTML(ingredients, title) {
let html = `<div class="chat-recipe-panel">`;
if (title) html += `<div class="chat-recipe-panel-title">🍽️ <strong>${escapeHtml(title)}</strong></div>`;
html += `<div class="chat-recipe-panel-subtitle">${t('chat.recipe_ingredients_from_pantry') || 'Ingredienti in dispensa'}</div>`;
html += `<ul class="recipe-ingredients">`;
ingredients.forEach((ing, idx) => {
if (ing.product_id) {
const qtyNum = ing.qty_number || 0;
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
html += `<li class="recipe-ingredient" id="recipe-ing-${idx}">`;
html += `<span class="recipe-ing-text"><strong>${escapeHtml(ing.name)}</strong>${ing.brand ? ' <em>(' + escapeHtml(ing.brand) + ')</em>' : ''}: ${escapeHtml(ing.qty || '')}`;
let details = [];
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
details.push(ingredientLocLabels[ing.location] || ('📍 ' + ing.location));
if (ing.expiry_date) {
const exp = new Date(ing.expiry_date);
const now = new Date(); now.setHours(0,0,0,0);
const diffDays = Math.round((exp - now) / 86400000);
if (diffDays < 0) details.push(t('expiry.badge_expired_ago').replace('{n}', Math.abs(diffDays)));
else if (diffDays <= 3) details.push(t('expiry.badge_expires_red').replace('{n}', diffDays));
else if (diffDays <= 7) details.push(t('expiry.badge_expires_yellow').replace('{n}', diffDays));
}
if (details.length) html += `<br><small class="recipe-ing-detail">${details.join(' · ')}</small>`;
html += `</span>`;
html += `<button class="btn-use-ingredient" onclick="useRecipeIngredient(${idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this, '${(ing.qty || '').replace(/'/g, '&apos;')}')" title="${t('cooking.ingredient_deduct_title') || 'Usa ingrediente'}">${t('cooking.ingredient_use_btn') || 'Usa'}</button>`;
html += `</li>`;
} else {
html += `<li class="recipe-ingredient"><span class="recipe-ing-text"><strong>${escapeHtml(ing.name)}</strong>: ${escapeHtml(ing.qty || '')} 🛒</span></li>`;
}
});
html += `</ul></div>`;
return html;
}
async function sendChatMessage() { async function sendChatMessage() {
const input = document.getElementById('chat-input'); const input = document.getElementById('chat-input');
const text = input.value.trim(); const text = input.value.trim();
@@ -12453,7 +12537,20 @@ async function sendChatMessage() {
if (result.success) { if (result.success) {
chatHistory.push({ role: 'gemini', text: result.reply }); chatHistory.push({ role: 'gemini', text: result.reply });
appendChatBubble('gemini', formatChatReply(result.reply)); const bubble = appendChatBubble('gemini', formatChatReply(result.reply));
// If reply looks like a recipe, append "Usa ingredienti" button
if (_looksLikeRecipe(result.reply)) {
const replyText = result.reply;
const useBtn = document.createElement('button');
useBtn.className = 'btn-chat-use-recipe';
useBtn.textContent = '🥄 ' + (t('chat.use_ingredients_btn') || 'Usa ingredienti');
useBtn.onclick = () => chatExtractIngredients(useBtn, replyText);
const panelDiv = document.createElement('div');
panelDiv.className = 'chat-recipe-panel-container';
panelDiv.style.display = 'none';
bubble.appendChild(useBtn);
bubble.appendChild(panelDiv);
}
} else { } else {
const errMsg = result.error === 'no_api_key' ? 'Configura la chiave API Gemini nelle impostazioni.' : (result.error || 'Errore nella risposta'); const errMsg = result.error === 'no_api_key' ? 'Configura la chiave API Gemini nelle impostazioni.' : (result.error || 'Errore nella risposta');
appendChatBubble('gemini', `⚠️ ${escapeHtml(errMsg)}`); appendChatBubble('gemini', `⚠️ ${escapeHtml(errMsg)}`);
+1 -1
View File
@@ -1 +1 @@
{"a_32_572":1776776330,"a_17_171":1776776404,"a_25_-777":1776776427,"a_7_-279":1776776434,"a_168_253":1777223925,"a_191_1":1777300414,"a_183_291":1777310462,"a_213_150":1777378506,"a_183_290":1777380000} {}
+154 -49
View File
@@ -1,51 +1,156 @@
{ {
"226887def70e33ef73290ebfe75ed4d0": { "226887def70e33ef73290ebfe75ed4d0": {
"days": 7, "days": 7,
"source": "ai", "source": "ai",
"name": "Polpa di pomodoro finissima", "name": "Polpa di pomodoro finissima",
"location": "frigo", "location": "frigo",
"ts": 1777444819 "ts": 1777444819
}, },
"0ed51c9496aa9edfe38caf41772f54ed": { "0ed51c9496aa9edfe38caf41772f54ed": {
"days": 7, "days": 7,
"source": "rule", "source": "rule",
"name": "Latte di Montagna", "name": "Latte di Montagna",
"location": "frigo", "location": "frigo",
"ts": 1777444820 "ts": 1777444820
}, },
"2d63d0216a75d46b465150e925d2e7ad": { "2d63d0216a75d46b465150e925d2e7ad": {
"days": 30, "days": 30,
"source": "rule", "source": "rule",
"name": "Burro", "name": "Burro",
"location": "frigo", "location": "frigo",
"ts": 1777444821 "ts": 1777444821
}, },
"9afdf35c4a256867ef47c32495349eb6": { "9afdf35c4a256867ef47c32495349eb6": {
"days": 5, "days": 5,
"source": "rule", "source": "rule",
"name": "Yaourt Vanille", "name": "Yaourt Vanille",
"location": "frigo", "location": "frigo",
"ts": 1777480477 "ts": 1777480477
}, },
"584f57418733a1f2acd29fe2e8816129": { "584f57418733a1f2acd29fe2e8816129": {
"days": 5, "days": 5,
"source": "rule", "source": "rule",
"name": "Passata di pomodoro", "name": "Passata di pomodoro",
"location": "frigo", "location": "frigo",
"ts": 1778133522 "ts": 1778133522
}, },
"baeb7f2021b4bb91c368c9131a61f07c": { "baeb7f2021b4bb91c368c9131a61f07c": {
"days": 10, "days": 10,
"source": "rule", "source": "rule",
"name": "Formaggio Monte Maria", "name": "Formaggio Monte Maria",
"location": "frigo", "location": "frigo",
"ts": 1778133523 "ts": 1778133523
}, },
"063f2d534407214786d039bb2bffbb93": { "063f2d534407214786d039bb2bffbb93": {
"days": 5, "days": 5,
"source": "rule", "source": "rule",
"name": "Carote", "name": "Carote",
"location": "frigo", "location": "frigo",
"ts": 1778133524 "ts": 1778133524
} },
"10a3d07c19bb1f889ebc9293862b4b36": {
"days": 60,
"source": "rule",
"name": "Ovomaltine",
"location": "dispensa",
"ts": 1778419084
},
"0fbad7ccd8b6155c06aaa6b3c17a67d3": {
"days": 365,
"source": "rule",
"name": "Linguine pasta di Gragnano Igp",
"location": "dispensa",
"ts": 1778419084
},
"b4a03e7356e7a0983b9c8af5f3cd8c57": {
"days": 60,
"source": "rule",
"name": "Polpa di pomodoro finissima",
"location": "dispensa",
"ts": 1778419085
},
"b8334ff0febd5c0440c9b24c9f3132ed": {
"days": 180,
"source": "rule",
"name": "Basilico tritato surgelato",
"location": "freezer",
"ts": 1778419086
},
"0cb14384d0ba763ccf12e079d6aa8d34": {
"days": 60,
"source": "rule",
"name": "Salsa Pronta Ciliegini",
"location": "dispensa",
"ts": 1778419086
},
"188634f49edb8b014a46942ee9fad689": {
"days": 180,
"source": "rule",
"name": "Farina Barilla",
"location": "dispensa",
"ts": 1778419204
},
"c8db359d8709c69a95f0e6f68216d220": {
"days": 9999,
"source": "rule",
"name": "Bicarbonato",
"location": "dispensa",
"ts": 1778419205
},
"a6d16a09fd9a6bfbd0a915f05dd71780": {
"days": 7,
"source": "ai",
"name": "Salsa Pronta Ciliegini",
"location": "frigo",
"ts": 1778419205
},
"4f8f1bb04a00e5fc62d7a9cfb21e1796": {
"days": 365,
"source": "rule",
"name": "Riso Chicchi Ricchi Gran Risparmio",
"location": "dispensa",
"ts": 1778419206
},
"e116e4c11084a463f9aaac02e1749fe7": {
"days": 90,
"source": "rule",
"name": "Salsa di soia",
"location": "dispensa",
"ts": 1778419207
},
"b1ad9afd4139b3f225b79af4dae256ce": {
"days": 60,
"source": "rule",
"name": "Tè Al limone",
"location": "dispensa",
"ts": 1778419504
},
"7ff2b7d326dcba52a664cebbf12f78a2": {
"days": 3,
"source": "ai",
"name": "Piselli fini 1\/2 vapore",
"location": "frigo",
"ts": 1778419505
},
"71062dc7ffd82b3ee4f40bad076a7c91": {
"days": 60,
"source": "rule",
"name": "Cioccolato bianco",
"location": "frigo",
"ts": 1778419506
},
"38a0eaea422dfe970eba125494e75981": {
"days": 180,
"source": "rule",
"name": "Zucca a pezzi",
"location": "freezer",
"ts": 1778419506
},
"cde21270e1cd50c431742e49117b225d": {
"days": 7,
"source": "rule",
"name": "Pancetta Dolce",
"location": "frigo",
"ts": 1778419507
}
} }
+1
View File
@@ -0,0 +1 @@
{}
+1 -1
View File
@@ -1462,6 +1462,6 @@
</div> </div>
</div> </div>
<script src="assets/js/app.js?v=20260510e"></script> <script src="assets/js/app.js?v=20260510f"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf", "name": "EverShelf",
"short_name": "EverShelf", "short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode", "description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.7", "version": "1.7.8",
"start_url": "/evershelf/", "start_url": "/evershelf/",
"display": "standalone", "display": "standalone",
"background_color": "#f0f4e8", "background_color": "#f0f4e8",
+3 -1
View File
@@ -471,7 +471,9 @@
"suggestion_snack_text": "Was kann ich als schnellen Snack machen?", "suggestion_snack_text": "Was kann ich als schnellen Snack machen?",
"suggestion_juice_text": "Mach mir einen Saft oder Smoothie mit dem was ich habe", "suggestion_juice_text": "Mach mir einen Saft oder Smoothie mit dem was ich habe",
"suggestion_light_text": "Ich habe Hunger, möchte aber etwas Leichtes", "suggestion_light_text": "Ich habe Hunger, möchte aber etwas Leichtes",
"suggestion_expiry_text": "Was läuft bald ab und wie kann ich es verwenden?" "suggestion_expiry_text": "Was läuft bald ab und wie kann ich es verwenden?",
"use_ingredients_btn": "Zutaten verwenden",
"recipe_ingredients_from_pantry": "Im Vorratsschrank gefundene Zutaten"
}, },
"cooking": { "cooking": {
"close": "Schließen", "close": "Schließen",
+3 -1
View File
@@ -471,7 +471,9 @@
"suggestion_snack_text": "What can I make for a quick snack?", "suggestion_snack_text": "What can I make for a quick snack?",
"suggestion_juice_text": "Make me a juice or smoothie with what I have", "suggestion_juice_text": "Make me a juice or smoothie with what I have",
"suggestion_light_text": "I'm hungry but want something light", "suggestion_light_text": "I'm hungry but want something light",
"suggestion_expiry_text": "What's about to expire and how can I use it?" "suggestion_expiry_text": "What's about to expire and how can I use it?",
"use_ingredients_btn": "Use ingredients",
"recipe_ingredients_from_pantry": "Ingredients found in pantry"
}, },
"cooking": { "cooking": {
"close": "Close", "close": "Close",
+3 -1
View File
@@ -471,7 +471,9 @@
"suggestion_snack_text": "Cosa posso preparare per uno spuntino veloce?", "suggestion_snack_text": "Cosa posso preparare per uno spuntino veloce?",
"suggestion_juice_text": "Fammi un succo o frullato con quello che ho", "suggestion_juice_text": "Fammi un succo o frullato con quello che ho",
"suggestion_light_text": "Ho fame ma voglio qualcosa di leggero", "suggestion_light_text": "Ho fame ma voglio qualcosa di leggero",
"suggestion_expiry_text": "Cosa sta per scadere e come posso usarlo?" "suggestion_expiry_text": "Cosa sta per scadere e come posso usarlo?",
"use_ingredients_btn": "Usa ingredienti",
"recipe_ingredients_from_pantry": "Ingredienti trovati in dispensa"
}, },
"cooking": { "cooking": {
"close": "Chiudi", "close": "Chiudi",