diff --git a/api/index.php b/api/index.php index 1c04fbf..0a1421e 100644 --- a/api/index.php +++ b/api/index.php @@ -91,6 +91,10 @@ try { generateRecipe($db); break; + case 'gemini_identify': + geminiIdentifyProduct(); + break; + default: http_response_code(404); echo json_encode(['error' => 'Unknown action: ' . $action]); @@ -509,13 +513,13 @@ function getStats(PDO $db): void { $recentIn = $db->query("SELECT COUNT(*) FROM transactions WHERE type='in' AND created_at >= datetime('now', '-7 days')")->fetchColumn(); $recentOut = $db->query("SELECT COUNT(*) FROM transactions WHERE type='out' AND created_at >= datetime('now', '-7 days')")->fetchColumn(); - // Expiring soonest (next items to expire, up to 10) + // Expiring soonest (next 4 items to expire) $expiring = $db->query(" SELECT i.*, p.name, p.brand FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.expiry_date IS NOT NULL AND i.expiry_date >= date('now') AND i.quantity > 0 ORDER BY i.expiry_date ASC - LIMIT 10 + LIMIT 4 ")->fetchAll(); // Expired @@ -798,3 +802,169 @@ PROMPT; echo json_encode(['success' => false, 'error' => 'Impossibile generare la ricetta', 'raw' => $text]); } } + +// ===== GEMINI AI PRODUCT IDENTIFICATION ===== +function geminiIdentifyProduct(): void { + // Load API key + $envFile = __DIR__ . '/../.env'; + $apiKey = ''; + if (file_exists($envFile)) { + $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + if (strpos($line, '#') === 0) continue; + if (strpos($line, '=') !== false) { + list($key, $val) = explode('=', $line, 2); + if (trim($key) === 'GEMINI_API_KEY') { + $apiKey = trim($val); + } + } + } + } + + if (empty($apiKey)) { + echo json_encode(['success' => false, 'error' => 'no_api_key']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + $imageBase64 = $input['image'] ?? ''; + + if (empty($imageBase64)) { + echo json_encode(['success' => false, 'error' => 'No image provided']); + return; + } + + // Step 1: Ask Gemini to identify the product + $prompt = << [ + [ + 'parts' => [ + ['text' => $prompt], + [ + 'inline_data' => [ + 'mime_type' => 'image/jpeg', + 'data' => $imageBase64 + ] + ] + ] + ] + ], + 'generationConfig' => [ + 'temperature' => 0.2, + 'maxOutputTokens' => 512 + ] + ]; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($response === false || $httpCode !== 200) { + echo json_encode(['success' => false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode]); + return; + } + + $data = json_decode($response, true); + $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; + + $text = preg_replace('/^```json\\s*/i', '', $text); + $text = preg_replace('/\\s*```$/i', '', $text); + $text = trim($text); + + $identified = json_decode($text, true); + + if (!$identified || empty($identified['name'])) { + echo json_encode(['success' => false, 'error' => 'Impossibile identificare il prodotto', 'raw' => $text]); + return; + } + + // Step 2: Search Open Food Facts by product name to find a matching barcode + $searchTerms = $identified['search_terms'] ?? $identified['name']; + $offProducts = searchOpenFoodFacts($searchTerms, $identified['name'], $identified['brand'] ?? ''); + + echo json_encode([ + 'success' => true, + 'identified' => $identified, + 'off_matches' => $offProducts + ]); +} + +function searchOpenFoodFacts(string $searchTerms, string $name, string $brand): array { + $results = []; + + // Try multiple search strategies + $queries = []; + if (!empty($brand)) { + $queries[] = trim($brand . ' ' . $name); + } + $queries[] = $name; + if ($searchTerms !== $name) { + $queries[] = $searchTerms; + } + + $seen = []; + foreach ($queries as $query) { + $encodedQuery = urlencode($query); + $url = "https://world.openfoodfacts.org/cgi/search.pl?search_terms={$encodedQuery}&search_simple=1&action=process&json=1&page_size=5&fields=code,product_name,product_name_it,brands,image_front_small_url,quantity,categories_tags&lc=it"; + + $ctx = stream_context_create([ + 'http' => [ + 'timeout' => 8, + 'header' => "User-Agent: DispensaManager/1.0\r\n" + ] + ]); + + $response = @file_get_contents($url, false, $ctx); + if ($response === false) continue; + + $data = json_decode($response, true); + if (empty($data['products'])) continue; + + foreach ($data['products'] as $p) { + $code = $p['code'] ?? ''; + if (empty($code) || isset($seen[$code])) continue; + $seen[$code] = true; + + $pName = $p['product_name_it'] ?? $p['product_name'] ?? ''; + if (empty($pName)) continue; + + $results[] = [ + 'barcode' => $code, + 'name' => $pName, + 'brand' => $p['brands'] ?? '', + 'image_url' => $p['image_front_small_url'] ?? '', + 'quantity_info' => $p['quantity'] ?? '', + 'category' => $p['categories_tags'][0] ?? '', + ]; + + if (count($results) >= 6) break 2; + } + } + + return $results; +} diff --git a/assets/css/style.css b/assets/css/style.css index 819a241..f60f973 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -1725,3 +1725,65 @@ body { margin-bottom: 12px; line-height: 1.5; } + +/* ===== AI IDENTIFICATION RESULTS ===== */ +.ai-identified-card { + background: var(--bg-light); + border-radius: var(--radius-sm); + padding: 12px; + margin-top: 8px; +} + +.ai-matches-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.ai-match-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + background: var(--bg-card); + border: 2px solid var(--bg-light); + border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.ai-match-item:active { + border-color: var(--primary); + background: var(--bg-light); +} + +.ai-match-img { + width: 48px; + height: 48px; + object-fit: contain; + border-radius: var(--radius-sm); + flex-shrink: 0; + background: #fff; +} + +.ai-match-info { + flex: 1; + min-width: 0; + font-size: 0.9rem; + line-height: 1.4; +} + +.ai-match-info strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-match-barcode { + font-size: 0.7rem; + color: var(--text-muted); + font-family: monospace; + flex-shrink: 0; +} diff --git a/assets/js/app.js b/assets/js/app.js index 44c0a6b..2c5638d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1669,14 +1669,12 @@ async function initAICamera() { const captureDiv = document.getElementById('ai-capture'); const previewDiv = document.getElementById('ai-preview'); const captureBtn = document.getElementById('ai-capture-btn'); - const analyzeBtn = document.getElementById('ai-analyze-btn'); const retakeBtn = document.getElementById('ai-retake-btn'); const resultDiv = document.getElementById('ai-result'); captureDiv.style.display = 'block'; previewDiv.style.display = 'none'; captureBtn.style.display = 'block'; - analyzeBtn.style.display = 'none'; retakeBtn.style.display = 'none'; resultDiv.style.display = 'none'; @@ -1718,8 +1716,10 @@ function takePhotoForAI() { document.getElementById('ai-capture').style.display = 'none'; document.getElementById('ai-preview').style.display = 'block'; document.getElementById('ai-capture-btn').style.display = 'none'; - document.getElementById('ai-analyze-btn').style.display = 'block'; document.getElementById('ai-retake-btn').style.display = 'block'; + + // Immediately start analysis + analyzeWithAI(); } function retakePhotoAI() { @@ -1730,82 +1730,173 @@ function retakePhotoAI() { async function analyzeWithAI() { const resultDiv = document.getElementById('ai-result'); resultDiv.style.display = 'block'; - resultDiv.innerHTML = '

🤖 Analisi in corso...

'; - + resultDiv.innerHTML = '

🤖 Identifico il prodotto...

'; + const canvas = document.getElementById('ai-canvas'); - const imageData = canvas.toDataURL('image/jpeg', 0.7); - - // We'll use a free approach: analyze image colors and shapes locally - // and try to identify using image analysis heuristics - const ctx = canvas.getContext('2d'); - const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); - - // Simple color analysis to guess product type - let r = 0, g = 0, b = 0; - const pixels = imgData.data; - const count = pixels.length / 4; - for (let i = 0; i < pixels.length; i += 16) { // sample every 4th pixel - r += pixels[i]; - g += pixels[i + 1]; - b += pixels[i + 2]; + const base64 = canvas.toDataURL('image/jpeg', 0.7).split(',')[1]; + + try { + const result = await api('gemini_identify', {}, 'POST', { image: base64 }); + + if (!result.success) { + if (result.error === 'no_api_key') { + resultDiv.innerHTML = `

⚠️ Chiave API Gemini non configurata.
Aggiungi GEMINI_API_KEY nel file .env sul server.

`; + } else { + resultDiv.innerHTML = `

❌ ${escapeHtml(result.error || 'Errore nell\'identificazione')}

+ `; + } + return; + } + + const id = result.identified; + const matches = result.off_matches || []; + + let html = `

🤖 Prodotto identificato

`; + html += `
`; + html += `${escapeHtml(id.name)}`; + if (id.brand) html += ` - ${escapeHtml(id.brand)}`; + if (id.description) html += `

${escapeHtml(id.description)}

`; + html += `
`; + + if (matches.length > 0) { + html += `

📦 Prodotti corrispondenti

`; + html += `
`; + matches.forEach((m, idx) => { + html += `
`; + if (m.image_url) { + html += ``; + } + html += `
`; + html += `${escapeHtml(m.name)}`; + if (m.brand) html += `
${escapeHtml(m.brand)}`; + if (m.quantity_info) html += `
${escapeHtml(m.quantity_info)}`; + html += `
`; + html += `${m.barcode}`; + html += `
`; + }); + html += `
`; + } + + // Option to save as-is without barcode + html += `
`; + html += ``; + html += `
`; + + resultDiv.innerHTML = html; + + // Store data for later use + window._aiIdentified = id; + window._aiMatches = matches; + + } catch (err) { + console.error('AI identify error:', err); + resultDiv.innerHTML = `

❌ Errore di connessione

+ `; } - const samples = count / 4; - r = Math.round(r / samples); - g = Math.round(g / samples); - b = Math.round(b / samples); - - // Provide a manual identification form since free AI APIs are limited - resultDiv.innerHTML = ` -

🤖 Identificazione Prodotto

-

- L'analisi automatica ha dei limiti senza API a pagamento. - Puoi descrivere il prodotto qui sotto e lo salveremo nel database. -

-
-
- - -
-
- - -
-
- - -
- -
- `; } -async function submitAIProduct(e) { - e.preventDefault(); +async function selectAIMatch(idx) { + const match = window._aiMatches[idx]; + if (!match) return; + showLoading(true); - - const name = document.getElementById('ai-product-name').value; - const brand = document.getElementById('ai-product-brand').value; - const category = document.getElementById('ai-product-category').value; - - // Save the captured image as base64 (we could save to file, but for simplicity use image_url) - const canvas = document.getElementById('ai-canvas'); - // For a lightweight approach, don't store the actual image data in DB - + try { - const result = await api('product_save', {}, 'POST', { - name, brand, category, + // Use the barcode to do a full lookup (gets all details) + const localResult = await api('search_barcode', { barcode: match.barcode }); + if (localResult.found) { + currentProduct = localResult.product; + showLoading(false); + showProductAction(); + return; + } + + // Full lookup via OpenFoodFacts + const lookupResult = await api('lookup_barcode', { barcode: match.barcode }); + if (lookupResult.found && lookupResult.product) { + const p = lookupResult.product; + const detected = detectUnitAndQuantity(p.quantity_info); + + const notesParts = []; + if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`); + if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`); + if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`); + if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`); + if (p.origin) notesParts.push(`Origine: ${p.origin}`); + + const saveResult = await api('product_save', {}, 'POST', { + barcode: match.barcode, + name: p.name || match.name, + brand: p.brand || match.brand || '', + category: p.category || '', + image_url: p.image_url || match.image_url || '', + unit: detected.unit, + default_quantity: detected.quantity, + notes: notesParts.join(' · '), + }); + + if (saveResult.id) { + currentProduct = { + id: saveResult.id, + barcode: match.barcode, + name: p.name || match.name, + brand: p.brand || match.brand || '', + category: p.category || '', + image_url: p.image_url || match.image_url || '', + unit: detected.unit, + default_quantity: detected.quantity, + weight_info: p.quantity_info || '', + }; + showLoading(false); + showProductAction(); + return; + } + } + + // Fallback: save with basic info from match + const saveResult = await api('product_save', {}, 'POST', { + barcode: match.barcode, + name: match.name, + brand: match.brand || '', + category: match.category || '', + image_url: match.image_url || '', unit: 'pz', default_quantity: 1, }); - - if (result.success) { - currentProduct = { id: result.id, name, brand, category, unit: 'pz', default_quantity: 1 }; + + if (saveResult.id) { + currentProduct = { id: saveResult.id, barcode: match.barcode, name: match.name, brand: match.brand || '', category: match.category || '', image_url: match.image_url || '', unit: 'pz', default_quantity: 1 }; showLoading(false); - showToast('Prodotto identificato e salvato!', 'success'); + showProductAction(); + } else { + showLoading(false); + showToast('Errore nel salvataggio', 'error'); + } + } catch (err) { + showLoading(false); + console.error('AI match select error:', err); + showToast('Errore di connessione', 'error'); + } +} + +async function saveAIProductDirect() { + const id = window._aiIdentified; + if (!id) return; + + showLoading(true); + try { + const result = await api('product_save', {}, 'POST', { + name: id.name, + brand: id.brand || '', + category: id.category || '', + unit: 'pz', + default_quantity: 1, + }); + + if (result.success || result.id) { + currentProduct = { id: result.id, name: id.name, brand: id.brand || '', category: id.category || '', unit: 'pz', default_quantity: 1 }; + showLoading(false); + showToast('Prodotto salvato!', 'success'); showProductAction(); } else { showLoading(false); diff --git a/index.html b/index.html index ce26e91..2ddd009 100644 --- a/index.html +++ b/index.html @@ -53,15 +53,16 @@ - - + + +
@@ -436,9 +437,6 @@ -