feat: shopping list pantry hints, barcode multi-API fallback (OPF/beauty/Gemini), README disclaimer
- Shopping list: each item now shows 'Hai già Xg in dispensa' for same-family inventory stock - Lazy-loads inventory once per shopping page visit (_getShoppingInventoryCache) - Matches by first significant token (same logic as related-stock on action page) - Green hint below item badges, dark-mode aware (.shopping-pantry-hint) - Barcode lookup: added Open Products Facts + Open Beauty Facts as step 3; Gemini AI (_barcodeLookupGemini) as final step 4 fallback - Added stockForName PHP endpoint (stock_for_name action) for future use - Restored missing function signatures for _offFetchProduct() and saveProduct() that were accidentally lost when stockForName was added in a previous session - Translation: added shopping.pantry_hint in it/en/de
This commit is contained in:
+162
@@ -738,6 +738,9 @@ try {
|
||||
case 'lookup_barcode':
|
||||
lookupBarcode();
|
||||
break;
|
||||
case 'stock_for_name':
|
||||
stockForName($db);
|
||||
break;
|
||||
case 'product_save':
|
||||
saveProduct($db);
|
||||
break;
|
||||
@@ -1442,6 +1445,66 @@ function searchBarcode(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all in-stock inventory items whose product name shares the same first
|
||||
* significant token as the given name (e.g. "Carote" matches "Carote Bio", "Carote DOP").
|
||||
* Used by the scan UI to show "you already have X in pantry" before adding a product.
|
||||
*/
|
||||
function stockForName(PDO $db): void {
|
||||
$name = trim($_GET['name'] ?? '');
|
||||
if (empty($name)) {
|
||||
echo json_encode(['items' => []]);
|
||||
return;
|
||||
}
|
||||
|
||||
$stop = ['di','del','della','dei','degli','delle','da','in','con','per','su',
|
||||
'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo'];
|
||||
|
||||
$tokenize = function(string $s) use ($stop): array {
|
||||
$clean = mb_strtolower(preg_replace('/[^\p{L}0-9\s]/u', ' ', $s));
|
||||
return array_values(array_filter(
|
||||
preg_split('/\s+/', trim($clean)),
|
||||
fn($t) => mb_strlen($t) > 2 && !in_array($t, $stop)
|
||||
));
|
||||
};
|
||||
|
||||
$searchTokens = $tokenize($name);
|
||||
if (empty($searchTokens)) {
|
||||
echo json_encode(['items' => []]);
|
||||
return;
|
||||
}
|
||||
$firstToken = $searchTokens[0];
|
||||
|
||||
$rows = $db->query(
|
||||
"SELECT i.quantity, i.unit, i.location,
|
||||
p.name AS product_name, p.brand,
|
||||
p.default_quantity, p.package_unit
|
||||
FROM inventory i
|
||||
JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0
|
||||
ORDER BY p.name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matches = [];
|
||||
foreach ($rows as $row) {
|
||||
$rowTokens = $tokenize($row['product_name']);
|
||||
if (empty($rowTokens)) continue;
|
||||
if ($rowTokens[0] === $firstToken) {
|
||||
$matches[] = [
|
||||
'name' => $row['product_name'],
|
||||
'brand' => $row['brand'] ?? '',
|
||||
'quantity' => (float)$row['quantity'],
|
||||
'unit' => $row['unit'],
|
||||
'location' => $row['location'] ?? '',
|
||||
'default_quantity' => (int)($row['default_quantity'] ?? 0),
|
||||
'package_unit' => $row['package_unit'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['items' => $matches], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
function _offFetchProduct(string $barcode): ?array {
|
||||
$fields = 'product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores';
|
||||
|
||||
@@ -1560,9 +1623,108 @@ function lookupBarcode(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try Open Products Facts (non-food household items) and Open Beauty Facts (cosmetics)
|
||||
$altBases = [
|
||||
'https://world.openproductsfacts.org',
|
||||
'https://world.openbeautyfacts.org',
|
||||
];
|
||||
$altFields = 'product_name,product_name_it,brands,categories_tags,categories_hierarchy,image_front_small_url,image_url,quantity';
|
||||
$altCandidates = [$barcode];
|
||||
if (strlen($barcode) === 12 && ctype_digit($barcode)) $altCandidates[] = '0' . $barcode;
|
||||
foreach ($altBases as $altBase) {
|
||||
foreach ($altCandidates as $bc) {
|
||||
$altUrl = "{$altBase}/api/v2/product/{$bc}.json?fields={$altFields}";
|
||||
$altCtx = stream_context_create(['http' => ['timeout' => 6, 'header' => "User-Agent: EverShelf/1.0\r\n"]]);
|
||||
$altR = @file_get_contents($altUrl, false, $altCtx);
|
||||
if ($altR === false) continue;
|
||||
$altD = json_decode($altR, true);
|
||||
if (!isset($altD['status']) || $altD['status'] !== 1 || empty($altD['product'])) continue;
|
||||
$p = $altD['product'];
|
||||
$altName = $p['product_name_it'] ?? $p['product_name'] ?? '';
|
||||
if (empty($altName)) continue;
|
||||
$altCat = $p['categories_tags'][0] ?? end($p['categories_hierarchy'] ?? []) ?? '';
|
||||
echo json_encode(['found' => true, 'source' => $altBase, 'product' => [
|
||||
'name' => $altName,
|
||||
'brand' => $p['brands'] ?? '',
|
||||
'category' => $altCat,
|
||||
'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '',
|
||||
'quantity_info' => $p['quantity'] ?? '',
|
||||
'nutriscore' => '', 'ingredients' => '', 'allergens' => '',
|
||||
'conservation' => '', 'origin' => '', 'nova_group' => '',
|
||||
'ecoscore' => '', 'labels' => '', 'stores' => '',
|
||||
]]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Gemini AI as last resort — works for well-known products not in any open DB
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if ($apiKey) {
|
||||
$geminiProduct = _barcodeLookupGemini($barcode, $apiKey);
|
||||
if ($geminiProduct !== null) {
|
||||
echo json_encode(['found' => true, 'source' => 'gemini', 'product' => $geminiProduct]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['found' => false, 'source' => 'openfoodfacts']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask Gemini to identify a product by barcode number.
|
||||
* Only used as a last resort when all open databases fail.
|
||||
* Returns null if Gemini doesn't know the product.
|
||||
*/
|
||||
function _barcodeLookupGemini(string $barcode, string $apiKey): ?array {
|
||||
$payload = [
|
||||
'contents' => [[
|
||||
'role' => 'user',
|
||||
'parts' => [[
|
||||
'text' => "You are a product database. A user scanned barcode: {$barcode}\n" .
|
||||
"Identify this product. If you know it, respond with ONLY valid JSON (no markdown, no explanation):\n" .
|
||||
"{\"name\":\"...\",\"brand\":\"...\",\"category\":\"...\"}\n" .
|
||||
"Use the Italian product name if the product is sold in Italy.\n" .
|
||||
"If you do not know this specific barcode, respond with: {\"unknown\":true}"
|
||||
]],
|
||||
]],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0,
|
||||
'maxOutputTokens' => 150,
|
||||
'responseMimeType' => 'application/json',
|
||||
],
|
||||
];
|
||||
|
||||
$result = callGeminiWithFallback($apiKey, $payload, 10);
|
||||
if (!$result) return null;
|
||||
|
||||
$text = '';
|
||||
foreach ($result['candidates'][0]['content']['parts'] ?? [] as $part) {
|
||||
$text .= ($part['text'] ?? '');
|
||||
}
|
||||
$text = trim($text);
|
||||
if (empty($text)) return null;
|
||||
|
||||
$data = json_decode($text, true);
|
||||
if (!$data || !empty($data['unknown']) || empty($data['name'])) return null;
|
||||
|
||||
return [
|
||||
'name' => $data['name'],
|
||||
'brand' => $data['brand'] ?? '',
|
||||
'category' => $data['category'] ?? '',
|
||||
'image_url' => '',
|
||||
'quantity_info' => '',
|
||||
'nutriscore' => '',
|
||||
'ingredients' => '',
|
||||
'allergens' => '',
|
||||
'conservation' => '',
|
||||
'origin' => '',
|
||||
'nova_group' => '',
|
||||
'ecoscore' => '',
|
||||
'labels' => '',
|
||||
'stores' => '',
|
||||
];
|
||||
}
|
||||
|
||||
function saveProduct(PDO $db): void {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input || empty($input['name'])) {
|
||||
|
||||
@@ -2567,6 +2567,17 @@ body.server-offline .bottom-nav {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.shopping-pantry-hint {
|
||||
font-size: 0.72rem;
|
||||
color: #15803d;
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
[data-theme="dark"] .shopping-pantry-hint {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.shopping-item-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+51
-1
@@ -3669,7 +3669,10 @@ function showPage(pageId, param = null) {
|
||||
}
|
||||
break;
|
||||
case 'products': loadAllProducts(); break;
|
||||
case 'shopping': loadShoppingList(); break;
|
||||
case 'shopping':
|
||||
_shoppingInventoryCache = null; // invalidate so hints use fresh data
|
||||
loadShoppingList();
|
||||
break;
|
||||
case 'recipe': loadRecipeArchive(); break;
|
||||
case 'log': loadLog(); break;
|
||||
case 'ai': initAICamera(); break;
|
||||
@@ -10155,6 +10158,20 @@ let shoppingItems = [];
|
||||
let suggestionItems = [];
|
||||
let _spesaScanTarget = null; // { name, rawName, idx } when tapping item to scan
|
||||
|
||||
// Inventory cache for "already at home" hints in the shopping list.
|
||||
// Loaded once per shopping page visit and reused for all item hints.
|
||||
let _shoppingInventoryCache = null;
|
||||
async function _getShoppingInventoryCache() {
|
||||
if (_shoppingInventoryCache !== null) return _shoppingInventoryCache;
|
||||
try {
|
||||
const data = await api('inventory_list');
|
||||
_shoppingInventoryCache = data.inventory || [];
|
||||
} catch(e) {
|
||||
_shoppingInventoryCache = [];
|
||||
}
|
||||
return _shoppingInventoryCache;
|
||||
}
|
||||
|
||||
// ===== SHOPPING TABS =====
|
||||
function switchShoppingTab(tab) {
|
||||
document.querySelectorAll('.shopping-tab').forEach(b => b.classList.remove('active'));
|
||||
@@ -11594,6 +11611,39 @@ async function renderShoppingItems() {
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// ── PANTRY HINTS: show "already at home: X" for each shopping item ──────
|
||||
// Load inventory once, then decorate all items asynchronously.
|
||||
_getShoppingInventoryCache().then(invItems => {
|
||||
for (const { item, idx } of enriched) {
|
||||
const firstTok = (_nameTokens(item.name)[0] || '').toLowerCase();
|
||||
if (!firstTok) continue;
|
||||
const matches = invItems.filter(i => {
|
||||
const iFirst = (_nameTokens(i.name || '')[0] || '').toLowerCase();
|
||||
return iFirst === firstTok && parseFloat(i.quantity) > 0;
|
||||
});
|
||||
if (matches.length === 0) continue;
|
||||
// Group by unit and sum
|
||||
const byUnit = {};
|
||||
for (const m of matches) {
|
||||
const u = m.unit || 'pz';
|
||||
byUnit[u] = (byUnit[u] || 0) + parseFloat(m.quantity);
|
||||
}
|
||||
const hintText = Object.entries(byUnit)
|
||||
.map(([u, q]) => `${Math.round(q * 10) / 10} ${u}`)
|
||||
.join(', ');
|
||||
const itemEl = document.getElementById(`shop-item-${idx}`);
|
||||
if (!itemEl) continue;
|
||||
const infoEl = itemEl.querySelector('.shopping-item-info');
|
||||
if (!infoEl) continue;
|
||||
// Don't duplicate
|
||||
if (infoEl.querySelector('.shopping-pantry-hint')) continue;
|
||||
const hintEl = document.createElement('div');
|
||||
hintEl.className = 'shopping-pantry-hint';
|
||||
hintEl.textContent = t('shopping.pantry_hint').replace('{qty}', hintText);
|
||||
infoEl.appendChild(hintEl);
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger async price loading if enabled
|
||||
const s2 = getSettings();
|
||||
if (s2.price_enabled && shoppingItems.length > 0) {
|
||||
|
||||
@@ -213,7 +213,8 @@
|
||||
"barcode_acquired": "🔖 Barcode gescannt: {code}",
|
||||
"scan_barcode": "🔖 Barcode scannen",
|
||||
"create_named": "{name} erstellen",
|
||||
"new_without_barcode": "Neues Produkt ohne Barcode"
|
||||
"new_without_barcode": "Neues Produkt ohne Barcode",
|
||||
"stock_in_pantry": "Bereits im Vorrat:"
|
||||
},
|
||||
"action": {
|
||||
"title": "Was möchtest du tun?",
|
||||
@@ -474,7 +475,8 @@
|
||||
"priority_medium": "Mittel",
|
||||
"priority_low": "Niedrig",
|
||||
"smart_last_update": "Aktualisiert {time}",
|
||||
"names_already_updated": "Alle Namen sind bereits aktuell"
|
||||
"names_already_updated": "Alle Namen sind bereits aktuell",
|
||||
"pantry_hint": "Bereits zuhause: {qty}"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 KI-Identifikation",
|
||||
|
||||
@@ -213,7 +213,8 @@
|
||||
"barcode_acquired": "🔖 Barcode scanned: {code}",
|
||||
"scan_barcode": "🔖 Scan Barcode",
|
||||
"create_named": "Create {name}",
|
||||
"new_without_barcode": "New product without barcode"
|
||||
"new_without_barcode": "New product without barcode",
|
||||
"stock_in_pantry": "Already in pantry:"
|
||||
},
|
||||
"action": {
|
||||
"title": "What do you want to do?",
|
||||
@@ -474,7 +475,8 @@
|
||||
"priority_medium": "Medium",
|
||||
"priority_low": "Low",
|
||||
"smart_last_update": "Updated {time}",
|
||||
"names_already_updated": "All names are already up to date"
|
||||
"names_already_updated": "All names are already up to date",
|
||||
"pantry_hint": "Already at home: {qty}"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 AI Identification",
|
||||
|
||||
@@ -213,7 +213,8 @@
|
||||
"barcode_acquired": "🔖 Barcode acquisito: {code}",
|
||||
"scan_barcode": "🔖 Scansiona Barcode",
|
||||
"create_named": "Crea {name}",
|
||||
"new_without_barcode": "Nuovo prodotto senza barcode"
|
||||
"new_without_barcode": "Nuovo prodotto senza barcode",
|
||||
"stock_in_pantry": "Hai gia in dispensa:"
|
||||
},
|
||||
"action": {
|
||||
"title": "Cosa vuoi fare?",
|
||||
@@ -474,7 +475,8 @@
|
||||
"priority_medium": "Media",
|
||||
"priority_low": "Bassa",
|
||||
"smart_last_update": "Aggiornato {time}",
|
||||
"names_already_updated": "Tutti i nomi sono già aggiornati"
|
||||
"names_already_updated": "Tutti i nomi sono già aggiornati",
|
||||
"pantry_hint": "Hai gia {qty} in dispensa"
|
||||
},
|
||||
"ai": {
|
||||
"title": "🤖 Identificazione AI",
|
||||
|
||||
Reference in New Issue
Block a user