feat: BarcodeDetector nativo + camera selector + recipe dedup + remote debug logging

- Scanner: usa BarcodeDetector API nativa (Chrome Android) come primario, Quagga come fallback
- Settings: aggiunta tab Fotocamera per scegliere posteriore/anteriore/specifica
- Scanner feedback: barra verde (scansione attiva), gialla (barcode rilevato)
- Ricette: invio titoli ricette del giorno per evitare duplicati nello stesso giorno
- Debug: sistema di logging remoto (client_debug.log) per diagnostica da dispositivi chioscati
- Fix: permessi .env per scrittura da Apache
This commit is contained in:
dadaloop82
2026-03-12 17:32:54 +00:00
parent 3a7fce49a0
commit c5f22fdf42
7 changed files with 738 additions and 77 deletions
+153 -32
View File
@@ -121,6 +121,14 @@ try {
getServerSettings();
break;
case 'client_log':
clientLog();
break;
case 'get_client_log':
getClientLog();
break;
// ===== SPESA ONLINE =====
case 'dupliclick_login':
dupliclickLogin();
@@ -149,6 +157,48 @@ try {
echo json_encode(['error' => $e->getMessage()]);
}
// ===== CLIENT LOG =====
function clientLog(): void {
$input = json_decode(file_get_contents('php://input'), true);
$logFile = __DIR__ . '/../data/client_debug.log';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
// Identify device from UA
$device = 'unknown';
if (preg_match('/tablet|ipad|playbook|silk/i', $ua)) $device = 'tablet';
elseif (preg_match('/mobile|android|iphone/i', $ua)) $device = 'phone';
else $device = 'desktop';
$ts = date('Y-m-d H:i:s');
$msgs = $input['messages'] ?? [];
$lines = [];
foreach ($msgs as $m) {
$lines[] = "[$ts] [$device] $m";
}
if ($lines) {
// Keep log under 100KB — truncate oldest if needed
if (file_exists($logFile) && filesize($logFile) > 100000) {
$existing = file($logFile);
$existing = array_slice($existing, -200);
file_put_contents($logFile, implode('', $existing));
}
file_put_contents($logFile, implode("\n", $lines) . "\n", FILE_APPEND | LOCK_EX);
}
echo json_encode(['ok' => true]);
}
function getClientLog(): void {
$logFile = __DIR__ . '/../data/client_debug.log';
$lines = 100;
if (isset($_GET['lines'])) $lines = min(500, max(1, (int)$_GET['lines']));
if (!file_exists($logFile)) {
echo json_encode(['log' => '(empty)', 'lines' => 0]);
return;
}
$all = file($logFile);
$tail = array_slice($all, -$lines);
echo json_encode(['log' => implode('', $tail), 'lines' => count($tail), 'total' => count($all)]);
}
// ===== PRODUCT FUNCTIONS =====
function searchBarcode(PDO $db): void {
@@ -1055,6 +1105,7 @@ function generateRecipe(PDO $db): void {
$options = $input['options'] ?? [];
$appliances = $input['appliances'] ?? [];
$dietaryRestrictions = $input['dietary_restrictions'] ?? '';
$todayRecipes = $input['today_recipes'] ?? [];
// Fetch all inventory items with expiry info
$stmt = $db->query("
@@ -1146,9 +1197,16 @@ function generateRecipe(PDO $db): void {
$dietaryText = "\n\nRESTRIZIONI ALIMENTARI:\n{$dietaryRestrictions}\nRispetta SEMPRE queste restrizioni.";
}
// Today's previous recipes - avoid repetition
$todayText = '';
if (!empty($todayRecipes)) {
$todayList = implode(', ', array_map(function($t) { return '"' . $t . '"'; }, $todayRecipes));
$todayText = "\n\nRICETTE GIÀ PREPARATE OGGI:\n{$todayList}\nNON proporre una ricetta simile o con lo stesso concetto di quelle già fatte oggi. Varia il tipo di piatto, gli ingredienti principali e lo stile di cucina. Ad esempio se a pranzo c'era una piadina, a cena proponi pasta, riso, zuppa o altro — MAI un'altra piadina o wrap o piatto concettualmente simile.";
}
$prompt = <<<PROMPT
Sei un nutrizionista e chef italiano esperto. Genera UNA ricetta per $mealLabel per $persons persona/e usando PRINCIPALMENTE gli ingredienti disponibili nella dispensa dell'utente.
{$extraRulesText}{$appliancesText}{$dietaryText}
{$extraRulesText}{$appliancesText}{$dietaryText}{$todayText}
REGOLE IMPORTANTI:
1. PRIORITÀ ASSOLUTA: usa prima gli ingredienti in scadenza o già scaduti (se ancora utilizzabili)
@@ -2198,45 +2256,35 @@ function dupliclickSearch(): void {
'x-ebsn-account: ' . $token,
];
// Search catalog by item name only (spec confuses the search engine)
$url = 'https://www.dupliclick.it/ebsn/api/products?' . http_build_query([
'q' => $query,
'page' => 1,
'order_by' => 'search_score desc'
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => $baseHeaders,
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
if (curl_errno($ch)) {
echo json_encode(['error' => 'Errore connessione DupliClick: ' . curl_error($ch)]);
curl_close($ch);
return;
}
curl_close($ch);
$data = json_decode(trim($response), true);
if (!$data || ($data['response']['status'] ?? -1) !== 0) {
echo json_encode(['error' => 'Errore nella ricerca', 'details' => $data['response'] ?? null]);
// Search catalog by item name only first
$searchResults = dupliclickCatalogSearch($query, $baseHeaders);
if ($searchResults === null) {
echo json_encode(['error' => 'Errore nella ricerca']);
return;
}
$products = $data['data']['products'] ?? [];
$products = $searchResults['products'];
$total = $searchResults['total'];
if (empty($products)) {
// Fallback: try searching with spec keywords appended
$specKeywords = dupliclickExtractSpecKeywords($spec);
if ($specKeywords) {
$searchResults = dupliclickCatalogSearch($query . ' ' . $specKeywords, $baseHeaders);
if ($searchResults && !empty($searchResults['products'])) {
$products = $searchResults['products'];
$total = $searchResults['total'];
}
}
if (empty($products)) {
echo json_encode(['success' => true, 'query' => $query, 'product' => null, 'total' => 0]);
return;
}
}
// Format top 10 products
$topProducts = array_slice($products, 0, 10);
$formatted = array_map('formatDupliclickProduct', $topProducts);
$total = $data['data']['page']['totItems'] ?? 0;
// If multiple results, use AI to pick the best match
$bestProduct = $formatted[0];
@@ -2246,6 +2294,22 @@ function dupliclickSearch(): void {
if ($aiResult !== null) {
$bestProduct = $aiResult;
$aiUsed = true;
} elseif ($aiResult === null && !empty($spec)) {
// AI said no match — try refined search with spec keywords
$specKeywords = dupliclickExtractSpecKeywords($spec);
if ($specKeywords) {
$refined = dupliclickCatalogSearch($query . ' ' . $specKeywords, $baseHeaders);
if ($refined && !empty($refined['products'])) {
$refinedFormatted = array_map('formatDupliclickProduct', array_slice($refined['products'], 0, 10));
$aiResult2 = aiSelectBestProduct($query, $spec, $refinedFormatted, $aiPrompt);
if ($aiResult2 !== null) {
$bestProduct = $aiResult2;
$aiUsed = true;
} else {
$bestProduct = $refinedFormatted[0];
}
}
}
}
}
@@ -2258,6 +2322,59 @@ function dupliclickSearch(): void {
]);
}
/**
* Search DupliClick catalog and return raw products array
*/
function dupliclickCatalogSearch(string $query, array $headers): ?array {
$url = 'https://www.dupliclick.it/ebsn/api/products?' . http_build_query([
'q' => $query,
'page' => 1,
'order_by' => 'search_score desc'
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
if (curl_errno($ch)) { curl_close($ch); return null; }
curl_close($ch);
$data = json_decode(trim($response), true);
if (!$data || ($data['response']['status'] ?? -1) !== 0) return null;
return [
'products' => $data['data']['products'] ?? [],
'total' => $data['data']['page']['totItems'] ?? 0,
];
}
/**
* Extract meaningful product keywords from a Bring specification string,
* stripping quantities, emojis, and noise words.
*/
function dupliclickExtractSpecKeywords(string $spec): string {
if (empty($spec)) return '';
// Remove priority emojis
$clean = preg_replace('/[\x{1F534}\x{1F7E1}\x{1F7E2}]/u', '', $spec);
// Remove quantities (150g, 500ml, 2x, 1 flacone, etc.)
$clean = preg_replace('/\d+\s*(g|kg|ml|l|pz|pezzi|conf|flacon[ei]|x)\b/i', '', $clean);
$clean = preg_replace('/\d+x\d*/i', '', $clean);
// Remove standalone numbers
$clean = preg_replace('/\b\d+\b/', '', $clean);
// Remove noise words
$noise = ['senza', 'con', 'più', 'meno', 'circa', 'tipo', 'lidl', 'coop', 'conad', 'esselunga'];
$clean = preg_replace('/\b(' . implode('|', $noise) . ')\b/i', '', $clean);
// Remove commas and extra spaces
$clean = preg_replace('/[,+]/', ' ', $clean);
$clean = preg_replace('/\s+/', ' ', trim($clean));
return $clean;
}
/**
* Use Gemini AI to pick the best product from search results
*/
@@ -2266,17 +2383,21 @@ function aiSelectBestProduct(string $itemName, string $spec, array $products, st
$apiKey = $env['GEMINI_API_KEY'] ?? '';
if (empty($apiKey)) return null;
$defaultPrompt = "Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare e una lista di prodotti trovati nel catalogo del supermercato.
$defaultPrompt = "Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare (con eventuale descrizione tra parentesi) e una lista di prodotti trovati nel catalogo del supermercato.
Regole di selezione:
- Scegli il prodotto che corrisponde ESATTAMENTE a quello richiesto (stessa categoria merceologica)
- La DESCRIZIONE tra parentesi è FONDAMENTALE: se l'utente cerca \"Pancetta (a cubetti)\", DEVI trovare pancetta A CUBETTI, non pancetta generica
- Se la descrizione include un tipo specifico (\"a cubetti\", \"a fette\", \"biologico\", \"cotto\", \"a pasta dura\"), il prodotto DEVE contenere quella caratteristica nel nome
- Preferisci prodotti freschi/sfusi rispetto a trasformati (es. \"Arance\" = arance frutta, NON aranciata bevanda)
- Se c'è una descrizione (es. \"a cubetti\", \"biologico\"), trova il prodotto che include quella caratteristica
- Se ci sono più varianti valide, scegli quella con il miglior rapporto qualità/prezzo
- Preferisci formati standard per una famiglia
- NON scegliere mai un prodotto di categoria diversa (bevanda vs frutta, surgelato vs fresco, condimento vs ortaggio, ecc.)
- NON scegliere mai un prodotto di categoria diversa (bevanda vs frutta, surgelato vs fresco, condimento vs ortaggio, pasta ripiena vs formaggio, ecc.)
- \"Finocchio\" = ortaggio fresco, NON semi di finocchio o tisana
- \"Arance\" = frutta fresca, NON aranciata o succo
- \"Formaggio\" = formaggio intero/pezzo, NON prodotti che contengono formaggio come ingrediente (ravioli, sfogliavelo, ecc.)
- \"Detergente intimo\" = detergente per igiene intima, NON detersivo generico
- Rispondi -1 se NESSUN prodotto corrisponde ragionevolmente alla richiesta
Rispondi SOLO con il numero (indice 0-based) del prodotto migliore, oppure -1 se nessun prodotto è appropriato.";
+26
View File
@@ -863,6 +863,22 @@ body {
background: var(--danger);
box-shadow: 0 0 10px var(--danger);
animation: scanLine 2s ease-in-out infinite;
transition: background 0.2s, box-shadow 0.2s, height 0.2s;
}
/* While Quagga is actively scanning frames */
.scanner-line.scanning {
background: #00c853;
box-shadow: 0 0 12px #00c853;
animation: scanLineActive 0.8s ease-in-out infinite;
}
/* Barcode partially detected — strong pulse */
.scanner-line.detecting {
background: #ffd600;
box-shadow: 0 0 20px #ffd600, 0 0 40px rgba(255,214,0,0.4);
height: 5px;
animation: scanLineDetect 0.3s ease-in-out infinite;
}
@keyframes scanLine {
@@ -870,6 +886,16 @@ body {
50% { opacity: 0.3; }
}
@keyframes scanLineActive {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes scanLineDetect {
0%, 100% { opacity: 1; transform: scaleY(1); }
50% { opacity: 0.7; transform: scaleY(2); }
}
.scan-result {
background: var(--bg-card);
border-radius: var(--radius);
+335 -40
View File
@@ -306,6 +306,43 @@ let scannerStream = null;
let quaggaRunning = false;
let aiStream = null;
// ===== CAMERA HELPER =====
function getCameraConstraints(extraVideo = {}) {
const s = getSettings();
const mode = s.camera_facing || 'environment';
// Front cameras on older devices often have lower resolution — don't over-request
const isFront = (mode === 'user');
const videoConstraints = {
width: { ideal: isFront ? 640 : 1280 },
height: { ideal: isFront ? 480 : 720 },
...extraVideo
};
if (mode === 'environment' || mode === 'user') {
videoConstraints.facingMode = mode;
} else {
// Specific deviceId selected
videoConstraints.deviceId = { exact: mode };
}
return { video: videoConstraints };
}
function isFrontCamera() {
const s = getSettings();
return (s.camera_facing || 'environment') === 'user';
}
async function enumerateCameras() {
try {
// Need a temporary stream to get device labels
const tempStream = await navigator.mediaDevices.getUserMedia({ video: true });
const devices = await navigator.mediaDevices.enumerateDevices();
tempStream.getTracks().forEach(t => t.stop());
return devices.filter(d => d.kind === 'videoinput');
} catch(e) {
return [];
}
}
// ===== SETTINGS / CONFIG =====
function getSettings() {
try {
@@ -340,6 +377,10 @@ async function loadSettingsUI() {
document.getElementById('setting-pref-comfort').checked = !!s.pref_comfort;
document.getElementById('setting-pref-zerowaste').checked = !!s.pref_zerowaste;
document.getElementById('setting-dietary').value = s.dietary || '';
// Camera
const cameraSelect = document.getElementById('setting-camera-facing');
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
loadCameraDevices();
renderAppliances(s.appliances || []);
loadSpesaSettings();
@@ -369,6 +410,23 @@ function renderAppliances(appliances) {
`).join('');
}
async function loadCameraDevices() {
const select = document.getElementById('setting-camera-facing');
if (!select) return;
const s = getSettings();
const current = s.camera_facing || 'environment';
// Remove old device-specific options (keep first 2: environment, user)
while (select.options.length > 2) select.remove(2);
const cameras = await enumerateCameras();
cameras.forEach(cam => {
const opt = document.createElement('option');
opt.value = cam.deviceId;
opt.textContent = cam.label || `Camera ${cam.deviceId.slice(0, 8)}`;
select.appendChild(opt);
});
select.value = current;
}
function addAppliance() {
const input = document.getElementById('new-appliance-input');
const name = (input.value || '').trim();
@@ -420,6 +478,8 @@ async function saveSettings() {
s.pref_comfort = document.getElementById('setting-pref-comfort').checked;
s.pref_zerowaste = document.getElementById('setting-pref-zerowaste').checked;
s.dietary = document.getElementById('setting-dietary').value.trim();
// Camera
s.camera_facing = document.getElementById('setting-camera-facing').value;
// Save spesa AI prompt if the field exists
const spesaPromptEl = document.getElementById('setting-spesa-ai-prompt');
if (spesaPromptEl) s.spesa_ai_prompt = spesaPromptEl.value.trim();
@@ -1162,31 +1222,80 @@ async function submitEditInventory(e, id, productId) {
refreshCurrentPage();
}
// ===== SCAN DEBUG LOG =====
let _scanDebugVisible = false;
let _scanLogBuffer = [];
let _scanLogTimer = null;
function scanLog(msg) {
const el = document.getElementById('scan-debug-log');
if (el) {
const ts = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:1});
el.textContent += `[${ts}] ${msg}\n`;
el.scrollTop = el.scrollHeight;
}
console.log('[ScanDebug]', msg);
// Buffer for remote send
_scanLogBuffer.push(msg);
if (!_scanLogTimer) {
_scanLogTimer = setTimeout(flushScanLog, 2000);
}
}
function flushScanLog() {
_scanLogTimer = null;
if (_scanLogBuffer.length === 0) return;
const msgs = _scanLogBuffer.splice(0);
fetch(`${API_BASE}?action=client_log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: msgs })
}).catch(() => {});
}
function toggleScanDebug() {
const el = document.getElementById('scan-debug-log');
if (!el) return;
_scanDebugVisible = !_scanDebugVisible;
el.style.display = _scanDebugVisible ? 'block' : 'none';
}
// ===== BARCODE SCANNER =====
let _useBarcodeDetector = ('BarcodeDetector' in window);
async function initScanner() {
const video = document.getElementById('scanner-video');
const viewport = document.getElementById('scanner-viewport');
const logEl = document.getElementById('scan-debug-log');
if (logEl) logEl.textContent = '';
const constraints = getCameraConstraints();
scanLog(`Camera mode: ${getSettings().camera_facing || 'environment'}`);
scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (Quagga fallback)'}`);
scanLog(`Constraints: ${JSON.stringify(constraints.video)}`);
try {
// Stop any existing stream
stopScanner();
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const track = stream.getVideoTracks()[0];
const caps = track.getSettings ? track.getSettings() : {};
scanLog(`Stream OK — track: ${track.label}`);
scanLog(`Resolution: ${caps.width||'?'}x${caps.height||'?'}, facing: ${caps.facingMode||'N/A'}`);
scannerStream = stream;
video.srcObject = stream;
await video.play();
scanLog(`Video playing — videoWidth: ${video.videoWidth}, videoHeight: ${video.videoHeight}`);
// Start Quagga for barcode detection
startQuagga(video);
if (_useBarcodeDetector) {
startNativeScanner(video);
} else {
startQuaggaScanner(video);
}
} catch (err) {
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
console.error('Camera error:', err);
document.getElementById('scan-result').style.display = 'block';
document.getElementById('scan-result').innerHTML = `
@@ -1199,29 +1308,165 @@ async function initScanner() {
}
}
function startQuagga(videoEl) {
// ===== NATIVE BarcodeDetector SCANNER =====
async function startNativeScanner(videoEl) {
if (quaggaRunning) return;
const scannerLine = document.querySelector('.scanner-line');
const detector = new BarcodeDetector({
formats: ['ean_13', 'ean_8', 'code_128', 'code_39', 'upc_a', 'upc_e']
});
let scanning = true;
quaggaRunning = true;
let frameCount = 0;
let partialCount = 0;
let lastDetected = '';
let detectCount = 0;
let detectionHistory = {};
scanLog('Native BarcodeDetector started');
function updateFeedback(state) {
if (!scannerLine) return;
scannerLine.classList.remove('scanning', 'detecting');
if (state) scannerLine.classList.add(state);
}
async function scanFrame() {
if (!scanning || !scannerStream) return;
frameCount++;
if (frameCount === 1) updateFeedback('scanning');
try {
const barcodes = await detector.detect(videoEl);
if (barcodes.length > 0) {
const code = barcodes[0].rawValue;
const format = barcodes[0].format;
partialCount++;
scanLog(`Native detect #${partialCount} [f${frameCount}]: ${code} (${format})`);
updateFeedback('detecting');
if (!detectionHistory[code]) detectionHistory[code] = { count: 0 };
detectionHistory[code].count++;
if (code === lastDetected) {
detectCount++;
} else {
lastDetected = code;
detectCount = 1;
}
if (detectCount >= 2 || detectionHistory[code].count >= 2) {
scanning = false;
quaggaRunning = false;
updateFeedback(null);
scanLog(`CONFIRMED: ${code} after ${frameCount} frames`);
onBarcodeDetected(code);
return;
}
} else {
updateFeedback('scanning');
}
} catch (e) {
scanLog(`Native detect error: ${e.message}`);
}
if (scanning) {
if (frameCount % 30 === 0) {
scanLog(`Native scanning... f${frameCount}, partials: ${partialCount}`);
}
requestAnimationFrame(scanFrame);
}
}
requestAnimationFrame(scanFrame);
}
// ===== QUAGGA FALLBACK SCANNER =====
function startQuaggaScanner(videoEl) {
if (quaggaRunning) return;
const canvas = document.getElementById('scanner-canvas');
const ctx = canvas.getContext('2d');
const frontCam = isFrontCamera();
const scannerLine = document.querySelector('.scanner-line');
let frameCount = 0;
let partialCount = 0;
scanLog(`Quagga starting — frontCam: ${frontCam}`);
let scanning = true;
quaggaRunning = true;
let lastDetected = '';
let detectCount = 0;
let detectionHistory = {};
// Alternate between full frame and center-cropped for better detection
let scanPass = 0; // 0=full, 1=center-crop, 2=full-enhanced, 3=center-enhanced
function updateScannerFeedback(state) {
if (!scannerLine) return;
scannerLine.classList.remove('scanning', 'detecting');
if (state) scannerLine.classList.add(state);
}
function getFrameDataUrl(pass) {
const vw = videoEl.videoWidth;
const vh = videoEl.videoHeight;
if (pass % 2 === 0) {
// Full frame
canvas.width = vw;
canvas.height = vh;
ctx.drawImage(videoEl, 0, 0);
} else {
// Center crop: 60% of frame, focused on barcode area
const cropW = Math.round(vw * 0.7);
const cropH = Math.round(vh * 0.4);
const sx = Math.round((vw - cropW) / 2);
const sy = Math.round((vh - cropH) / 2);
canvas.width = cropW;
canvas.height = cropH;
ctx.drawImage(videoEl, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
}
// Apply enhancement on passes 2,3 or always for front cam
if (frontCam || pass >= 2) {
enhanceCanvasForBarcode(ctx, canvas.width, canvas.height);
}
return canvas.toDataURL('image/jpeg', 0.95);
}
function scanFrame() {
if (!scanning || !scannerStream) return;
frameCount++;
scanPass = (scanPass + 1) % 4;
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0);
const dataUrl = getFrameDataUrl(scanPass);
if (frameCount === 1) {
scanLog(`Frame #1 — video: ${videoEl.videoWidth}x${videoEl.videoHeight}`);
updateScannerFeedback('scanning');
}
let callbackCalled = false;
const safetyTimer = setTimeout(() => {
if (!callbackCalled && scanning) {
scanLog(`Quagga timeout on f${frameCount}, retrying...`);
setTimeout(scanFrame, 100);
}
}, 5000);
try {
const imgSize = Math.max(canvas.width, canvas.height);
Quagga.decodeSingle({
src: canvas.toDataURL('image/jpeg', 0.8),
src: dataUrl,
numOfWorkers: 0,
inputStream: { size: 800 },
inputStream: { size: Math.min(imgSize, 800) },
decoder: {
readers: [
'ean_reader',
@@ -1230,39 +1475,81 @@ function startQuagga(videoEl) {
'code_39_reader',
'upc_reader',
'upc_e_reader'
]
],
multiple: false
},
locate: true
locate: true,
locator: { patchSize: 'large', halfSample: false }
}, function(result) {
callbackCalled = true;
clearTimeout(safetyTimer);
if (result && result.codeResult) {
const code = result.codeResult.code;
const format = result.codeResult.format;
partialCount++;
const passName = ['full','crop','full+enh','crop+enh'][scanPass];
scanLog(`Partial #${partialCount} [f${frameCount} ${passName}]: ${code} (${format})`);
updateScannerFeedback('detecting');
if (!detectionHistory[code]) detectionHistory[code] = { count: 0, lastFrame: 0 };
detectionHistory[code].count++;
detectionHistory[code].lastFrame = frameCount;
if (code === lastDetected) {
detectCount++;
} else {
lastDetected = code;
detectCount = 1;
}
// Require 2 consecutive reads for reliability
if (detectCount >= 2) {
const dominated = detectionHistory[code];
if (detectCount >= 2 || dominated.count >= 2) {
scanning = false;
quaggaRunning = false;
updateScannerFeedback(null);
scanLog(`CONFIRMED: ${code} after ${frameCount} frames (consec:${detectCount}, total:${dominated.count})`);
onBarcodeDetected(code);
return;
}
} else {
updateScannerFeedback('scanning');
}
if (scanning) {
setTimeout(scanFrame, 300);
if (frameCount % 20 === 0) {
scanLog(`Scanning... f${frameCount}, partials: ${partialCount}, pass: ${scanPass}`);
}
setTimeout(scanFrame, 150);
}
});
} catch (e) {
callbackCalled = true;
clearTimeout(safetyTimer);
scanLog(`Quagga error: ${e.message}`);
if (scanning) setTimeout(scanFrame, 500);
}
}
// Start scanning after a small delay
setTimeout(scanFrame, 500);
}
// Enhance low-quality camera frames for better barcode recognition
function enhanceCanvasForBarcode(ctx, w, h) {
const imageData = ctx.getImageData(0, 0, w, h);
const d = imageData.data;
// Convert to high-contrast grayscale
for (let i = 0; i < d.length; i += 4) {
// Luminance
let gray = 0.299 * d[i] + 0.587 * d[i+1] + 0.114 * d[i+2];
// Increase contrast
gray = ((gray - 128) * 1.5) + 128;
gray = gray < 0 ? 0 : gray > 255 ? 255 : gray;
// Threshold to make bars more distinct
gray = gray < 140 ? 0 : 255;
d[i] = d[i+1] = d[i+2] = gray;
}
ctx.putImageData(imageData, 0, 0);
}
function stopScanner() {
quaggaRunning = false;
if (scannerStream) {
@@ -2560,9 +2847,7 @@ async function initAICamera() {
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
}
aiStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
aiStream = await navigator.mediaDevices.getUserMedia(getCameraConstraints());
video.srcObject = aiStream;
await video.play();
} catch (err) {
@@ -3156,12 +3441,9 @@ async function searchItemPrice(idx, force = false) {
renderShoppingItems();
try {
// Include specification in the search query for better catalog results
let searchQ = item.name;
// Send item name as query, spec separately for AI selection
const searchQ = item.name;
const spec = item.specification || '';
// Strip priority emojis from spec before appending
const cleanSpec = spec.replace(/[🔴🟡🟢]/g, '').trim();
if (cleanSpec) searchQ += ' ' + cleanSpec;
const s2 = getSettings();
const aiPrompt = s2.spesa_ai_prompt || '';
@@ -3451,9 +3733,7 @@ async function scanExpiryWithAI() {
// Start camera
try {
expiryStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
expiryStream = await navigator.mediaDevices.getUserMedia(getCameraConstraints());
const video = document.getElementById('expiry-video');
video.srcObject = expiryStream;
await video.play();
@@ -3515,9 +3795,7 @@ function retakeExpiry() {
document.getElementById('expiry-scan-status').style.display = 'none';
// Restart camera
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
}).then(stream => {
navigator.mediaDevices.getUserMedia(getCameraConstraints()).then(stream => {
expiryStream = stream;
const video = document.getElementById('expiry-video');
video.srcObject = stream;
@@ -3711,10 +3989,21 @@ function saveRecipeToArchive(recipe) {
localStorage.setItem('dispensa_recipe_archive', JSON.stringify(archive));
}
function getTodayRecipeTitles() {
const archive = getRecipeArchive();
const today = new Date().toISOString().slice(0, 10);
return archive
.filter(e => e.date === today && e.recipe && e.recipe.title)
.map(e => e.recipe.title);
}
let _recipeArchiveEntries = [];
function loadRecipeArchive() {
const container = document.getElementById('recipe-archive');
if (!container) return;
const archive = getRecipeArchive();
_recipeArchiveEntries = archive;
if (archive.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:20px"><div class="empty-state-icon">🍳</div><p>Nessuna ricetta salvata.<br>Genera la tua prima ricetta!</p></div>';
@@ -3729,6 +4018,7 @@ function loadRecipeArchive() {
}
let html = '';
let flatIdx = 0;
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
@@ -3744,7 +4034,9 @@ function loadRecipeArchive() {
const r = entry.recipe;
const mealIcon = MEAL_LABELS[r.meal] || r.meal;
const tags = (r.tags || []).slice(0, 3).join(', ');
html += `<div class="recipe-archive-card" onclick='viewArchivedRecipe(${JSON.stringify(JSON.stringify(entry))})'>`;
// Find this entry's index in the flat archive array
const archiveIdx = archive.indexOf(entry);
html += `<div class="recipe-archive-card" onclick="viewArchivedRecipe(${archiveIdx})">`;
html += `<div class="recipe-archive-card-header">`;
html += `<span class="recipe-archive-meal">${mealIcon}</span>`;
html += `<span class="recipe-archive-title">${escapeHtml(r.title)}</span>`;
@@ -3755,6 +4047,7 @@ function loadRecipeArchive() {
html += `<span>👥 ${r.persons}</span>`;
if (tags) html += `<span>${tags}</span>`;
html += `</div></div>`;
flatIdx++;
}
html += `</div>`;
}
@@ -3762,8 +4055,9 @@ function loadRecipeArchive() {
container.innerHTML = html;
}
function viewArchivedRecipe(entryJson) {
const entry = JSON.parse(entryJson);
function viewArchivedRecipe(idx) {
const entry = _recipeArchiveEntries[idx];
if (!entry) return;
renderRecipe(entry.recipe);
document.getElementById('recipe-overlay').style.display = 'flex';
document.getElementById('recipe-ask').style.display = 'none';
@@ -3985,7 +4279,8 @@ async function generateRecipe() {
persons,
options,
appliances: settings.appliances || [],
dietary_restrictions: settings.dietary_restrictions || ''
dietary_restrictions: settings.dietary_restrictions || '',
today_recipes: getTodayRecipeTitles()
});
if (!result.success) {
+82
View File
@@ -0,0 +1,82 @@
[2026-03-12 17:30:05] [desktop] Camera mode: environment
[2026-03-12 17:30:05] [desktop] BarcodeDetector: NO (Quagga fallback)
[2026-03-12 17:30:05] [desktop] Constraints: {"width":{"ideal":1280},"height":{"ideal":720},"facingMode":"environment"}
[2026-03-12 17:30:05] [desktop] Stream OK — track: HP TrueVision HD Camera (30c9:002c)
[2026-03-12 17:30:05] [desktop] Resolution: 1280x720, facing: user
[2026-03-12 17:30:05] [desktop] Video playing — videoWidth: 1280, videoHeight: 720
[2026-03-12 17:30:05] [desktop] Quagga starting — frontCam: false
[2026-03-12 17:30:05] [desktop] Frame #1 — video: 1280x720
[2026-03-12 17:30:11] [desktop] Scanning... f20, partials: 0, pass: 0
[2026-03-12 17:30:15] [desktop] Scanning... f40, partials: 0, pass: 0
[2026-03-12 17:30:20] [desktop] Scanning... f60, partials: 0, pass: 0
[2026-03-12 17:30:21] [phone] Camera mode: user
[2026-03-12 17:30:21] [phone] BarcodeDetector: YES (native)
[2026-03-12 17:30:21] [phone] Constraints: {"width":{"ideal":640},"height":{"ideal":480},"facingMode":"user"}
[2026-03-12 17:30:21] [phone] Stream OK — track: camera 1, facing front
[2026-03-12 17:30:21] [phone] Resolution: 480x640, facing: user
[2026-03-12 17:30:21] [phone] Video playing — videoWidth: 480, videoHeight: 640
[2026-03-12 17:30:21] [phone] Native BarcodeDetector started
[2026-03-12 17:30:23] [phone] Native detect #1 [f3]: 4003608024917 (ean_13)
[2026-03-12 17:30:23] [phone] Native detect #2 [f6]: 8003000024917 (ean_13)
[2026-03-12 17:30:23] [phone] Native detect #3 [f16]: 8003000024917 (ean_13)
[2026-03-12 17:30:23] [phone] CONFIRMED: 8003000024917 after 16 frames
[2026-03-12 17:30:25] [desktop] Scanning... f80, partials: 0, pass: 0
[2026-03-12 17:30:30] [desktop] Scanning... f100, partials: 0, pass: 0
[2026-03-12 17:30:35] [desktop] Scanning... f120, partials: 0, pass: 0
[2026-03-12 17:30:40] [desktop] Scanning... f140, partials: 0, pass: 0
[2026-03-12 17:30:44] [desktop] Scanning... f160, partials: 0, pass: 0
[2026-03-12 17:30:49] [desktop] Scanning... f180, partials: 0, pass: 0
[2026-03-12 17:30:54] [desktop] Scanning... f200, partials: 0, pass: 0
[2026-03-12 17:30:59] [desktop] Scanning... f220, partials: 0, pass: 0
[2026-03-12 17:31:04] [desktop] Scanning... f240, partials: 0, pass: 0
[2026-03-12 17:31:07] [desktop] Camera mode: user
[2026-03-12 17:31:07] [desktop] BarcodeDetector: YES (native)
[2026-03-12 17:31:07] [desktop] Constraints: {"width":{"ideal":640},"height":{"ideal":480},"facingMode":"user"}
[2026-03-12 17:31:07] [desktop] Stream OK — track: camera 1, facing front
[2026-03-12 17:31:07] [desktop] Resolution: 480x640, facing: user
[2026-03-12 17:31:07] [desktop] Video playing — videoWidth: 480, videoHeight: 640
[2026-03-12 17:31:07] [desktop] Native BarcodeDetector started
[2026-03-12 17:31:09] [desktop] Scanning... f260, partials: 0, pass: 0
[2026-03-12 17:31:14] [desktop] Native scanning... f30, partials: 0
[2026-03-12 17:31:14] [desktop] Native detect #1 [f31]: 8003000024917 (ean_13)
[2026-03-12 17:31:14] [desktop] Native detect #2 [f33]: 8003000024917 (ean_13)
[2026-03-12 17:31:14] [desktop] CONFIRMED: 8003000024917 after 33 frames
[2026-03-12 17:31:14] [desktop] Scanning... f280, partials: 0, pass: 0
[2026-03-12 17:31:19] [desktop] Scanning... f300, partials: 0, pass: 0
[2026-03-12 17:31:22] [desktop] Camera mode: user
[2026-03-12 17:31:22] [desktop] BarcodeDetector: YES (native)
[2026-03-12 17:31:22] [desktop] Constraints: {"width":{"ideal":640},"height":{"ideal":480},"facingMode":"user"}
[2026-03-12 17:31:22] [desktop] Stream OK — track: camera 1, facing front
[2026-03-12 17:31:22] [desktop] Resolution: 480x640, facing: user
[2026-03-12 17:31:22] [desktop] Video playing — videoWidth: 480, videoHeight: 640
[2026-03-12 17:31:22] [desktop] Native BarcodeDetector started
[2026-03-12 17:31:24] [desktop] Scanning... f320, partials: 0, pass: 0
[2026-03-12 17:31:26] [desktop] Native scanning... f30, partials: 0
[2026-03-12 17:31:28] [desktop] Scanning... f340, partials: 0, pass: 0
[2026-03-12 17:31:30] [desktop] Native detect #1 [f49]: 016418230816 (upc_a)
[2026-03-12 17:31:30] [desktop] Native detect #2 [f54]: 003418230816 (upc_a)
[2026-03-12 17:31:33] [desktop] Scanning... f360, partials: 0, pass: 0
[2026-03-12 17:31:33] [desktop] Native scanning... f60, partials: 2
[2026-03-12 17:31:33] [desktop] Native detect #3 [f63]: 9019489230816 (ean_13)
[2026-03-12 17:31:38] [desktop] Scanning... f380, partials: 0, pass: 0
[2026-03-12 17:31:42] [desktop] Native scanning... f90, partials: 3
[2026-03-12 17:31:43] [desktop] Scanning... f400, partials: 0, pass: 0
[2026-03-12 17:31:48] [desktop] Scanning... f420, partials: 0, pass: 0
[2026-03-12 17:31:49] [desktop] Native scanning... f120, partials: 3
[2026-03-12 17:31:53] [desktop] Scanning... f440, partials: 0, pass: 0
[2026-03-12 17:31:58] [desktop] Native scanning... f150, partials: 3
[2026-03-12 17:31:58] [desktop] Scanning... f460, partials: 0, pass: 0
[2026-03-12 17:32:03] [desktop] Native scanning... f180, partials: 3
[2026-03-12 17:32:03] [desktop] Native detect #4 [f188]: 8000300337396 (ean_13)
[2026-03-12 17:32:03] [desktop] Native detect #5 [f189]: 8000300337396 (ean_13)
[2026-03-12 17:32:03] [desktop] CONFIRMED: 8000300337396 after 189 frames
[2026-03-12 17:32:03] [desktop] Scanning... f480, partials: 0, pass: 0
[2026-03-12 17:32:08] [desktop] Scanning... f500, partials: 0, pass: 0
[2026-03-12 17:32:11] [desktop] Camera mode: user
[2026-03-12 17:32:11] [desktop] BarcodeDetector: YES (native)
[2026-03-12 17:32:11] [desktop] Constraints: {"width":{"ideal":640},"height":{"ideal":480},"facingMode":"user"}
[2026-03-12 17:32:11] [desktop] Stream OK — track: camera 1, facing front
[2026-03-12 17:32:11] [desktop] Resolution: 480x640, facing: user
[2026-03-12 17:32:11] [desktop] Video playing — videoWidth: 480, videoHeight: 640
[2026-03-12 17:32:11] [desktop] Native BarcodeDetector started
[2026-03-12 17:32:13] [desktop] Scanning... f520, partials: 0, pass: 0
BIN
View File
Binary file not shown.
+118
View File
@@ -0,0 +1,118 @@
{
"token": "eyJraWQiOiJzdGQiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJkYWRhbG9vcDgyQGdtYWlsLmNvbSIsImV4cCI6MTc3NDU5NjkzMywianRpIjoiMjM0NzIxNDUzVkJLR0hBSFlDUUxITjdIWFVNREJWIn0.97ZRFe3i0-0sCoVe_lG5Li2YVXkEyyPTfTr6tck984HKQryUf_12BidPe23aL_2eUeUiuMbd8HJfkyumlfOvmA",
"email": "dadaloop82@gmail.com",
"logged_at": "2026-03-12T06:35:33+00:00",
"user": {
"userId": 234721,
"firstName": "Daniel",
"lastName": "Stimpfl",
"fidelityCard": "0401011000247",
"email": "dadaloop82@gmail.com",
"login": "dadaloop82@gmail.com",
"phone": "+393490726254",
"userType": {
"userTypeId": "1"
},
"companyId": 0,
"hasFavorites": true,
"defaultStoreAddress": {
"addressId": null,
"addressName": null
},
"codInt": "2167695",
"profile": {
"level": 2,
"confirmed": true
},
"hrAgent": {
"hrId": null,
"name": "",
"internalCode": ""
},
"giftCertificatesReceived": [],
"legals": [
{
"legalId": 1
}
],
"userPointsCounters": [
{
"codInt": "0",
"name": "Fidelity",
"value": 0,
"pointsUsed": 0
}
],
"userPoints": [
{
"codInt": "0",
"name": "Fidelity",
"value": 0,
"pointsUsed": 0
}
],
"person": {
"personId": "8116672",
"firstName": "Daniel",
"lastName": "Stimpfl",
"gender": "",
"birthDate": "1982-06-19",
"birthPlace": "",
"company": "",
"fiscalCode": "",
"vatCode": "",
"vatFiscalCode": "",
"codInt": "",
"active": "1",
"industryId": null,
"industryName": null,
"emailCertified": "",
"vatSdiCode": "",
"personTypeId": null,
"personInfos": []
},
"billingAddress": {
"addressId": 605113,
"addressTypeId": 1,
"addressType": "billing",
"zoneId": -1,
"active": 1,
"deliveryAddressId": 605113,
"shippingAddressId": 605113,
"addressName": "residenza",
"address1": "VIA PETER ROSEGGER",
"address2": "",
"addressNumber": "20\/C",
"city": "LAIVES",
"postalcode": "",
"floor": "",
"doorbellName": "",
"province": "BZ",
"apartmentNumber": "",
"maxDistance": 0.4,
"country": {
"countryId": 1,
"name": "Italia",
"iso": "IT",
"codInt": ""
},
"doorbellNumber": "",
"referencePhoneNumber": "",
"referenceMobileNumber": "",
"referenceEMail": "",
"addressNote": "",
"ztl": null,
"elevator": null,
"countryId": "1"
},
"contact": {
"contactId": "56730212",
"email1": "dadaloop82@gmail.com",
"email2": "",
"homePhone": "",
"workPhone": ""
},
"crmUserSegments": []
},
"cart_id": 1476245
}
+21 -2
View File
@@ -9,7 +9,7 @@
<title>Dispensa Manager</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>">
<link rel="stylesheet" href="assets/css/style.css?v=20260312h">
<link rel="stylesheet" href="assets/css/style.css?v=20260312t">
<!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
</head>
@@ -134,6 +134,8 @@
</button>
</div>
<p class="scan-hint">Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo</p>
<div id="scan-debug-log" style="display:none;margin-top:12px;padding:10px;background:#1a1a2e;color:#0f0;font-family:monospace;font-size:0.7rem;max-height:200px;overflow-y:auto;border-radius:8px;white-space:pre-wrap"></div>
<button class="btn btn-small btn-secondary" style="margin-top:8px;opacity:0.5" onclick="toggleScanDebug()">🐛 Debug Log</button>
</div>
</section>
@@ -558,6 +560,7 @@
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-spesa')" data-tab="tab-spesa" title="Spesa Online">🛍️</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
</div>
<div class="settings-panels">
@@ -686,6 +689,22 @@
</div>
</div>
</div>
<!-- Camera Tab -->
<div class="settings-panel" id="tab-camera">
<div class="settings-card">
<h4>📷 Fotocamera</h4>
<p class="settings-hint">Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.</p>
<div class="form-group">
<label>📸 Fotocamera predefinita</label>
<select id="setting-camera-facing" class="form-input">
<option value="environment">📱 Posteriore (default)</option>
<option value="user">🤳 Anteriore</option>
</select>
<p class="settings-hint mt-2">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()">🔄 Rileva fotocamere</button>
</div>
</div>
</div>
<!-- Security Tab -->
<div class="settings-panel" id="tab-security">
<div class="settings-card">
@@ -839,6 +858,6 @@
<div class="modal-content" id="modal-content" onclick="event.stopPropagation()"></div>
</div>
<script src="assets/js/app.js?v=20260312n"></script>
<script src="assets/js/app.js?v=20260312w"></script>
</body>
</html>