From 039d37b4101d84af24b348292d5987fbf5a61099 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sun, 5 Apr 2026 11:57:25 +0200 Subject: [PATCH] feat: add barcode scanner with OpenFoodFacts integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package.json | 2 + pnpm-lock.yaml | 49 +++ scripts/import-openfoodfacts.ts | 278 ++++++++++++++++ src/lib/components/fitness/FoodSearch.svelte | 333 ++++++++++++++++++- src/models/CustomMeal.ts | 4 +- src/models/FavoriteIngredient.ts | 4 +- src/models/FoodLogEntry.ts | 4 +- src/models/OpenFoodFact.ts | 53 +++ src/routes/api/nutrition/barcode/+server.ts | 178 ++++++++++ 9 files changed, 882 insertions(+), 23 deletions(-) create mode 100644 scripts/import-openfoodfacts.ts create mode 100644 src/models/OpenFoodFact.ts create mode 100644 src/routes/api/nutrition/barcode/+server.ts diff --git a/package.json b/package.json index 4af2ea8f..8e6ce12d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dcc4acd..f77dad9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/import-openfoodfacts.ts b/scripts/import-openfoodfacts.ts new file mode 100644 index 00000000..77b80f0c --- /dev/null +++ b/scripts/import-openfoodfacts.ts @@ -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 = { + '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 | null { + if (!nutriments) return null; + const out: Record = {}; + 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); +}); diff --git a/src/lib/components/fitness/FoodSearch.svelte b/src/lib/components/fitness/FoodSearch.svelte index 4462d632..4bfcad99 100644 --- a/src/lib/components/fitness/FoodSearch.svelte +++ b/src/lib/components/fitness/FoodSearch.svelte @@ -1,6 +1,7 @@ -{#if !selected} - - +{#if scanning} +
+
+ {isEn ? 'Scan barcode' : 'Barcode scannen'} + +
+ + +
+
+
+ {#if scanDebug} +
{scanDebug}
+ {/if} +
+{:else if !selected} +
+ + + {#if browser} + + {/if} +
+ {#if scanError} +

{scanError}

+ {/if} {#if loading}

{t('loading', lang)}

{/if} @@ -170,13 +352,14 @@
{item.name} - {item.source === 'bls' ? 'BLS' : 'USDA'} - {item.category} + {sourceLabel(item.source)} + {#if item.brands}{item.brands}{/if} + {#if item.category}{item.category}{/if}
{item.calories} kcal - {#if showDetailLinks} + {#if showDetailLinks && (item.source === 'bls' || item.source === 'usda')} @@ -193,9 +376,12 @@
- {selected.source === 'bls' ? 'BLS' : 'USDA'} + {sourceLabel(selected.source)} {selected.name} + {#if selected.brands} + {selected.brands} + {/if}
- /* ── 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; diff --git a/src/models/CustomMeal.ts b/src/models/CustomMeal.ts index 0d1fd843..57d315b0 100644 --- a/src/models/CustomMeal.ts +++ b/src/models/CustomMeal.ts @@ -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 }, diff --git a/src/models/FavoriteIngredient.ts b/src/models/FavoriteIngredient.ts index 1860d16a..0758b67e 100644 --- a/src/models/FavoriteIngredient.ts +++ b/src/models/FavoriteIngredient.ts @@ -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; diff --git a/src/models/FoodLogEntry.ts b/src/models/FoodLogEntry.ts index 7f50ee29..0cd4fcc7 100644 --- a/src/models/FoodLogEntry.ts +++ b/src/models/FoodLogEntry.ts @@ -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 }, diff --git a/src/models/OpenFoodFact.ts b/src/models/OpenFoodFact.ts new file mode 100644 index 00000000..1f0d90f8 --- /dev/null +++ b/src/models/OpenFoodFact.ts @@ -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; +try { _model = mongoose.model('OpenFoodFact'); } catch { _model = mongoose.model('OpenFoodFact', OpenFoodFactSchema); } +export const OpenFoodFact = _model; +export type { IOpenFoodFact }; diff --git a/src/routes/api/nutrition/barcode/+server.ts b/src/routes/api/nutrition/barcode/+server.ts new file mode 100644 index 00000000..051ea13c --- /dev/null +++ b/src/routes/api/nutrition/barcode/+server.ts @@ -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 = { + '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 | null { + if (!nutriments) return null; + const out: Record = {}; + 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); +};