diff --git a/.gitignore b/.gitignore index b89c378..6d5ed0a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ data/*.db-wal data/*.db-shm +# Bring! auth token cache +data/bring_token.json + # SSL CA cert (local only) ca.crt diff --git a/api/index.php b/api/index.php index 38f43ce..ae08c17 100644 --- a/api/index.php +++ b/api/index.php @@ -95,6 +95,20 @@ try { geminiIdentifyProduct(); break; + // ===== BRING! SHOPPING LIST ===== + case 'bring_list': + bringGetList(); + break; + case 'bring_add': + bringAddItems(); + break; + case 'bring_remove': + bringRemoveItem(); + break; + case 'bring_suggest': + bringSuggestItems($db); + break; + default: http_response_code(404); echo json_encode(['error' => 'Unknown action: ' . $action]); @@ -968,3 +982,366 @@ function searchOpenFoodFacts(string $searchTerms, string $name, string $brand): return $results; } + +// ===== BRING! SHOPPING LIST INTEGRATION ===== + +function loadEnvVars(): array { + $envFile = __DIR__ . '/../.env'; + $vars = []; + 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); + $vars[trim($key)] = trim($val); + } + } + } + return $vars; +} + +function bringAuth(): ?array { + $env = loadEnvVars(); + $email = $env['BRING_EMAIL'] ?? ''; + $password = $env['BRING_PASSWORD'] ?? ''; + + if (empty($email) || empty($password)) { + return null; + } + + // Check cache file for valid token + $cacheFile = __DIR__ . '/../data/bring_token.json'; + if (file_exists($cacheFile)) { + $cached = json_decode(file_get_contents($cacheFile), true); + if ($cached && isset($cached['expires']) && $cached['expires'] > time()) { + return $cached; + } + } + + $url = 'https://api.getbring.com/rest/v2/bringauth'; + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/x-www-form-urlencoded\r\nX-BRING-API-KEY: cof4Nc6D8sOprah0hUXrFl\r\nX-BRING-CLIENT: webApp\r\n", + 'content' => http_build_query(['email' => $email, 'password' => $password]), + 'timeout' => 10, + ] + ]); + + $response = @file_get_contents($url, false, $ctx); + if ($response === false) return null; + + $data = json_decode($response, true); + if (!isset($data['access_token'])) return null; + + $tokenData = [ + 'access_token' => $data['access_token'], + 'uuid' => $data['uuid'], + 'bringListUUID' => $data['bringListUUID'] ?? '', + 'expires' => time() + 3500, // tokens last ~1 hour + ]; + + // Cache token + @file_put_contents($cacheFile, json_encode($tokenData)); + + return $tokenData; +} + +function bringRequest(string $method, string $url, ?string $body = null): ?array { + $auth = bringAuth(); + if (!$auth) { + return null; + } + + $headers = "Authorization: Bearer {$auth['access_token']}\r\n" . + "X-BRING-API-KEY: cof4Nc6D8sOprah0hUXrFl\r\n" . + "X-BRING-CLIENT: webApp\r\n" . + "Content-Type: application/x-www-form-urlencoded\r\n"; + + $opts = [ + 'http' => [ + 'method' => $method, + 'header' => $headers, + 'timeout' => 10, + 'ignore_errors' => true, + ] + ]; + if ($body !== null) { + $opts['http']['content'] = $body; + } + + $response = @file_get_contents($url, false, stream_context_create($opts)); + if ($response === false) return null; + + $data = json_decode($response, true); + return $data ?? ['_raw' => $response]; +} + +function bringGetList(): void { + $auth = bringAuth(); + if (!$auth) { + echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate. Aggiungi BRING_EMAIL e BRING_PASSWORD al file .env']); + return; + } + + $listUUID = $auth['bringListUUID']; + if (empty($listUUID)) { + // Try to get lists + $lists = bringRequest('GET', "https://api.getbring.com/rest/v2/bringusers/{$auth['uuid']}/lists"); + if ($lists && isset($lists['lists'][0]['listUuid'])) { + $listUUID = $lists['lists'][0]['listUuid']; + } else { + echo json_encode(['success' => false, 'error' => 'Nessuna lista Bring! trovata']); + return; + } + } + + $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); + if (!$data) { + echo json_encode(['success' => false, 'error' => 'Errore nel recupero della lista']); + return; + } + + $purchase = []; + $recently = []; + + if (isset($data['purchase'])) { + foreach ($data['purchase'] as $item) { + $purchase[] = [ + 'name' => $item['name'] ?? '', + 'specification' => $item['specification'] ?? '', + ]; + } + } + if (isset($data['recently'])) { + foreach ($data['recently'] as $item) { + $recently[] = [ + 'name' => $item['name'] ?? '', + 'specification' => $item['specification'] ?? '', + ]; + } + } + + echo json_encode([ + 'success' => true, + 'listUUID' => $listUUID, + 'purchase' => $purchase, + 'recently' => $recently, + ]); +} + +function bringAddItems(): void { + $auth = bringAuth(); + if (!$auth) { + echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + $items = $input['items'] ?? []; + $listUUID = $input['listUUID'] ?? $auth['bringListUUID']; + + if (empty($listUUID)) { + echo json_encode(['success' => false, 'error' => 'Lista non trovata']); + return; + } + + $added = 0; + $errors = []; + + foreach ($items as $item) { + $name = $item['name'] ?? ''; + $spec = $item['specification'] ?? ''; + if (empty($name)) continue; + + $body = http_build_query([ + 'uuid' => $listUUID, + 'purchase' => $name, + 'specification' => $spec, + ]); + + $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); + if ($result !== null) { + $added++; + } else { + $errors[] = $name; + } + } + + echo json_encode(['success' => true, 'added' => $added, 'errors' => $errors]); +} + +function bringRemoveItem(): void { + $auth = bringAuth(); + if (!$auth) { + echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + $name = $input['name'] ?? ''; + $listUUID = $input['listUUID'] ?? $auth['bringListUUID']; + + if (empty($name) || empty($listUUID)) { + echo json_encode(['success' => false, 'error' => 'Parametri mancanti']); + return; + } + + $body = http_build_query([ + 'uuid' => $listUUID, + 'remove' => $name, + ]); + + $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body); + echo json_encode(['success' => $result !== null]); +} + +function bringSuggestItems(PDO $db): void { + $env = loadEnvVars(); + $apiKey = $env['GEMINI_API_KEY'] ?? ''; + + if (empty($apiKey)) { + echo json_encode(['success' => false, 'error' => 'API Key Gemini non configurata']); + return; + } + + // Get current Bring! list + $auth = bringAuth(); + $bringItems = []; + $listUUID = ''; + if ($auth) { + $listUUID = $auth['bringListUUID']; + if (empty($listUUID)) { + $lists = bringRequest('GET', "https://api.getbring.com/rest/v2/bringusers/{$auth['uuid']}/lists"); + if ($lists && isset($lists['lists'][0]['listUuid'])) { + $listUUID = $lists['lists'][0]['listUuid']; + } + } + if ($listUUID) { + $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); + if ($data && isset($data['purchase'])) { + foreach ($data['purchase'] as $item) { + $bringItems[] = $item['name'] ?? ''; + } + } + } + } + + // Get inventory + $stmt = $db->query(" + SELECT p.name, p.brand, p.category, i.quantity, p.unit, i.location, i.expiry_date, + 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 p.category, p.name + "); + $inventory = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Build context + $invLines = []; + foreach ($inventory as $item) { + $line = "- {$item['name']}"; + if ($item['brand']) $line .= " ({$item['brand']})"; + $line .= ": {$item['quantity']} {$item['unit']} in {$item['location']}"; + if ($item['expiry_date']) { + $dl = intval($item['days_left']); + if ($dl < 0) $line .= " [SCADUTO]"; + elseif ($dl <= 3) $line .= " [scade tra {$dl}g]"; + } + $invLines[] = $line; + } + $inventoryText = empty($invLines) ? 'La dispensa è VUOTA.' : implode("\n", $invLines); + + $bringText = empty($bringItems) ? 'La lista della spesa è vuota.' : 'Già nella lista della spesa Bring!: ' . implode(', ', $bringItems); + + // Current month for seasonal suggestions + $mese = strftime('%B') ?: date('F'); + $mesi_it = ['January'=>'Gennaio','February'=>'Febbraio','March'=>'Marzo','April'=>'Aprile','May'=>'Maggio','June'=>'Giugno','July'=>'Luglio','August'=>'Agosto','September'=>'Settembre','October'=>'Ottobre','November'=>'Novembre','December'=>'Dicembre']; + $meseIt = $mesi_it[date('F')] ?? date('F'); + $anno = date('Y'); + + $prompt = << [ + ['parts' => [['text' => $prompt]]] + ], + 'generationConfig' => [ + 'temperature' => 0.8, + 'maxOutputTokens' => 2048, + ] + ]; + + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => json_encode($payload), + 'timeout' => 30, + ] + ]); + + $response = @file_get_contents($url, false, $ctx); + if ($response === false) { + echo json_encode(['success' => false, 'error' => 'Errore di connessione a Gemini']); + return; + } + + $data = json_decode($response, true); + $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; + + // Clean markdown artifacts + $text = preg_replace('/^```json\s*/i', '', $text); + $text = preg_replace('/```\s*$/', '', $text); + $text = trim($text); + + $suggestions = json_decode($text, true); + if (!$suggestions || !isset($suggestions['suggestions'])) { + echo json_encode(['success' => false, 'error' => 'Risposta AI non valida', 'raw' => $text]); + return; + } + + echo json_encode([ + 'success' => true, + 'suggestions' => $suggestions['suggestions'], + 'seasonal_tip' => $suggestions['seasonal_tip'] ?? '', + 'listUUID' => $listUUID, + ]); +} diff --git a/assets/css/style.css b/assets/css/style.css index 85db746..49fdc6e 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -969,6 +969,213 @@ body { margin-top: 4px; } +/* ===== SHOPPING LIST (BRING!) ===== */ +.shopping-container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.bring-loading { + text-align: center; + padding: 30px; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.bring-error { + text-align: center; + padding: 20px; + background: #fef2f2; + color: #dc2626; + border-radius: var(--radius); + font-size: 0.9rem; +} + +.shopping-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.shopping-section-header h3 { + font-size: 1rem; + margin: 0; +} + +.shopping-count { + background: var(--accent); + color: #fff; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 600; +} + +.shopping-items { + display: flex; + flex-direction: column; + gap: 4px; +} + +.shopping-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--bg-card); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.shopping-item-icon { + font-size: 1.3rem; + flex-shrink: 0; +} + +.shopping-item-info { + flex: 1; + min-width: 0; +} + +.shopping-item-name { + font-weight: 600; + font-size: 0.95rem; +} + +.shopping-item-spec { + font-size: 0.8rem; + color: var(--text-muted); +} + +.shopping-item-remove { + background: none; + border: none; + color: var(--text-muted); + font-size: 1rem; + padding: 4px 8px; + cursor: pointer; + border-radius: 50%; + transition: background 0.2s; +} + +.shopping-item-remove:hover, +.shopping-item-remove:active { + background: #fee2e2; + color: #ef4444; +} + +.shopping-actions { + margin-top: 8px; +} + +.seasonal-tip { + background: linear-gradient(135deg, #ecfdf5, #f0fdf4); + padding: 12px 14px; + border-radius: var(--radius); + font-size: 0.85rem; + color: #065f46; + margin-bottom: 8px; + border-left: 3px solid #10b981; +} + +.suggestion-items { + display: flex; + flex-direction: column; + gap: 4px; +} + +.suggestion-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--bg-card); + border-radius: var(--radius); + box-shadow: var(--shadow); + cursor: pointer; + transition: transform 0.1s, background 0.2s; + border: 2px solid transparent; +} + +.suggestion-item.selected { + border-color: var(--accent); + background: #faf5ff; +} + +.suggestion-item:active { + transform: scale(0.98); +} + +.suggestion-check { + font-size: 1.1rem; + flex-shrink: 0; +} + +.suggestion-info { + flex: 1; + min-width: 0; +} + +.suggestion-name { + font-weight: 600; + font-size: 0.9rem; +} + +.suggestion-name small { + font-weight: 400; + color: var(--text-muted); +} + +.suggestion-reason { + font-size: 0.78rem; + color: var(--text-muted); + margin-top: 2px; +} + +.priority-badge { + display: inline-block; + font-size: 0.65rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + vertical-align: middle; + margin-left: 4px; + text-transform: uppercase; +} + +.priority-high { + background: #fee2e2; + color: #dc2626; +} + +.priority-med { + background: #fef9c3; + color: #ca8a04; +} + +.priority-low { + background: #ecfdf5; + color: #059669; +} + +.suggestion-actions { + margin-top: 12px; + text-align: center; +} + +.suggestion-actions .btn-success { + width: 100%; +} + /* ===== PRODUCT PREVIEW ===== */ .product-preview, .product-preview-small { background: var(--bg-card); diff --git a/assets/js/app.js b/assets/js/app.js index 2a15b75..bd46aa9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -330,6 +330,7 @@ function showPage(pageId, param = null) { break; case 'scan': initScanner(); clearQuickNameResults(); break; case 'products': loadAllProducts(); break; + case 'shopping': loadShoppingList(); break; case 'ai': initAICamera(); break; } @@ -2174,6 +2175,213 @@ async function selectProductForAction(productId) { } } +// ===== SHOPPING LIST (BRING! INTEGRATION) ===== +let shoppingListUUID = ''; +let shoppingItems = []; +let suggestionItems = []; + +async function loadShoppingList() { + const statusEl = document.getElementById('bring-status'); + const currentEl = document.getElementById('shopping-current'); + const suggestionsEl = document.getElementById('shopping-suggestions'); + + statusEl.style.display = 'block'; + statusEl.innerHTML = '
Connessione a Bring!...
'; + currentEl.style.display = 'none'; + suggestionsEl.style.display = 'none'; + + try { + const data = await api('bring_list'); + statusEl.style.display = 'none'; + + if (!data.success) { + statusEl.style.display = 'block'; + statusEl.innerHTML = `
⚠️ ${escapeHtml(data.error || 'Errore connessione Bring!')}
`; + return; + } + + shoppingListUUID = data.listUUID; + shoppingItems = data.purchase || []; + + renderShoppingItems(); + currentEl.style.display = 'block'; + + } catch (err) { + console.error('Bring! error:', err); + statusEl.style.display = 'block'; + statusEl.innerHTML = '
⚠️ Errore di connessione a Bring!
'; + } +} + +function renderShoppingItems() { + const container = document.getElementById('shopping-items'); + const countEl = document.getElementById('shopping-count'); + + countEl.textContent = shoppingItems.length; + + if (shoppingItems.length === 0) { + container.innerHTML = '

Lista della spesa vuota!
Usa il pulsante sotto per generare suggerimenti.

'; + return; + } + + container.innerHTML = shoppingItems.map(item => { + const catIcon = CATEGORY_ICONS[guessCategoryFromName(item.name)] || '🛒'; + return ` +
+ ${catIcon} +
+
${escapeHtml(item.name)}
+ ${item.specification ? `
${escapeHtml(item.specification)}
` : ''} +
+ +
`; + }).join(''); +} + +async function removeBringItem(name) { + try { + const data = await api('bring_remove', {}, 'POST', { name, listUUID: shoppingListUUID }); + if (data.success) { + shoppingItems = shoppingItems.filter(i => i.name !== name); + renderShoppingItems(); + showToast('Rimosso dalla lista', 'success'); + } + } catch (err) { + showToast('Errore nella rimozione', 'error'); + } +} + +async function generateSuggestions() { + const btn = document.getElementById('btn-suggest'); + const suggestionsEl = document.getElementById('shopping-suggestions'); + + btn.disabled = true; + btn.innerHTML = '
Analisi in corso...'; + suggestionsEl.style.display = 'none'; + + try { + const data = await api('bring_suggest', {}, 'POST', {}); + + btn.disabled = false; + btn.innerHTML = '🤖 Suggerisci cosa comprare'; + + if (!data.success) { + showToast(data.error || 'Errore nella generazione', 'error'); + return; + } + + suggestionItems = (data.suggestions || []).map(s => ({ ...s, selected: true })); + + // Show seasonal tip + const tipEl = document.getElementById('seasonal-tip'); + if (data.seasonal_tip) { + tipEl.style.display = 'block'; + tipEl.innerHTML = `🌿 ${escapeHtml(data.seasonal_tip)}`; + } else { + tipEl.style.display = 'none'; + } + + renderSuggestions(); + suggestionsEl.style.display = 'block'; + document.getElementById('suggestion-actions').style.display = 'block'; + + // Scroll to suggestions + suggestionsEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + } catch (err) { + btn.disabled = false; + btn.innerHTML = '🤖 Suggerisci cosa comprare'; + console.error('Suggestion error:', err); + showToast('Errore di connessione', 'error'); + } +} + +function renderSuggestions() { + const container = document.getElementById('suggestion-items'); + + const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 }; + const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2)); + + container.innerHTML = sorted.map((item, idx) => { + const catIcon = CATEGORY_ICONS[item.category] || '🛒'; + const priorityBadge = { + 'alta': 'Alta', + 'media': 'Media', + 'bassa': 'Bassa', + }[item.priority] || ''; + + return ` +
+
${item.selected ? '☑️' : '⬜'}
+ ${catIcon} +
+
${escapeHtml(item.name)}${item.specification ? ` (${escapeHtml(item.specification)})` : ''} ${priorityBadge}
+
${escapeHtml(item.reason)}
+
+
`; + }).join(''); + + updateSuggestionActionBtn(); +} + +function toggleSuggestion(idx) { + const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 }; + const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2)); + const actualItem = sorted[idx]; + // Find in original array + const origIdx = suggestionItems.indexOf(actualItem); + if (origIdx >= 0) { + suggestionItems[origIdx].selected = !suggestionItems[origIdx].selected; + } + renderSuggestions(); +} + +function updateSuggestionActionBtn() { + const selected = suggestionItems.filter(s => s.selected); + const btn = document.querySelector('#suggestion-actions .btn-success'); + if (btn) { + btn.textContent = `✅ Aggiungi ${selected.length} prodott${selected.length === 1 ? 'o' : 'i'} a Bring!`; + btn.disabled = selected.length === 0; + } +} + +async function addSelectedSuggestions() { + const selected = suggestionItems.filter(s => s.selected); + if (selected.length === 0) { + showToast('Seleziona almeno un prodotto', 'error'); + return; + } + + const btn = document.querySelector('#suggestion-actions .btn-success'); + btn.disabled = true; + btn.innerHTML = '
Aggiunta in corso...'; + + try { + const items = selected.map(s => ({ + name: s.name, + specification: s.specification || '', + })); + + const data = await api('bring_add', {}, 'POST', { items, listUUID: shoppingListUUID }); + + if (data.success) { + showToast(`${data.added} prodott${data.added === 1 ? 'o aggiunto' : 'i aggiunti'} a Bring!`, 'success'); + // Refresh list + await loadShoppingList(); + // Clear suggestions + document.getElementById('shopping-suggestions').style.display = 'none'; + suggestionItems = []; + } else { + showToast(data.error || 'Errore', 'error'); + } + } catch (err) { + showToast('Errore di connessione', 'error'); + } + + btn.disabled = false; + btn.innerHTML = '✅ Aggiungi selezionati a Bring!'; +} + // ===== UTILITY FUNCTIONS ===== // ===== SCAN EXPIRY DATE WITH CAMERA + GEMINI AI ===== diff --git a/index.html b/index.html index d080a16..93f58ed 100644 --- a/index.html +++ b/index.html @@ -426,6 +426,43 @@
+ +
+ +
+
+
Connessione a Bring!...
+
+ + +
+ +
+
+
+