chore: auto-merge develop → main

Triggered by: 6a41b53 feat: shopping list pantry hints, barcode multi-API fallback (OPF/beauty/Gemini), README disclaimer
This commit is contained in:
github-actions[bot]
2026-05-23 09:54:56 +00:00
6 changed files with 236 additions and 7 deletions
+162
View File
@@ -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'])) {
+11
View File
@@ -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
View File
@@ -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) {
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",