From 073b4b9cfaf55771f88f8802e811ebd8536f005a Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Sun, 10 May 2026 14:49:08 +0000 Subject: [PATCH] v1.7.8: Trasferisci a Ricette dalla chat (refactor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sostituisce 'Usa ingredienti' inline con 'Trasferisci a Ricette' - Nuovo endpoint chat_to_recipe: Gemini restituisce JSON completo (title, meal, servings, ingredients, steps, nutrition_note), PHP arricchisce tutti gli ingredienti con product_id/location via fuzzy-match identico a generateRecipe - La ricetta viene salvata in archivio e si apre nell'overlay Ricette con tutti i pulsanti Usa, modalità cottura, salvataggio intatto - Rimossi: chatExtractIngredients, _buildChatIngredientPanelHTML, _chatRecipeTitle, chat_extract_recipe, chat-recipe-panel CSS --- CHANGELOG.md | 2 +- api/index.php | 57 +++++++++++++++------------ assets/css/style.css | 25 +----------- assets/js/app.js | 93 ++++---------------------------------------- index.html | 2 +- translations/de.json | 5 ++- translations/en.json | 5 ++- translations/it.json | 5 ++- 8 files changed, 51 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a201897..d87b684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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. +- **Trasferisci a Ricette dalla chat** — Quando la chat con Gemini Chef genera una ricetta, compare il bottone "📥 Trasferisci a Ricette". Premendolo, Gemini converte il testo in JSON strutturato completo (titolo, pasti, ingredienti, passi), il backend arricchisce ogni ingrediente con product_id e location via fuzzy-match (identico a generateRecipe), la ricetta viene salvata in archivio e si apre direttamente nella sezione Ricette con tutti i pulsanti "Usa" e la modalità cottura completa. ## [1.7.7] - 2026-05-10 diff --git a/api/index.php b/api/index.php index ae99467..d90b842 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', 'chat_extract_recipe']; + $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe']; $loginActions = []; $recipeActions = ['generate_recipe', 'generate_recipe_stream']; $errorActions = ['report_error', 'check_update']; @@ -335,8 +335,8 @@ try { geminiChat($db); break; - case 'chat_extract_recipe': - chatExtractRecipe($db); + case 'chat_to_recipe': + chatToRecipe($db); break; // ===== BRING! SHOPPING LIST ===== @@ -3595,8 +3595,8 @@ PROMPT; } } -// ===== CHAT: EXTRACT RECIPE INGREDIENTS ===== -function chatExtractRecipe(PDO $db): void { +// ===== CHAT: CONVERT CHAT RECIPE TO STRUCTURED RECIPE ===== +function chatToRecipe(PDO $db): void { $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { echo json_encode(['success' => false, 'error' => 'no_api_key']); @@ -3605,6 +3605,7 @@ function chatExtractRecipe(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); $replyText = trim($input['text'] ?? ''); + $lang = recipeNormalizeLang($input['lang'] ?? 'it'); if (empty($replyText)) { echo json_encode(['success' => false, 'error' => 'empty_text']); @@ -3622,9 +3623,25 @@ function chatExtractRecipe(PDO $db): void { "); $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}"; + // Ask Gemini to convert the chat recipe text into the full structured recipe JSON. + // Prompt is tiny — no inventory sent to Gemini (PHP does all the matching below). + $prompt = << [['role' => 'user', 'parts' => [['text' => $prompt]]]], @@ -3644,35 +3661,25 @@ function chatExtractRecipe(PDO $db): void { 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)) { + $recipe = json_decode($text, true); + if (!is_array($recipe) || empty($recipe['title'])) { 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]); + // Enrich ingredients with product_id/location — same fuzzy-match as generateRecipe + if (!empty($recipe['ingredients'])) { + _enrichChatIngredients($recipe['ingredients'], $items); } - echo json_encode(['success' => true, 'ingredients' => $ingredients, 'title' => $recipeTitle]); + echo json_encode(['success' => true, 'recipe' => $recipe]); } + function _enrichChatIngredients(array &$ingredients, array $items): void { if (empty($ingredients) || empty($items)) return; diff --git a/assets/css/style.css b/assets/css/style.css index 28e7e39..e7421fe 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -5630,7 +5630,7 @@ body.cooking-mode-active .app-header { 30% { transform: translateY(-6px); opacity: 1; } } -/* ====== Chat Recipe Ingredient Panel ====== */ +/* ====== Chat Transfer to Recipes button ====== */ .btn-chat-use-recipe { display: inline-block; margin-top: 10px; @@ -5652,29 +5652,6 @@ body.cooking-mode-active .app-header { 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 2b97a15..00b8b59 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 || _chatRecipeTitle || ''; + const recipeTitle = _cachedRecipe?.recipe?.title || ''; const result = await api('inventory_use', {}, 'POST', { product_id: productId, quantity: qty, @@ -12382,7 +12382,6 @@ 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 @@ -12425,80 +12424,6 @@ function _looksLikeRecipe(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 = `
`; - if (title) html += `
🍽️ ${escapeHtml(title)}
`; - html += `
${t('chat.recipe_ingredients_from_pantry') || 'Ingredienti in dispensa'}
`; - html += `
    `; - - ingredients.forEach((ing, idx) => { - if (ing.product_id) { - const qtyNum = ing.qty_number || 0; - const loc = (ing.location || 'dispensa').replace(/'/g, "\\'"); - html += `
  • `; - html += `${escapeHtml(ing.name)}${ing.brand ? ' (' + escapeHtml(ing.brand) + ')' : ''}: ${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 += `
    ${details.join(' · ')}`; - html += `
    `; - html += ``; - html += `
  • `; - } else { - html += `
  • ${escapeHtml(ing.name)}: ${escapeHtml(ing.qty || '')} 🛒
  • `; - } - }); - - html += `
`; - return html; -} - async function sendChatMessage() { const input = document.getElementById('chat-input'); const text = input.value.trim(); @@ -12538,18 +12463,14 @@ async function sendChatMessage() { if (result.success) { chatHistory.push({ role: 'gemini', text: result.reply }); const bubble = appendChatBubble('gemini', formatChatReply(result.reply)); - // If reply looks like a recipe, append "Usa ingredienti" button + // If reply looks like a recipe, append "Trasferisci a Ricette" 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); + const transferBtn = document.createElement('button'); + transferBtn.className = 'btn-chat-use-recipe'; + transferBtn.textContent = '📥 ' + (t('chat.transfer_to_recipes') || 'Trasferisci a Ricette'); + transferBtn.onclick = () => chatTransferToRecipes(transferBtn, replyText); + bubble.appendChild(transferBtn); } } else { const errMsg = result.error === 'no_api_key' ? 'Configura la chiave API Gemini nelle impostazioni.' : (result.error || 'Errore nella risposta'); diff --git a/index.html b/index.html index b5d8f95..1188561 100644 --- a/index.html +++ b/index.html @@ -1462,6 +1462,6 @@ - + diff --git a/translations/de.json b/translations/de.json index 2c5bc3e..8460b86 100644 --- a/translations/de.json +++ b/translations/de.json @@ -472,8 +472,9 @@ "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_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" + "transfer_to_recipes": "Zu Rezepten hinzufügen", + "transferring": "Übertrage...", + "transferred": "Zu Rezepten hinzugefügt!" }, "cooking": { "close": "Schließen", diff --git a/translations/en.json b/translations/en.json index 8d3ba40..920085e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -472,8 +472,9 @@ "suggestion_juice_text": "Make me a juice or smoothie with what I have", "suggestion_light_text": "I'm hungry but want something light", "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" + "transfer_to_recipes": "Transfer to Recipes", + "transferring": "Transferring...", + "transferred": "Added to Recipes!" }, "cooking": { "close": "Close", diff --git a/translations/it.json b/translations/it.json index bfa4bbf..f78061a 100644 --- a/translations/it.json +++ b/translations/it.json @@ -472,8 +472,9 @@ "suggestion_juice_text": "Fammi un succo o frullato con quello che ho", "suggestion_light_text": "Ho fame ma voglio qualcosa di leggero", "suggestion_expiry_text": "Cosa sta per scadere e come posso usarlo?", - "use_ingredients_btn": "Usa ingredienti", - "recipe_ingredients_from_pantry": "Ingredienti trovati in dispensa" + "transfer_to_recipes": "Trasferisci a Ricette", + "transferring": "Trasferimento in corso...", + "transferred": "Aggiunta alle Ricette!" }, "cooking": { "close": "Chiudi",