feat: add barcode scanner with OpenFoodFacts integration
All checks were successful
CI / update (push) Successful in 5m26s

- Camera-based barcode scanning in FoodSearch using barcode-detector (ZXing WASM)
- Import script to load OFF MongoDB dump into lean openfoodfacts collection
  with kJ→kcal fallback and dedup handling
- Barcode lookup API with live OFF API fallback that caches results locally,
  progressively enhancing the local database
- Add 'off' source to food log, custom meal, and favorite ingredient models
- OpenFoodFact mongoose model for the openfoodfacts collection
This commit is contained in:
2026-04-05 11:57:25 +02:00
parent c4420b73d2
commit b7397898e3
9 changed files with 882 additions and 23 deletions

View File

@@ -1,6 +1,7 @@
<script>
import { page } from '$app/stores';
import { Heart, ExternalLink, Search, X } from 'lucide-svelte';
import { browser } from '$app/environment';
import { Heart, ExternalLink, ScanBarcode, X } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
/**
@@ -38,6 +39,14 @@
let amountInput = $state('100');
let portionIdx = $state(-1); // -1 = grams
// --- Barcode scanner state ---
let scanning = $state(false);
let scanError = $state('');
let videoEl = $state(null);
let scanStream = $state(null);
let scanDebug = $state('');
function doSearch() {
if (timeout) clearTimeout(timeout);
if (query.length < 2) {
@@ -142,18 +151,191 @@
if (v >= 10) return v.toFixed(1);
return v.toFixed(1);
}
function sourceLabel(source) {
if (source === 'bls') return 'BLS';
if (source === 'usda') return 'USDA';
if (source === 'off') return 'OFF';
return source?.toUpperCase() ?? '';
}
// --- Barcode scanning ---
async function startScan() {
scanError = '';
// Check secure context (getUserMedia requires HTTPS or localhost)
if (!globalThis.isSecureContext) {
scanError = isEn ? 'Camera requires HTTPS' : 'Kamera benötigt HTTPS';
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
scanError = isEn ? 'Camera API not available' : 'Kamera-API nicht verfügbar';
return;
}
// Check/request permission via Permissions API if available
if (navigator.permissions) {
try {
const perm = await navigator.permissions.query({ name: /** @type {any} */ ('camera') });
if (perm.state === 'denied') {
scanError = isEn
? 'Camera permission denied — enable it in your browser site settings'
: 'Kamerazugriff verweigert — in den Browser-Seiteneinstellungen aktivieren';
return;
}
} catch {
// permissions.query('camera') not supported on all browsers — proceed anyway
}
}
scanning = true;
scanDebug = 'starting…';
try {
scanDebug = 'requesting camera…';
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
scanStream = stream;
const track = stream.getVideoTracks()[0];
const settings = track?.getSettings?.() ?? {};
scanDebug = `camera: ${settings.width}x${settings.height} ${track?.label ?? '?'}`;
// Wait for the video element to be mounted
await new Promise(r => requestAnimationFrame(r));
if (!videoEl) { scanDebug = 'no video element'; stopScan(); return; }
videoEl.srcObject = stream;
await videoEl.play();
scanDebug += ` | video: ${videoEl.videoWidth}x${videoEl.videoHeight}`;
// Import barcode-detector/pure — ZXing WASM-based ponyfill
scanDebug += ' | importing detector…';
let BarcodeDetector;
try {
const mod = await import('barcode-detector/pure');
BarcodeDetector = mod.BarcodeDetector;
scanDebug += ' OK';
} catch (importErr) {
scanDebug = `IMPORT ERROR: ${importErr?.message ?? importErr}`;
stopScan();
return;
}
const detector = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'] });
scanDebug += ' | detector created';
let scanCount = 0;
const detectLoop = async () => {
while (scanning && videoEl) {
scanCount++;
try {
const vw = videoEl.videoWidth;
const vh = videoEl.videoHeight;
if (vw === 0 || vh === 0) {
scanDebug = `scan #${scanCount} | waiting for video…`;
await new Promise(r => setTimeout(r, 500));
continue;
}
const results = await detector.detect(videoEl);
scanDebug = `scan #${scanCount} | ${vw}x${vh} | ${results.length ? `found: ${results[0].rawValue}` : 'none'}`;
if (results.length > 0) {
const code = results[0].rawValue;
scanDebug = `DETECTED: ${code} (${results[0].format})`;
stopScan();
await lookupBarcode(code);
return;
}
} catch (detectErr) {
scanDebug = `scan #${scanCount} ERROR: ${detectErr?.name}: ${detectErr?.message}`;
}
await new Promise(r => setTimeout(r, 300));
}
};
detectLoop();
} catch (err) {
scanning = false;
const name = err?.name;
if (name === 'NotAllowedError') {
scanError = isEn
? 'Camera permission denied — enable it in your browser site settings'
: 'Kamerazugriff verweigert — in den Browser-Seiteneinstellungen aktivieren';
} else if (name === 'NotFoundError') {
scanError = isEn ? 'No camera found' : 'Keine Kamera gefunden';
} else if (name === 'NotReadableError') {
scanError = isEn ? 'Camera is in use by another app' : 'Kamera wird von einer anderen App verwendet';
} else {
scanError = isEn ? `Camera error: ${err?.message || name}` : `Kamerafehler: ${err?.message || name}`;
}
}
}
function stopScan() {
scanning = false;
if (scanStream) {
for (const track of scanStream.getTracks()) track.stop();
scanStream = null;
}
if (videoEl) videoEl.srcObject = null;
}
async function lookupBarcode(code) {
loading = true;
scanError = '';
try {
const res = await fetch(`/api/nutrition/barcode?code=${encodeURIComponent(code)}`);
if (!res.ok) {
scanError = isEn ? `No product found for barcode ${code}` : `Kein Produkt gefunden für Barcode ${code}`;
return;
}
const data = await res.json();
// Directly select the scanned item
selectItem(data);
} catch {
scanError = isEn ? 'Lookup failed' : 'Suche fehlgeschlagen';
} finally {
loading = false;
}
}
</script>
{#if !selected}
<!-- svelte-ignore a11y_autofocus -->
<input
type="search"
class="fs-search-input"
placeholder={t('search_food', lang)}
bind:value={query}
oninput={doSearch}
autofocus={autofocus}
/>
{#if scanning}
<div class="fs-scanner">
<div class="fs-scanner-header">
<span class="fs-scanner-title">{isEn ? 'Scan barcode' : 'Barcode scannen'}</span>
<button class="fs-scanner-close" onclick={stopScan} aria-label="Close scanner"><X size={18} /></button>
</div>
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={videoEl} class="fs-scanner-video" playsinline></video>
<div class="fs-scanner-overlay">
<div class="fs-scanner-reticle"></div>
</div>
{#if scanDebug}
<div class="fs-scan-debug">{scanDebug}</div>
{/if}
</div>
{:else if !selected}
<div class="fs-search-row">
<!-- svelte-ignore a11y_autofocus -->
<input
type="search"
class="fs-search-input"
placeholder={t('search_food', lang)}
bind:value={query}
oninput={doSearch}
autofocus={autofocus}
/>
{#if browser}
<button class="fs-barcode-btn" onclick={startScan} aria-label={isEn ? 'Scan barcode' : 'Barcode scannen'}>
<ScanBarcode size={20} />
</button>
{/if}
</div>
{#if scanError}
<p class="fs-scan-error">{scanError}</p>
{/if}
{#if loading}
<p class="fs-status">{t('loading', lang)}</p>
{/if}
@@ -170,13 +352,14 @@
<div class="fs-result-info">
<span class="fs-result-name">{item.name}</span>
<span class="fs-result-meta">
<span class="fs-source-badge" class:usda={item.source === 'usda'}>{item.source === 'bls' ? 'BLS' : 'USDA'}</span>
{item.category}
<span class="fs-source-badge" class:usda={item.source === 'usda'} class:off={item.source === 'off'}>{sourceLabel(item.source)}</span>
{#if item.brands}<span class="fs-result-brands">{item.brands}</span>{/if}
{#if item.category}{item.category}{/if}
</span>
</div>
<span class="fs-result-cal">{item.calories}<small> kcal</small></span>
</button>
{#if showDetailLinks}
{#if showDetailLinks && (item.source === 'bls' || item.source === 'usda')}
<a class="fs-detail-link" href="/fitness/{s.nutrition}/food/{item.source}/{item.id}" aria-label="View details">
<ExternalLink size={13} />
</a>
@@ -193,9 +376,12 @@
<div class="fs-selected">
<div class="fs-selected-header">
<span class="fs-selected-name">
<span class="fs-source-badge" class:usda={selected.source === 'usda'}>{selected.source === 'bls' ? 'BLS' : 'USDA'}</span>
<span class="fs-source-badge" class:usda={selected.source === 'usda'} class:off={selected.source === 'off'}>{sourceLabel(selected.source)}</span>
{selected.name}
</span>
{#if selected.brands}
<span class="fs-selected-brands">{selected.brands}</span>
{/if}
</div>
<div class="fs-amount-row">
<input
@@ -242,9 +428,14 @@
{/if}
<style>
/* ── Search input ── */
/* ── Search row with barcode button ── */
.fs-search-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.fs-search-input {
width: 100%;
flex: 1;
padding: 0.55rem 0.65rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
@@ -253,16 +444,112 @@
font-size: 0.9rem;
box-sizing: border-box;
transition: border-color 0.15s;
min-width: 0;
}
.fs-search-input:focus {
outline: none;
border-color: var(--nord8);
}
.fs-barcode-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-text-secondary);
cursor: pointer;
flex-shrink: 0;
transition: color 0.15s, border-color 0.15s;
}
.fs-barcode-btn:hover {
color: var(--nord8);
border-color: var(--nord8);
}
.fs-status {
font-size: 0.78rem;
color: var(--color-text-tertiary);
margin: 0.4rem 0;
}
.fs-scan-error {
font-size: 0.78rem;
color: var(--nord11);
margin: 0.3rem 0;
}
/* ── Barcode scanner ── */
.fs-scanner {
position: relative;
border-radius: 10px;
overflow: hidden;
background: #000;
}
.fs-scanner-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.65rem;
background: rgba(0,0,0,0.7);
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
}
.fs-scanner-title {
font-size: 0.82rem;
font-weight: 600;
color: #fff;
}
.fs-scanner-close {
display: flex;
background: none;
border: none;
color: #fff;
cursor: pointer;
padding: 0.2rem;
}
.fs-scanner-video {
width: 100%;
display: block;
max-height: 260px;
object-fit: cover;
}
.fs-scanner-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.fs-scanner-reticle {
width: 70%;
height: 40%;
border: 2px solid rgba(136, 192, 208, 0.8);
border-radius: 8px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
animation: fs-pulse 2s ease-in-out infinite;
}
@keyframes fs-pulse {
0%, 100% { border-color: rgba(136, 192, 208, 0.8); }
50% { border-color: rgba(136, 192, 208, 0.3); }
}
.fs-scan-debug {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.35rem 0.5rem;
background: rgba(0,0,0,0.75);
color: #88c0d0;
font-size: 0.65rem;
font-family: monospace;
word-break: break-all;
z-index: 3;
}
/* ── Results ── */
.fs-results {
@@ -330,6 +617,9 @@
align-items: center;
gap: 0.25rem;
}
.fs-result-brands {
font-style: italic;
}
.fs-source-badge {
display: inline-block;
font-size: 0.55rem;
@@ -345,6 +635,10 @@
background: color-mix(in srgb, var(--nord10) 15%, transparent);
color: var(--nord10);
}
.fs-source-badge.off {
background: color-mix(in srgb, var(--nord15) 15%, transparent);
color: var(--nord15);
}
.fs-result-cal {
font-size: 0.85rem;
font-weight: 700;
@@ -383,6 +677,11 @@
align-items: center;
gap: 0.35rem;
}
.fs-selected-brands {
font-size: 0.72rem;
color: var(--color-text-tertiary);
font-style: italic;
}
.fs-amount-row {
display: flex;
align-items: center;

View File

@@ -2,7 +2,7 @@ import mongoose from 'mongoose';
interface ICustomMealIngredient {
name: string;
source: 'bls' | 'usda' | 'custom';
source: 'bls' | 'usda' | 'custom' | 'off';
sourceId?: string;
amountGrams: number;
portions?: { description: string; grams: number }[];
@@ -54,7 +54,7 @@ const PortionSchema = new mongoose.Schema({
const IngredientSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
source: { type: String, enum: ['bls', 'usda', 'custom'], required: true },
source: { type: String, enum: ['bls', 'usda', 'custom', 'off'], required: true },
sourceId: { type: String },
amountGrams: { type: Number, required: true, min: 0 },
portions: { type: [PortionSchema], default: undefined },

View File

@@ -2,7 +2,7 @@ import mongoose from 'mongoose';
const FavoriteIngredientSchema = new mongoose.Schema(
{
source: { type: String, enum: ['bls', 'usda'], required: true },
source: { type: String, enum: ['bls', 'usda', 'off'], required: true },
sourceId: { type: String, required: true },
name: { type: String, required: true },
createdBy: { type: String, required: true },
@@ -13,7 +13,7 @@ const FavoriteIngredientSchema = new mongoose.Schema(
FavoriteIngredientSchema.index({ createdBy: 1, source: 1, sourceId: 1 }, { unique: true });
interface IFavoriteIngredient {
source: 'bls' | 'usda';
source: 'bls' | 'usda' | 'off';
sourceId: string;
name: string;
createdBy: string;

View File

@@ -5,7 +5,7 @@ interface IFoodLogEntry {
date: Date;
mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
name: string;
source: 'bls' | 'usda' | 'recipe' | 'custom';
source: 'bls' | 'usda' | 'recipe' | 'custom' | 'off';
sourceId?: string;
amountGrams: number;
per100g: {
@@ -47,7 +47,7 @@ const FoodLogEntrySchema = new mongoose.Schema(
date: { type: Date, required: true },
mealType: { type: String, enum: ['breakfast', 'lunch', 'dinner', 'snack'], required: true },
name: { type: String, required: true, trim: true },
source: { type: String, enum: ['bls', 'usda', 'recipe', 'custom'], required: true },
source: { type: String, enum: ['bls', 'usda', 'recipe', 'custom', 'off'], required: true },
sourceId: { type: String },
amountGrams: { type: Number, required: true, min: 0 },
per100g: { type: NutritionSnapshotSchema, required: true },

View File

@@ -0,0 +1,53 @@
import mongoose from 'mongoose';
interface IOpenFoodFact {
barcode: string;
name: string;
nameDe?: string;
brands?: string;
category?: string;
nutriscore?: string;
productQuantityG?: number;
serving?: { description: string; grams: number };
per100g: {
calories: number; protein: number; fat: number; saturatedFat?: number;
carbs: number; fiber?: number; sugars?: number;
calcium?: number; iron?: number; magnesium?: number; phosphorus?: number;
potassium?: number; sodium?: number; zinc?: number;
vitaminA?: number; vitaminC?: number; vitaminD?: number; vitaminE?: number;
vitaminK?: number; thiamin?: number; riboflavin?: number; niacin?: number;
vitaminB6?: number; vitaminB12?: number; folate?: number; cholesterol?: number;
};
}
const ServingSchema = new mongoose.Schema({
description: String,
grams: Number,
}, { _id: false });
const Per100gSchema = new mongoose.Schema({
calories: Number, protein: Number, fat: Number, saturatedFat: Number,
carbs: Number, fiber: Number, sugars: Number,
calcium: Number, iron: Number, magnesium: Number, phosphorus: Number,
potassium: Number, sodium: Number, zinc: Number,
vitaminA: Number, vitaminC: Number, vitaminD: Number, vitaminE: Number,
vitaminK: Number, thiamin: Number, riboflavin: Number, niacin: Number,
vitaminB6: Number, vitaminB12: Number, folate: Number, cholesterol: Number,
}, { _id: false });
const OpenFoodFactSchema = new mongoose.Schema({
barcode: { type: String, required: true, unique: true, index: true },
name: { type: String, required: true },
nameDe: String,
brands: String,
category: String,
nutriscore: String,
productQuantityG: Number,
serving: ServingSchema,
per100g: { type: Per100gSchema, required: true },
}, { collection: 'openfoodfacts' });
let _model: mongoose.Model<IOpenFoodFact>;
try { _model = mongoose.model<IOpenFoodFact>('OpenFoodFact'); } catch { _model = mongoose.model<IOpenFoodFact>('OpenFoodFact', OpenFoodFactSchema); }
export const OpenFoodFact = _model;
export type { IOpenFoodFact };

View File

@@ -0,0 +1,178 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { OpenFoodFact } from '$models/OpenFoodFact';
const NUTRIENT_MAP: Record<string, string> = {
'energy-kcal_100g': 'calories',
'proteins_100g': 'protein',
'fat_100g': 'fat',
'saturated-fat_100g': 'saturatedFat',
'carbohydrates_100g': 'carbs',
'fiber_100g': 'fiber',
'sugars_100g': 'sugars',
'calcium_100g': 'calcium',
'iron_100g': 'iron',
'magnesium_100g': 'magnesium',
'phosphorus_100g': 'phosphorus',
'potassium_100g': 'potassium',
'sodium_100g': 'sodium',
'zinc_100g': 'zinc',
'vitamin-a_100g': 'vitaminA',
'vitamin-c_100g': 'vitaminC',
'vitamin-d_100g': 'vitaminD',
'vitamin-e_100g': 'vitaminE',
'vitamin-k_100g': 'vitaminK',
'vitamin-b1_100g': 'thiamin',
'vitamin-b2_100g': 'riboflavin',
'vitamin-pp_100g': 'niacin',
'vitamin-b6_100g': 'vitaminB6',
'vitamin-b12_100g': 'vitaminB12',
'folates_100g': 'folate',
'cholesterol_100g': 'cholesterol',
};
function extractPer100g(nutriments: any): Record<string, number> | null {
if (!nutriments) return null;
const out: Record<string, number> = {};
let hasAny = false;
for (const [offKey, ourKey] of Object.entries(NUTRIENT_MAP)) {
const v = Number(nutriments[offKey]);
if (!isNaN(v) && v >= 0) {
out[ourKey] = v;
if (ourKey === 'calories' || ourKey === 'protein' || ourKey === 'fat' || ourKey === 'carbs') {
hasAny = true;
}
}
}
if (!out.calories) {
const kj = Number(nutriments['energy_100g']);
if (!isNaN(kj) && kj > 0) {
out.calories = Math.round(kj / 4.184 * 10) / 10;
hasAny = true;
}
}
return hasAny ? out : null;
}
async function fetchFromOffApi(code: string) {
const res = await fetch(
`https://world.openfoodfacts.org/api/v2/product/${code}?fields=code,product_name,product_name_en,product_name_de,product_name_fr,brands,quantity,serving_size,serving_quantity,nutriments,nutriscore_grade,categories_tags,product_quantity`
);
if (!res.ok) return null;
const data = await res.json();
if (data.status !== 1 || !data.product) return null;
const p = data.product;
const per100g = extractPer100g(p.nutriments);
if (!per100g) return null;
const en = p.product_name_en?.trim();
const de = p.product_name_de?.trim();
const generic = p.product_name?.trim();
const fr = p.product_name_fr?.trim();
const name = en || generic || fr;
if (!name) return null;
const portions: { description: string; grams: number }[] = [];
const servingG = Number(p.serving_quantity);
const servingDesc = typeof p.serving_size === 'string' ? p.serving_size.trim() : '';
if (servingG > 0 && servingDesc) {
portions.push({ description: servingDesc, grams: servingG });
}
const pq = Number(p.product_quantity);
if (pq > 0) {
const label = de ? `1 Packung (${pq}g)` : `1 package (${pq}g)`;
portions.push({ description: label, grams: pq });
}
let nutriscore: string | null = null;
if (typeof p.nutriscore_grade === 'string' && /^[a-e]$/.test(p.nutriscore_grade)) {
nutriscore = p.nutriscore_grade;
}
let category: string | null = null;
if (Array.isArray(p.categories_tags) && p.categories_tags.length > 0) {
category = String(p.categories_tags[p.categories_tags.length - 1])
.replace(/^en:/, '').replace(/-/g, ' ');
}
const brands = typeof p.brands === 'string' ? p.brands.trim() : null;
return {
source: 'off' as const,
id: String(p.code),
name,
nameDe: de && de !== name ? de : null,
brands: brands || null,
category,
nutriscore,
calories: per100g.calories,
per100g,
portions,
serving: servingG > 0 && servingDesc ? { description: servingDesc, grams: servingG } : null,
productQuantityG: pq > 0 ? pq : null,
};
}
/** GET /api/nutrition/barcode?code=3017620422003 */
export const GET: RequestHandler = async ({ url }) => {
const code = (url.searchParams.get('code') || '').trim();
if (!code || code.length < 4) {
return json({ error: 'Invalid barcode' }, { status: 400 });
}
await dbConnect();
const doc = await OpenFoodFact.findOne({ barcode: code }).lean();
if (doc) {
const portions = [];
if (doc.serving && doc.serving.grams > 0) {
portions.push({ description: doc.serving.description, grams: doc.serving.grams });
}
if (doc.productQuantityG && doc.productQuantityG > 0) {
const label = doc.nameDe ? `1 Packung (${doc.productQuantityG}g)` : `1 package (${doc.productQuantityG}g)`;
portions.push({ description: label, grams: doc.productQuantityG });
}
return json({
source: 'off',
id: doc.barcode,
name: doc.name,
nameDe: doc.nameDe ?? null,
brands: doc.brands ?? null,
category: doc.category ?? null,
nutriscore: doc.nutriscore ?? null,
calories: doc.per100g.calories,
per100g: doc.per100g,
portions,
});
}
// Fallback: query OFF live API
const live = await fetchFromOffApi(code);
if (!live) return json({ error: 'Product not found' }, { status: 404 });
// Cache in local DB for future lookups
try {
await OpenFoodFact.updateOne(
{ barcode: live.id },
{ $setOnInsert: {
barcode: live.id,
name: live.name,
...(live.nameDe ? { nameDe: live.nameDe } : {}),
...(live.brands ? { brands: live.brands } : {}),
...(live.category ? { category: live.category } : {}),
...(live.nutriscore ? { nutriscore: live.nutriscore } : {}),
per100g: live.per100g,
...(live.serving ? { serving: live.serving } : {}),
...(live.productQuantityG ? { productQuantityG: live.productQuantityG } : {}),
}},
{ upsert: true },
);
} catch {
// non-critical — don't fail the response
}
return json(live);
};