diff --git a/CHANGELOG.md b/CHANGELOG.md index 8469e78..a201897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Fixed diff --git a/api/index.php b/api/index.php index ad8f9ae..ae99467 100644 --- a/api/index.php +++ b/api/index.php @@ -111,7 +111,7 @@ function checkRateLimit(string $action): void { } // 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 = []; $recipeActions = ['generate_recipe', 'generate_recipe_stream']; $errorActions = ['report_error', 'check_update']; @@ -335,6 +335,10 @@ try { geminiChat($db); break; + case 'chat_extract_recipe': + chatExtractRecipe($db); + break; + // ===== BRING! SHOPPING LIST ===== case 'bring_list': 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 ===== function generateRecipeStream(PDO $db): void { // Override content-type for SSE before any output is sent diff --git a/assets/css/style.css b/assets/css/style.css index ecfead2..28e7e39 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -5630,6 +5630,51 @@ body.cooking-mode-active .app-header { 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 { display: flex; diff --git a/assets/js/app.js b/assets/js/app.js index da59f4d..2b97a15 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -11355,7 +11355,7 @@ async function submitRecipeUse(useAll) { btn.textContent = '⏳...'; try { - const recipeTitle = _cachedRecipe?.recipe?.title || ''; + const recipeTitle = _cachedRecipe?.recipe?.title || _chatRecipeTitle || ''; const result = await api('inventory_use', {}, 'POST', { product_id: productId, quantity: qty, @@ -12382,6 +12382,7 @@ async function generateRecipe() { let chatHistory = []; let chatInventoryContext = null; 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() { // Load chat history from DB @@ -12415,6 +12416,89 @@ function sendChatSuggestion(text) { 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 = `