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

@@ -48,8 +48,10 @@
"dependencies": {
"@auth/sveltekit": "^1.11.1",
"@huggingface/transformers": "^4.0.0",
"@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3",
"@sveltejs/adapter-node": "^5.0.0",
"@tauri-apps/plugin-geolocation": "^2.3.2",
"barcode-detector": "^3.1.2",
"chart.js": "^4.5.0",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",

49
pnpm-lock.yaml generated
View File

@@ -14,12 +14,18 @@ importers:
'@huggingface/transformers':
specifier: ^4.0.0
version: 4.0.0
'@nicolo-ribaudo/chokidar-2':
specifier: 2.1.8-no-fsevents.3
version: 2.1.8-no-fsevents.3
'@sveltejs/adapter-node':
specifier: ^5.0.0
version: 5.3.1(@sveltejs/kit@2.37.0(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.6)(vite@7.1.3(@types/node@22.18.0)(terser@5.46.0)))(svelte@5.38.6)(vite@7.1.3(@types/node@22.18.0)(terser@5.46.0)))
'@tauri-apps/plugin-geolocation':
specifier: ^2.3.2
version: 2.3.2
barcode-detector:
specifier: ^3.1.2
version: 3.1.2(@types/emscripten@1.41.5)
chart.js:
specifier: ^4.5.0
version: 4.5.0
@@ -807,6 +813,9 @@ packages:
'@mongodb-js/saslprep@1.3.0':
resolution: {integrity: sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==}
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
@@ -1160,6 +1169,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/emscripten@1.41.5':
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
'@types/estree@1.0.1':
resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==}
@@ -1257,6 +1269,9 @@ packages:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
barcode-detector@3.1.2:
resolution: {integrity: sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ==}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -1959,6 +1974,10 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
terser@5.46.0:
resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==}
engines: {node: '>=10'}
@@ -2016,6 +2035,10 @@ packages:
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
engines: {node: '>=10'}
type-fest@5.5.0:
resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==}
engines: {node: '>=20'}
typescript@5.1.6:
resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==}
engines: {node: '>=14.17'}
@@ -2210,6 +2233,11 @@ packages:
zimmerframe@1.1.2:
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
zxing-wasm@3.0.2:
resolution: {integrity: sha512-2YMAriaYHX9wrBY2k7H0epSo+dyCaCZg/vOtt+nEDXM9ul480gkXz/9SkwpOeHcD2H5qqDG8lWDSBwpTcZpa6w==}
peerDependencies:
'@types/emscripten': '>=1.39.6'
snapshots:
'@acemir/cssom@0.9.23': {}
@@ -2666,6 +2694,8 @@ snapshots:
dependencies:
sparse-bitfield: 3.0.3
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': {}
'@panva/hkdf@1.2.1': {}
'@playwright/test@1.56.1':
@@ -2959,6 +2989,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/emscripten@1.41.5': {}
'@types/estree@1.0.1': {}
'@types/estree@1.0.8': {}
@@ -3053,6 +3085,12 @@ snapshots:
axobject-query@4.1.0: {}
barcode-detector@3.1.2(@types/emscripten@1.41.5):
dependencies:
zxing-wasm: 3.0.2(@types/emscripten@1.41.5)
transitivePeerDependencies:
- '@types/emscripten'
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -3820,6 +3858,8 @@ snapshots:
symbol-tree@3.2.4: {}
tagged-tag@1.0.0: {}
terser@5.46.0:
dependencies:
'@jridgewell/source-map': 0.3.11
@@ -3873,6 +3913,10 @@ snapshots:
type-fest@0.13.1: {}
type-fest@5.5.0:
dependencies:
tagged-tag: 1.0.0
typescript@5.1.6: {}
uint8array-extras@1.5.0: {}
@@ -4005,3 +4049,8 @@ snapshots:
xmlchars@2.2.0: {}
zimmerframe@1.1.2: {}
zxing-wasm@3.0.2(@types/emscripten@1.41.5):
dependencies:
'@types/emscripten': 1.41.5
type-fest: 5.5.0

View File

