feat: add barcode scanner with OpenFoodFacts integration
All checks were successful
CI / update (push) Successful in 5m26s
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:
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
53
src/models/OpenFoodFact.ts
Normal file
53
src/models/OpenFoodFact.ts
Normal 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 };
|
||||
178
src/routes/api/nutrition/barcode/+server.ts
Normal file
178
src/routes/api/nutrition/barcode/+server.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user