@@ -0,0 +1,278 @@
/**
* Import OpenFoodFacts MongoDB dump into a lean `openfoodfacts` collection.
*
* This script:
* 0. Downloads the OFF MongoDB dump if not present locally
* 1. Runs `mongorestore` to load the raw dump into a temporary `off_products` collection
* 2. Transforms each document, extracting only the fields we need
* 3. Inserts into the `openfoodfacts` collection with proper indexes
* 4. Drops the temporary `off_products` collection
*
* Reads MONGO_URL from .env (via dotenv).
*
* Usage:
* pnpm exec vite-node scripts/import-openfoodfacts.ts [path-to-dump.gz]
*
* Default dump path: ./openfoodfacts-mongodbdump.gz
*/
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import mongoose from 'mongoose';
const OFF_DUMP_URL = 'https://static.openfoodfacts.org/data/openfoodfacts-mongodbdump.gz';
// --- Load MONGO_URL from .env ---
const envPath = resolve(import.meta.dirname ?? '.', '..', '.env');
const envText = readFileSync(envPath, 'utf-8');
const mongoMatch = envText.match(/^MONGO_URL="?([^"\n]+)"?/m);
if (!mongoMatch) { console.error('MONGO_URL not found in .env'); process.exit(1); }
const MONGO_URL = mongoMatch[1];
// Parse components for mongorestore URI (needs root DB, not /recipes)
const parsed = new URL(MONGO_URL);
const RESTORE_URI = `mongodb://${parsed.username}:${parsed.password}@${parsed.host}/?authSource=${new URLSearchParams(parsed.search).get('authSource') || 'admin'}`;
const DB_NAME = parsed.pathname.replace(/^\//, '') || 'recipes';
const BATCH_SIZE = 5000;
// --- Resolve dump file path, download if missing ---
const dumpPath = resolve(process.argv[2] || './openfoodfacts-mongodbdump.gz');
if (!existsSync(dumpPath)) {
console.log(`\nDump file not found at ${dumpPath}`);
console.log(`Downloading from ${OFF_DUMP_URL} (~13 GB)…\n`);
try {
execSync(`curl -L -o "${dumpPath}" --progress-bar "${OFF_DUMP_URL}"`, { stdio: 'inherit' });
} catch (err: any) {
console.error('Download failed:', err.message);
process.exit(1);
}
console.log('Download complete.\n');
}
// Map OFF nutriment keys → our per100g field names
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;
}
}
}
// Fall back to kJ → kcal if energy-kcal_100g was missing
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;
}
function pickName(doc: any): { name: string; nameDe?: string } | null {
const en = doc.product_name_en?.trim();
const de = doc.product_name_de?.trim();
const generic = doc.product_name?.trim();
const fr = doc.product_name_fr?.trim();
const name = en || generic || fr;
if (!name) return null;
return { name, ...(de && de !== name ? { nameDe: de } : {}) };
}
async function main() {
// --- Step 1: mongorestore (skip if off_products already has data) ---
await mongoose.connect(MONGO_URL);
let existingCount = await mongoose.connection.db!.collection('off_products').estimatedDocumentCount();
if (existingCount > 100000) {
console.log(`\n=== Step 1: SKIPPED — off_products already has ~${existingCount.toLocaleString()} documents ===\n`);
} else {
console.log(`\n=== Step 1: mongorestore from ${dumpPath} ===\n`);
await mongoose.disconnect();
const restoreCmd = [
'mongorestore', '--gzip',
`--archive=${dumpPath}`,
`--uri="${RESTORE_URI}"`,
`--nsFrom='off.products'`,
`--nsTo='${DB_NAME}.off_products'`,
'--drop', '--noIndexRestore',
].join(' ');
console.log(`Running: ${restoreCmd.replace(parsed.password, '***')}\n`);
try {
execSync(restoreCmd, { stdio: 'inherit', shell: '/bin/sh' });
} catch (err: any) {
console.error('mongorestore failed:', err.message);
process.exit(1);
}
await mongoose.connect(MONGO_URL);
}
const db = mongoose.connection.db!;
// --- Step 2: Transform ---
console.log('\n=== Step 2: Transform off_products → openfoodfacts ===\n');
const src = db.collection('off_products');
const dst = db.collection('openfoodfacts');
const srcCount = await src.estimatedDocumentCount();
console.log(`Source off_products: ~${srcCount.toLocaleString()} documents`);
try { await dst.drop(); } catch {}
console.log('Transforming…');
let processed = 0;
let inserted = 0;
let skipped = 0;
let batch: any[] = [];
const cursor = src.find(
{ code: { $exists: true, $ne: '' }, $or: [{ 'nutriments.energy-kcal_100g': { $gt: 0 } }, { 'nutriments.energy_100g': { $gt: 0 } }] },
{
projection: {
code: 1, product_name: 1, product_name_en: 1, product_name_de: 1,
product_name_fr: 1, brands: 1, quantity: 1, serving_size: 1,
serving_quantity: 1, nutriments: 1, nutriscore_grade: 1,
categories_tags: 1, product_quantity: 1,
}
}
).batchSize(BATCH_SIZE);
for await (const doc of cursor) {
processed++;
const names = pickName(doc);
if (!names) { skipped++; continue; }
const per100g = extractPer100g(doc.nutriments);
if (!per100g) { skipped++; continue; }
const barcode = String(doc.code).trim();
if (!barcode || barcode.length < 4) { skipped++; continue; }
const entry: any = { barcode, name: names.name, per100g };
if (names.nameDe) entry.nameDe = names.nameDe;
const brands = typeof doc.brands === 'string' ? doc.brands.trim() : '';
if (brands) entry.brands = brands;
const servingG = Number(doc.serving_quantity);
const servingDesc = typeof doc.serving_size === 'string' ? doc.serving_size.trim() : '';
if (servingG > 0 && servingDesc) {
entry.serving = { description: servingDesc, grams: servingG };
}
const pq = Number(doc.product_quantity);
if (pq > 0) entry.productQuantityG = pq;
if (typeof doc.nutriscore_grade === 'string' && /^[a-e]$/.test(doc.nutriscore_grade)) {
entry.nutriscore = doc.nutriscore_grade;
}
if (Array.isArray(doc.categories_tags) && doc.categories_tags.length > 0) {
const cat = String(doc.categories_tags[doc.categories_tags.length - 1])
.replace(/^en:/, '').replace(/-/g, ' ');
entry.category = cat;
}
batch.push(entry);
if (batch.length >= BATCH_SIZE) {
try {
await dst.insertMany(batch, { ordered: false });
inserted += batch.length;
} catch (bulkErr: any) {
// Duplicate key errors are expected (duplicate barcodes in OFF data)
inserted += bulkErr.insertedCount ?? 0;
}
batch = [];
if (processed % 100000 === 0) {
console.log(` ${processed.toLocaleString()} processed, ${inserted.toLocaleString()} inserted, ${skipped.toLocaleString()} skipped`);
}
}
}
if (batch.length > 0) {
try {
await dst.insertMany(batch, { ordered: false });
inserted += batch.length;
} catch (bulkErr: any) {
inserted += bulkErr.insertedCount ?? 0;
}
}
console.log(`\nTransform complete: ${processed.toLocaleString()} processed → ${inserted.toLocaleString()} inserted, ${skipped.toLocaleString()} skipped`);
// --- Step 3: Deduplicate & create indexes ---
console.log('\n=== Step 3: Deduplicate & create indexes ===\n');
// Remove duplicate barcodes (keep first inserted)
const dupes = await dst.aggregate([
{ $group: { _id: '$barcode', ids: { $push: '$_id' }, count: { $sum: 1 } } },
{ $match: { count: { $gt: 1 } } },
]).toArray();
if (dupes.length > 0) {
const idsToRemove = dupes.flatMap(d => d.ids.slice(1));
await dst.deleteMany({ _id: { $in: idsToRemove } });
console.log(` ✓ removed ${idsToRemove.length} duplicate barcodes`);
}
await dst.createIndex({ barcode: 1 }, { unique: true });
console.log(' ✓ barcode (unique)');
await dst.createIndex({ name: 'text', nameDe: 'text', brands: 'text' });
console.log(' ✓ text (name, nameDe, brands)');
// --- Step 4: Cleanup (manual) ---
// To drop the large off_products temp collection after verifying results:
// db.off_products.drop()
console.log('\n=== Step 4: Skipping off_products cleanup (run manually when satisfied) ===');
const finalCount = await dst.countDocuments();
console.log(`\n=== Done: openfoodfacts collection has ${finalCount.toLocaleString()} documents ===\n`);
await mongoose.disconnect();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

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,9 +151,173 @@
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}
{#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"
@@ -154,6 +327,15 @@
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);
};