Compare commits

3 Commits

Author SHA1 Message Date
4f77f29a27 Merge branch 'recipes-calories'
All checks were successful
CI / update (push) Successful in 4m37s
recipes: nutrition calculator with BLS/USDA matching, manual overwrites, and skip

Dual-source nutrition system using BLS (German, primary) and USDA (English, fallback)
with ML embedding matching (multilingual-e5-small / all-MiniLM-L6-v2), hybrid
substring-first search, and position-aware scoring heuristics.

Includes per-recipe and global manual ingredient overwrites, ingredient skip/exclude,
referenced recipe nutrition (base refs + anchor tags), section-name dedup,
amino acid tracking, and reactive client-side calculator with NutritionSummary component.

recipes: overhaul nutrition editor UI and defer saves to form submission

- Nutrition mappings and global overwrites are now local-only until
  the recipe is saved, preventing premature DB writes on generate/edit
- Generate endpoint supports ?preview=true for non-persisting previews
- Show existing nutrition data immediately instead of requiring generate
- Replace raw checkboxes with Toggle component for global overwrites,
  initialized from existing NutritionOverwrite records
- Fix search dropdown readability (solid backgrounds, proper theming)
- Use fuzzy search (fzf-style) for manual nutrition ingredient lookup
- Swap ingredient display: German primary, English in brackets
- Allow editing g/u on manually mapped ingredients
- Make translation optional: separate save (FAB) and translate buttons
- "Vollständig neu übersetzen" now triggers actual full retranslation
- Show existing translation inline instead of behind a button
- Replace nord0 dark backgrounds with semantic theme variables
2026-04-02 19:47:12 +02:00
705a10bb3a recipes: overhaul nutrition editor UI and defer saves to form submission
- Nutrition mappings and global overwrites are now local-only until
  the recipe is saved, preventing premature DB writes on generate/edit
- Generate endpoint supports ?preview=true for non-persisting previews
- Show existing nutrition data immediately instead of requiring generate
- Replace raw checkboxes with Toggle component for global overwrites,
  initialized from existing NutritionOverwrite records
- Fix search dropdown readability (solid backgrounds, proper theming)
- Use fuzzy search (fzf-style) for manual nutrition ingredient lookup
- Swap ingredient display: German primary, English in brackets
- Allow editing g/u on manually mapped ingredients
- Make translation optional: separate save (FAB) and translate buttons
- "Vollständig neu übersetzen" now triggers actual full retranslation
- Show existing translation inline instead of behind a button
- Replace nord0 dark backgrounds with semantic theme variables
2026-04-02 19:46:03 +02:00
7e1181461e recipes: nutrition calculator with BLS/USDA matching, manual overwrites, and skip
Dual-source nutrition system using BLS (German, primary) and USDA (English, fallback)
with ML embedding matching (multilingual-e5-small / all-MiniLM-L6-v2), hybrid
substring-first search, and position-aware scoring heuristics.

Includes per-recipe and global manual ingredient overwrites, ingredient skip/exclude,
referenced recipe nutrition (base refs + anchor tags), section-name dedup,
amino acid tracking, and reactive client-side calculator with NutritionSummary component.
2026-04-01 13:00:55 +02:00
33 changed files with 722586 additions and 73 deletions

2
.gitignore vendored
View File

@@ -10,6 +10,8 @@ node_modules
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# USDA bulk data downloads (regenerated by scripts/import-usda-nutrition.ts)
data/usda/
src-tauri/target/ src-tauri/target/
src-tauri/*.keystore src-tauri/*.keystore
# Android: ignore build output and caches, track source files # Android: ignore build output and caches, track source files

View File

@@ -47,6 +47,7 @@
}, },
"dependencies": { "dependencies": {
"@auth/sveltekit": "^1.11.1", "@auth/sveltekit": "^1.11.1",
"@huggingface/transformers": "^4.0.0",
"@sveltejs/adapter-node": "^5.0.0", "@sveltejs/adapter-node": "^5.0.0",
"@tauri-apps/plugin-geolocation": "^2.3.2", "@tauri-apps/plugin-geolocation": "^2.3.2",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",

589
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

61
scripts/embed-bls-db.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* Pre-compute sentence embeddings for BLS German food names.
* Uses multilingual-e5-small for good German language understanding.
*
* Run: pnpm exec vite-node scripts/embed-bls-db.ts
*/
import { pipeline } from '@huggingface/transformers';
import { writeFileSync } from 'fs';
import { resolve } from 'path';
// Dynamic import of blsDb (generated file)
const { BLS_DB } = await import('../src/lib/data/blsDb');
const MODEL_NAME = 'Xenova/multilingual-e5-small';
const OUTPUT_FILE = resolve('src/lib/data/blsEmbeddings.json');
async function main() {
console.log(`Loading model ${MODEL_NAME}...`);
const embedder = await pipeline('feature-extraction', MODEL_NAME, {
dtype: 'q8',
});
console.log(`Embedding ${BLS_DB.length} BLS entries...`);
const entries: { blsCode: string; name: string; vector: number[] }[] = [];
const batchSize = 32;
for (let i = 0; i < BLS_DB.length; i += batchSize) {
const batch = BLS_DB.slice(i, i + batchSize);
// e5 models require "passage: " prefix for documents
const texts = batch.map(e => `passage: ${e.nameDe}`);
for (let j = 0; j < batch.length; j++) {
const result = await embedder(texts[j], { pooling: 'mean', normalize: true });
const vector = Array.from(result.data as Float32Array).map(v => Math.round(v * 10000) / 10000);
entries.push({
blsCode: batch[j].blsCode,
name: batch[j].nameDe,
vector,
});
}
if ((i + batchSize) % 500 < batchSize) {
console.log(` ${Math.min(i + batchSize, BLS_DB.length)}/${BLS_DB.length}`);
}
}
const output = {
model: MODEL_NAME,
dimensions: entries[0]?.vector.length || 384,
count: entries.length,
entries,
};
const json = JSON.stringify(output);
writeFileSync(OUTPUT_FILE, json, 'utf-8');
console.log(`Written ${OUTPUT_FILE} (${(json.length / 1024 / 1024).toFixed(1)}MB, ${entries.length} entries)`);
}
main().catch(console.error);

View File

@@ -0,0 +1,60 @@
/**
* Pre-computes sentence embeddings for all USDA nutrition DB entries using
* all-MiniLM-L6-v2 via @huggingface/transformers.
*
* Run with: pnpm exec vite-node scripts/embed-nutrition-db.ts
*
* Outputs: src/lib/data/nutritionEmbeddings.json
* Format: { entries: [{ fdcId, name, vector: number[384] }] }
*/
import { writeFileSync } from 'fs';
import { resolve } from 'path';
import { pipeline } from '@huggingface/transformers';
import { NUTRITION_DB } from '../src/lib/data/nutritionDb';
const OUTPUT_PATH = resolve('src/lib/data/nutritionEmbeddings.json');
const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2';
const BATCH_SIZE = 64;
async function main() {
console.log('=== Nutrition DB Embedding Generation ===\n');
console.log(`Entries to embed: ${NUTRITION_DB.length}`);
console.log(`Model: ${MODEL_NAME}`);
console.log(`Loading model (first run downloads ~23MB)...\n`);
const embedder = await pipeline('feature-extraction', MODEL_NAME, {
dtype: 'q8',
});
const entries: { fdcId: number; name: string; vector: number[] }[] = [];
const totalBatches = Math.ceil(NUTRITION_DB.length / BATCH_SIZE);
for (let i = 0; i < NUTRITION_DB.length; i += BATCH_SIZE) {
const batch = NUTRITION_DB.slice(i, i + BATCH_SIZE);
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
process.stdout.write(`\r Batch ${batchNum}/${totalBatches} (${i + batch.length}/${NUTRITION_DB.length})`);
// Embed all names in this batch
for (const item of batch) {
const result = await embedder(item.name, { pooling: 'mean', normalize: true });
// result.data is a Float32Array — truncate to 4 decimal places to save space
const vector = Array.from(result.data as Float32Array).map(v => Math.round(v * 10000) / 10000);
entries.push({ fdcId: item.fdcId, name: item.name, vector });
}
}
console.log('\n\nWriting embeddings...');
const output = { model: MODEL_NAME, dimensions: 384, count: entries.length, entries };
writeFileSync(OUTPUT_PATH, JSON.stringify(output), 'utf-8');
const fileSizeMB = (Buffer.byteLength(JSON.stringify(output)) / 1024 / 1024).toFixed(1);
console.log(`Written ${entries.length} embeddings to ${OUTPUT_PATH} (${fileSizeMB}MB)`);
await embedder.dispose();
}
main().catch(err => {
console.error('Embedding generation failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,180 @@
/**
* Import BLS 4.0 (Bundeslebensmittelschlüssel) nutrition data from CSV.
* Pre-convert the xlsx to CSV first (one-time):
* node -e "const X=require('xlsx');const w=X.readFile('BLS_4_0_2025_DE/BLS_4_0_Daten_2025_DE.xlsx');
* require('fs').writeFileSync('BLS_4_0_2025_DE/BLS_4_0_Daten_2025_DE.csv',X.utils.sheet_to_csv(w.Sheets[w.SheetNames[0]]))"
*
* Run: pnpm exec vite-node scripts/import-bls-nutrition.ts
*/
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
/** Parse CSV handling quoted fields with commas */
function parseCSV(text: string): string[][] {
const rows: string[][] = [];
let i = 0;
while (i < text.length) {
const row: string[] = [];
while (i < text.length && text[i] !== '\n') {
if (text[i] === '"') {
i++; // skip opening quote
let field = '';
while (i < text.length) {
if (text[i] === '"') {
if (text[i + 1] === '"') { field += '"'; i += 2; }
else { i++; break; }
} else { field += text[i]; i++; }
}
row.push(field);
if (text[i] === ',') i++;
} else {
const next = text.indexOf(',', i);
const nl = text.indexOf('\n', i);
const end = (next === -1 || (nl !== -1 && nl < next)) ? (nl === -1 ? text.length : nl) : next;
row.push(text.substring(i, end));
i = end;
if (text[i] === ',') i++;
}
}
if (text[i] === '\n') i++;
if (row.length > 0) rows.push(row);
}
return rows;
}
const BLS_CSV = resolve('BLS_4_0_2025_DE/BLS_4_0_Daten_2025_DE.csv');
const OUTPUT_FILE = resolve('src/lib/data/blsDb.ts');
// BLS nutrient code → our per100g field name
const NUTRIENT_MAP: Record<string, { field: string; divisor?: number }> = {
ENERCC: { field: 'calories' },
PROT625: { field: 'protein' },
FAT: { field: 'fat' },
FASAT: { field: 'saturatedFat' },
CHO: { field: 'carbs' },
FIBT: { field: 'fiber' },
SUGAR: { field: 'sugars' },
CA: { field: 'calcium' },
FE: { field: 'iron' },
MG: { field: 'magnesium' },
P: { field: 'phosphorus' },
K: { field: 'potassium' },
NA: { field: 'sodium' },
ZN: { field: 'zinc' },
VITA: { field: 'vitaminA' },
VITC: { field: 'vitaminC' },
VITD: { field: 'vitaminD' },
VITE: { field: 'vitaminE' },
VITK: { field: 'vitaminK' },
THIA: { field: 'thiamin' },
RIBF: { field: 'riboflavin' },
NIA: { field: 'niacin' },
VITB6: { field: 'vitaminB6', divisor: 1000 }, // BLS: µg → mg
VITB12: { field: 'vitaminB12' },
FOL: { field: 'folate' },
CHORL: { field: 'cholesterol' },
// Amino acids (all g/100g)
ILE: { field: 'isoleucine' },
LEU: { field: 'leucine' },
LYS: { field: 'lysine' },
MET: { field: 'methionine' },
PHE: { field: 'phenylalanine' },
THR: { field: 'threonine' },
TRP: { field: 'tryptophan' },
VAL: { field: 'valine' },
HIS: { field: 'histidine' },
ALA: { field: 'alanine' },
ARG: { field: 'arginine' },
ASP: { field: 'asparticAcid' },
CYSTE: { field: 'cysteine' },
GLU: { field: 'glutamicAcid' },
GLY: { field: 'glycine' },
PRO: { field: 'proline' },
SER: { field: 'serine' },
TYR: { field: 'tyrosine' },
};
// BLS code first letter → category (BLS 4.0 Hauptgruppen)
const CATEGORY_MAP: Record<string, string> = {
A: 'Getränke', B: 'Getreideprodukte', C: 'Getreide', D: 'Backwaren',
E: 'Gemüse', F: 'Obst', G: 'Hülsenfrüchte',
H: 'Gewürze und Kräuter', J: 'Fette und Öle', K: 'Milch und Milchprodukte',
L: 'Eier', M: 'Fleisch', N: 'Wurstwaren', O: 'Wild', P: 'Geflügel',
Q: 'Fisch und Meeresfrüchte', R: 'Süßwaren', S: 'Zucker und Honig',
T: 'Gerichte und Rezepte', U: 'Pilze', V: 'Sonstiges', W: 'Algen',
X: 'Fleischersatz', Y: 'Supplemente',
};
async function main() {
console.log('Reading BLS CSV...');
const csvText = readFileSync(BLS_CSV, 'utf-8');
const rows: string[][] = parseCSV(csvText);
const headers = rows[0];
console.log(`Headers: ${headers.length} columns, ${rows.length - 1} data rows`);
// Build column index: BLS nutrient code → column index of the value column
const codeToCol = new Map<string, number>();
for (let c = 3; c < headers.length; c += 3) {
const code = headers[c]?.split(' ')[0];
if (code) codeToCol.set(code, c);
}
const entries: any[] = [];
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
const blsCode = row[0]?.trim();
const nameDe = row[1]?.trim();
const nameEn = row[2]?.trim() || '';
if (!blsCode || !nameDe) continue;
const category = CATEGORY_MAP[blsCode[0]] || 'Sonstiges';
const per100g: Record<string, number> = {};
for (const [blsNutrientCode, mapping] of Object.entries(NUTRIENT_MAP)) {
const col = codeToCol.get(blsNutrientCode);
if (col === undefined) {
per100g[mapping.field] = 0;
continue;
}
let value = parseFloat(row[col] || '0');
if (isNaN(value)) value = 0;
if (mapping.divisor) value /= mapping.divisor;
per100g[mapping.field] = Math.round(value * 1000) / 1000;
}
entries.push({ blsCode, nameDe, nameEn, category, per100g });
}
console.log(`Parsed ${entries.length} BLS entries`);
// Sample entries
const sample = entries.slice(0, 3);
for (const e of sample) {
console.log(` ${e.blsCode} | ${e.nameDe} | ${e.per100g.calories} kcal | protein ${e.per100g.protein}g`);
}
const output = `// Auto-generated from BLS 4.0 (Bundeslebensmittelschlüssel)
// Generated: ${new Date().toISOString().split('T')[0]}
// Do not edit manually — regenerate with: pnpm exec vite-node scripts/import-bls-nutrition.ts
import type { NutritionPer100g } from '$types/types';
export type BlsEntry = {
blsCode: string;
nameDe: string;
nameEn: string;
category: string;
per100g: NutritionPer100g;
};
export const BLS_DB: BlsEntry[] = ${JSON.stringify(entries, null, 0)};
`;
writeFileSync(OUTPUT_FILE, output, 'utf-8');
console.log(`Written ${OUTPUT_FILE} (${(output.length / 1024 / 1024).toFixed(1)}MB, ${entries.length} entries)`);
}
main().catch(console.error);

View File

@@ -0,0 +1,371 @@
/**
* Imports USDA FoodData Central data (SR Legacy + Foundation Foods) and generates
* a typed nutrition database for the recipe calorie calculator.
*
* Run with: pnpm exec vite-node scripts/import-usda-nutrition.ts
*
* Downloads bulk CSV data from USDA FDC, filters to relevant food categories,
* extracts macro/micronutrient data per 100g, and outputs src/lib/data/nutritionDb.ts
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
const DATA_DIR = resolve('data/usda');
const OUTPUT_PATH = resolve('src/lib/data/nutritionDb.ts');
// USDA FDC bulk download URLs
const USDA_URLS = {
srLegacy: 'https://fdc.nal.usda.gov/fdc-datasets/FoodData_Central_sr_legacy_food_csv_2018-04.zip',
foundation: 'https://fdc.nal.usda.gov/fdc-datasets/FoodData_Central_foundation_food_csv_2024-10-31.zip',
};
// Nutrient IDs we care about
const NUTRIENT_IDS: Record<number, string> = {
1008: 'calories',
1003: 'protein',
1004: 'fat',
1258: 'saturatedFat',
1005: 'carbs',
1079: 'fiber',
1063: 'sugars',
// Minerals
1087: 'calcium',
1089: 'iron',
1090: 'magnesium',
1091: 'phosphorus',
1092: 'potassium',
1093: 'sodium',
1095: 'zinc',
// Vitamins
1106: 'vitaminA', // RAE (mcg)
1162: 'vitaminC',
1114: 'vitaminD', // D2+D3 (mcg)
1109: 'vitaminE',
1185: 'vitaminK',
1165: 'thiamin',
1166: 'riboflavin',
1167: 'niacin',
1175: 'vitaminB6',
1178: 'vitaminB12',
1177: 'folate',
// Other
1253: 'cholesterol',
// Amino acids (g/100g)
1212: 'isoleucine',
1213: 'leucine',
1214: 'lysine',
1215: 'methionine',
1217: 'phenylalanine',
1211: 'threonine',
1210: 'tryptophan',
1219: 'valine',
1221: 'histidine',
1222: 'alanine',
1220: 'arginine',
1223: 'asparticAcid',
1216: 'cysteine',
1224: 'glutamicAcid',
1225: 'glycine',
1226: 'proline',
1227: 'serine',
1218: 'tyrosine',
};
// Food categories to include (SR Legacy food_category_id descriptions)
const INCLUDED_CATEGORIES = new Set([
'Dairy and Egg Products',
'Spices and Herbs',
'Baby Foods',
'Fats and Oils',
'Poultry Products',
'Soups, Sauces, and Gravies',
'Sausages and Luncheon Meats',
'Breakfast Cereals',
'Fruits and Fruit Juices',
'Pork Products',
'Vegetables and Vegetable Products',
'Nut and Seed Products',
'Beef Products',
'Beverages',
'Finfish and Shellfish Products',
'Legumes and Legume Products',
'Lamb, Veal, and Game Products',
'Baked Products',
'Sweets',
'Cereal Grains and Pasta',
'Snacks',
'Restaurant Foods',
]);
type NutrientData = Record<string, number>;
interface RawFood {
fdcId: number;
description: string;
categoryId: number;
category: string;
}
interface Portion {
description: string;
grams: number;
}
// Simple CSV line parser that handles quoted fields
function parseCSVLine(line: string): string[] {
const fields: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (ch === ',' && !inQuotes) {
fields.push(current);
current = '';
} else {
current += ch;
}
}
fields.push(current);
return fields;
}
async function readCSV(filePath: string): Promise<Record<string, string>[]> {
if (!existsSync(filePath)) {
console.warn(` File not found: ${filePath}`);
return [];
}
const content = readFileSync(filePath, 'utf-8');
const lines = content.split('\n').filter(l => l.trim());
if (lines.length === 0) return [];
const headers = parseCSVLine(lines[0]);
const rows: Record<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const fields = parseCSVLine(lines[i]);
const row: Record<string, string> = {};
for (let j = 0; j < headers.length; j++) {
row[headers[j]] = fields[j] || '';
}
rows.push(row);
}
return rows;
}
async function downloadAndExtract(url: string, targetDir: string): Promise<void> {
const zipName = url.split('/').pop()!;
const zipPath = resolve(DATA_DIR, zipName);
if (existsSync(targetDir) && readFileSync(resolve(targetDir, '.done'), 'utf-8').trim() === 'ok') {
console.log(` Already extracted: ${targetDir}`);
return;
}
mkdirSync(targetDir, { recursive: true });
if (!existsSync(zipPath)) {
console.log(` Downloading ${zipName}...`);
const response = await fetch(url);
if (!response.ok) throw new Error(`Download failed: ${response.status} ${response.statusText}`);
const buffer = Buffer.from(await response.arrayBuffer());
writeFileSync(zipPath, buffer);
console.log(` Downloaded ${(buffer.length / 1024 / 1024).toFixed(1)}MB`);
}
console.log(` Extracting to ${targetDir}...`);
const { execSync } = await import('child_process');
execSync(`unzip -o -j "${zipPath}" -d "${targetDir}"`, { stdio: 'pipe' });
writeFileSync(resolve(targetDir, '.done'), 'ok');
}
async function importDataset(datasetDir: string, label: string) {
console.log(`\nProcessing ${label}...`);
// Read category mapping
const categoryRows = await readCSV(resolve(datasetDir, 'food_category.csv'));
const categoryMap = new Map<string, string>();
for (const row of categoryRows) {
categoryMap.set(row['id'], row['description']);
}
// Read foods
const foodRows = await readCSV(resolve(datasetDir, 'food.csv'));
const foods = new Map<number, RawFood>();
for (const row of foodRows) {
const catId = parseInt(row['food_category_id'] || '0');
const category = categoryMap.get(row['food_category_id']) || '';
if (!INCLUDED_CATEGORIES.has(category)) continue;
const fdcId = parseInt(row['fdc_id']);
foods.set(fdcId, {
fdcId,
description: row['description'],
categoryId: catId,
category,
});
}
console.log(` Found ${foods.size} foods in included categories`);
// Read nutrients
const nutrientRows = await readCSV(resolve(datasetDir, 'food_nutrient.csv'));
const nutrients = new Map<number, NutrientData>();
for (const row of nutrientRows) {
const fdcId = parseInt(row['fdc_id']);
if (!foods.has(fdcId)) continue;
const nutrientId = parseInt(row['nutrient_id']);
const fieldName = NUTRIENT_IDS[nutrientId];
if (!fieldName) continue;
if (!nutrients.has(fdcId)) nutrients.set(fdcId, {});
const amount = parseFloat(row['amount'] || '0');
if (!isNaN(amount)) {
nutrients.get(fdcId)![fieldName] = amount;
}
}
console.log(` Loaded nutrients for ${nutrients.size} foods`);
// Read portions
const portionRows = await readCSV(resolve(datasetDir, 'food_portion.csv'));
const portions = new Map<number, Portion[]>();
for (const row of portionRows) {
const fdcId = parseInt(row['fdc_id']);
if (!foods.has(fdcId)) continue;
const gramWeight = parseFloat(row['gram_weight'] || '0');
if (!gramWeight || isNaN(gramWeight)) continue;
// Build description from amount + modifier/description
const amount = parseFloat(row['amount'] || '1');
const modifier = row['modifier'] || row['portion_description'] || '';
const desc = modifier
? (amount !== 1 ? `${amount} ${modifier}` : modifier)
: `${amount} unit`;
if (!portions.has(fdcId)) portions.set(fdcId, []);
portions.get(fdcId)!.push({ description: desc, grams: Math.round(gramWeight * 100) / 100 });
}
console.log(` Loaded portions for ${portions.size} foods`);
return { foods, nutrients, portions };
}
function buildNutrientRecord(data: NutrientData | undefined): Record<string, number> {
const allFields = Object.values(NUTRIENT_IDS);
const result: Record<string, number> = {};
for (const field of allFields) {
result[field] = Math.round((data?.[field] || 0) * 100) / 100;
}
return result;
}
async function main() {
console.log('=== USDA Nutrition Database Import ===\n');
mkdirSync(DATA_DIR, { recursive: true });
// Download and extract datasets
const srDir = resolve(DATA_DIR, 'sr_legacy');
const foundationDir = resolve(DATA_DIR, 'foundation');
await downloadAndExtract(USDA_URLS.srLegacy, srDir);
await downloadAndExtract(USDA_URLS.foundation, foundationDir);
// Import both datasets
const sr = await importDataset(srDir, 'SR Legacy');
const foundation = await importDataset(foundationDir, 'Foundation Foods');
// Merge: Foundation Foods takes priority (more detailed), SR Legacy fills gaps
const merged = new Map<string, {
fdcId: number;
name: string;
category: string;
per100g: Record<string, number>;
portions: Portion[];
}>();
// Add SR Legacy first
for (const [fdcId, food] of sr.foods) {
const nutrientData = buildNutrientRecord(sr.nutrients.get(fdcId));
// Skip entries with no nutrient data at all
if (!sr.nutrients.has(fdcId)) continue;
merged.set(food.description.toLowerCase(), {
fdcId,
name: food.description,
category: food.category,
per100g: nutrientData,
portions: sr.portions.get(fdcId) || [],
});
}
// Override with Foundation Foods where available
for (const [fdcId, food] of foundation.foods) {
const nutrientData = buildNutrientRecord(foundation.nutrients.get(fdcId));
if (!foundation.nutrients.has(fdcId)) continue;
merged.set(food.description.toLowerCase(), {
fdcId,
name: food.description,
category: food.category,
per100g: nutrientData,
portions: foundation.portions.get(fdcId) || [],
});
}
console.log(`\nMerged total: ${merged.size} unique foods`);
// Sort by name for stable output
const entries = [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
// Generate TypeScript output
const tsContent = `// Auto-generated from USDA FoodData Central (SR Legacy + Foundation Foods)
// Generated: ${new Date().toISOString().split('T')[0]}
// Do not edit manually — regenerate with: pnpm exec vite-node scripts/import-usda-nutrition.ts
import type { NutritionPer100g } from '$types/types';
export type NutritionEntry = {
fdcId: number;
name: string;
category: string;
per100g: NutritionPer100g;
portions: { description: string; grams: number }[];
};
export const NUTRITION_DB: NutritionEntry[] = ${JSON.stringify(entries, null, '\t')};
`;
writeFileSync(OUTPUT_PATH, tsContent, 'utf-8');
console.log(`\nWritten ${entries.length} entries to ${OUTPUT_PATH}`);
// Print category breakdown
const categoryCounts = new Map<string, number>();
for (const entry of entries) {
categoryCounts.set(entry.category, (categoryCounts.get(entry.category) || 0) + 1);
}
console.log('\nCategory breakdown:');
for (const [cat, count] of [...categoryCounts.entries()].sort((a, b) => b[1] - a[1])) {
console.log(` ${cat}: ${count}`);
}
}
main().catch(err => {
console.error('Import failed:', err);
process.exit(1);
});

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
let { checked = $bindable(false), label = "", accentColor = "var(--color-primary)", href = undefined as string | undefined } = $props<{ checked?: boolean, label?: string, accentColor?: string, href?: string }>(); let { checked = $bindable(false), label = "", accentColor = "var(--color-primary)", href = undefined as string | undefined, onchange = undefined as (() => void) | undefined } = $props<{ checked?: boolean, label?: string, accentColor?: string, href?: string, onchange?: () => void }>();
</script> </script>
<style> <style>
@@ -96,7 +96,7 @@
</a> </a>
{:else} {:else}
<label> <label>
<input type="checkbox" bind:checked /> <input type="checkbox" bind:checked onchange={onchange} />
<span>{label}</span> <span>{label}</span>
</label> </label>
{/if} {/if}

View File

@@ -4,6 +4,7 @@ import { onNavigate } from "$app/navigation";
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { page } from '$app/stores'; import { page } from '$app/stores';
import HefeSwapper from './HefeSwapper.svelte'; import HefeSwapper from './HefeSwapper.svelte';
import NutritionSummary from './NutritionSummary.svelte';
let { data } = $props(); let { data } = $props();
// Helper function to multiply numbers in ingredient amounts // Helper function to multiply numbers in ingredient amounts
@@ -365,6 +366,44 @@ function adjust_amount(string, multiplier){
} }
// Collect section names for nutrition dedup (skip ingredients matching another section's name)
const nutritionSectionNames = $derived.by(() => {
if (!data.ingredients) return new Set();
const names = new Set();
for (const section of data.ingredients) {
if (section.name) {
const stripped = section.name.replace(/<[^>]*>/g, '').toLowerCase().trim();
if (stripped) names.add(stripped);
}
}
return names;
});
// Build flat ingredient list with section/ingredient indices for nutrition calculator
const nutritionFlatIngredients = $derived.by(() => {
if (!data.ingredients) return [];
/** @type {{ name: string; unit: string; amount: string; sectionIndex: number; ingredientIndex: number; sectionName: string }[]} */
const flat = [];
for (let si = 0; si < data.ingredients.length; si++) {
const section = data.ingredients[si];
if (section.type === 'reference') continue;
if (!section.list) continue;
const sectionName = (section.name || '').replace(/<[^>]*>/g, '').toLowerCase().trim();
for (let ii = 0; ii < section.list.length; ii++) {
const item = section.list[ii];
flat.push({
name: item.name,
unit: item.unit || '',
amount: item.amount || '',
sectionIndex: si,
ingredientIndex: ii,
sectionName,
});
}
}
return flat;
});
// No need for complex yeast toggle handling - everything is calculated server-side now // No need for complex yeast toggle handling - everything is calculated server-side now
</script> </script>
<style> <style>
@@ -587,5 +626,15 @@ function adjust_amount(string, multiplier){
</div> </div>
{/if} {/if}
{/each} {/each}
<NutritionSummary
flatIngredients={nutritionFlatIngredients}
nutritionMappings={data.nutritionMappings}
sectionNames={nutritionSectionNames}
referencedNutrition={data.referencedNutrition || []}
{multiplier}
portions={data.portions}
isEnglish={isEnglish}
/>
</div> </div>
{/if} {/if}

View File

@@ -0,0 +1,295 @@
<script>
import { createNutritionCalculator } from '$lib/js/nutrition.svelte';
let { flatIngredients, nutritionMappings, sectionNames, referencedNutrition, multiplier, portions, isEnglish } = $props();
const nutrition = createNutritionCalculator(
() => flatIngredients,
() => nutritionMappings || [],
() => multiplier,
() => sectionNames || new Set(),
() => referencedNutrition || [],
);
let showDetails = $state(false);
const portionCount = $derived.by(() => {
if (!portions) return 0;
const match = portions.match(/^(\d+(?:[.,]\d+)?)/);
return match ? parseFloat(match[1].replace(',', '.')) : 0;
});
const adjustedPortionCount = $derived(portionCount > 0 ? portionCount * multiplier : 0);
// Divisor for per-portion values (1 if no portions → show total)
const div = $derived(adjustedPortionCount > 0 ? adjustedPortionCount : 1);
const perPortionCalories = $derived(adjustedPortionCount > 0 ? nutrition.totalMacros.calories / adjustedPortionCount : 0);
// Macro percentages by calories: protein=4kcal/g, fat=9kcal/g, carbs=4kcal/g
const macroPercent = $derived.by(() => {
const m = nutrition.totalMacros;
const proteinCal = m.protein * 4;
const fatCal = m.fat * 9;
const carbsCal = m.carbs * 4;
const total = proteinCal + fatCal + carbsCal;
if (total === 0) return { protein: 0, fat: 0, carbs: 0 };
return {
protein: Math.round(proteinCal / total * 100),
fat: Math.round(fatCal / total * 100),
carbs: 100 - Math.round(proteinCal / total * 100) - Math.round(fatCal / total * 100),
};
});
const labels = $derived({
title: isEnglish ? 'Nutrition' : 'Nährwerte',
perPortion: isEnglish ? 'per portion' : 'pro Portion',
protein: isEnglish ? 'Protein' : 'Eiweiß',
fat: isEnglish ? 'Fat' : 'Fett',
carbs: isEnglish ? 'Carbs' : 'Kohlenh.',
fiber: isEnglish ? 'Fiber' : 'Ballaststoffe',
sugars: isEnglish ? 'Sugars' : 'Zucker',
saturatedFat: isEnglish ? 'Sat. Fat' : 'Ges. Fett',
details: isEnglish ? 'Details' : 'Details',
vitamins: isEnglish ? 'Vitamins' : 'Vitamine',
minerals: isEnglish ? 'Minerals' : 'Mineralstoffe',
coverage: isEnglish ? 'coverage' : 'Abdeckung',
unmapped: isEnglish ? 'Not tracked' : 'Nicht erfasst',
aminoAcids: isEnglish ? 'Amino Acids' : 'Aminosäuren',
});
const hasAminoAcids = $derived.by(() => {
const aa = nutrition.totalAminoAcids;
return aa.leucine > 0 || aa.lysine > 0 || aa.isoleucine > 0;
});
/** @param {number} value */
function fmt(value) {
if (value >= 100) return Math.round(value).toString();
if (value >= 10) return value.toFixed(1);
return value.toFixed(1);
}
// SVG arc parameters — 300° arc with 60° gap at bottom
const RADIUS = 28;
const ARC_DEGREES = 300;
const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
// Arc starts at the left side: rotate so the gap is centered at the bottom
// 0° in SVG circle = 3 o'clock. We want the arc to start at ~210° (7 o'clock)
// and end at ~150° (5 o'clock), leaving a 60° gap at bottom center.
const ARC_ROTATE = 120; // rotate the starting point: -90 (top) + 210 offset → start at left
/** @param {number} percent */
function strokeOffset(percent) {
return ARC_LENGTH - (percent / 100) * ARC_LENGTH;
}
</script>
<style>
.nutrition-summary {
margin-top: 1.5rem;
}
.portion-cal {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary, #666);
margin: 0.25rem 0;
}
.macro-rings {
display: flex;
justify-content: space-around;
margin: 0.5rem 0;
}
.macro-ring {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
}
.ring-bg {
fill: none;
stroke: var(--color-border, #e5e5e5);
stroke-width: 5;
stroke-linecap: round;
}
.ring-fill {
fill: none;
stroke-width: 5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.ring-text {
font-size: 14px;
font-weight: 700;
fill: currentColor;
text-anchor: middle;
dominant-baseline: central;
}
.ring-protein { stroke: var(--nord14, #a3be8c); }
.ring-fat { stroke: var(--nord12, #d08770); }
.ring-carbs { stroke: var(--nord9, #81a1c1); }
.macro-label {
font-size: 0.85rem;
font-weight: 600;
text-align: center;
}
.details-toggle-row {
text-align: center;
margin-top: 0.5rem;
}
.details-toggle {
font-size: 0.85rem;
cursor: pointer;
color: var(--color-primary);
background: none;
border: none;
padding: 0;
text-decoration: underline;
text-decoration-style: dotted;
}
.details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.detail-section h4 {
margin: 0 0 0.35rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.15rem 0;
border-bottom: 1px solid var(--color-border, #e5e5e5);
}
.detail-row:last-child {
border-bottom: none;
}
.coverage-warning {
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--nord13, #ebcb8b);
}
@media (max-width: 500px) {
.details-grid {
grid-template-columns: 1fr;
}
}
</style>
{#if nutritionMappings && nutritionMappings.length > 0}
<div class="nutrition-summary">
<div class="macro-rings">
{#each [
{ pct: macroPercent.protein, label: labels.protein, cls: 'ring-protein' },
{ pct: macroPercent.fat, label: labels.fat, cls: 'ring-fat' },
{ pct: macroPercent.carbs, label: labels.carbs, cls: 'ring-carbs' },
] as macro}
<div class="macro-ring">
<svg width="90" height="90" viewBox="0 0 70 70">
<circle
class="ring-bg"
cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
transform="rotate({ARC_ROTATE} 35 35)"
/>
<circle
class="ring-fill {macro.cls}"
cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
stroke-dashoffset={strokeOffset(macro.pct)}
transform="rotate({ARC_ROTATE} 35 35)"
/>
<text class="ring-text" x="35" y="35">{macro.pct}%</text>
</svg>
<span class="macro-label">{macro.label}</span>
</div>
{/each}
</div>
{#if adjustedPortionCount > 0}
<p class="portion-cal">{fmt(perPortionCalories)} kcal {labels.perPortion}</p>
{/if}
{#if showDetails}
<div class="details-grid">
<div class="detail-section">
<h4>{labels.title} {adjustedPortionCount > 0 ? `(${labels.perPortion})` : ''}</h4>
<div class="detail-row"><span>{labels.protein}</span><span>{fmt(nutrition.totalMacros.protein / div)}g</span></div>
<div class="detail-row"><span>{labels.fat}</span><span>{fmt(nutrition.totalMacros.fat / div)}g</span></div>
<div class="detail-row"><span>&nbsp;&nbsp;{labels.saturatedFat}</span><span>{fmt(nutrition.totalMacros.saturatedFat / div)}g</span></div>
<div class="detail-row"><span>{labels.carbs}</span><span>{fmt(nutrition.totalMacros.carbs / div)}g</span></div>
<div class="detail-row"><span>&nbsp;&nbsp;{labels.sugars}</span><span>{fmt(nutrition.totalMacros.sugars / div)}g</span></div>
<div class="detail-row"><span>{labels.fiber}</span><span>{fmt(nutrition.totalMacros.fiber / div)}g</span></div>
</div>
<div class="detail-section">
<h4>{labels.vitamins}</h4>
<div class="detail-row"><span>Vitamin A</span><span>{fmt(nutrition.totalMicros.vitaminA / div)} mcg</span></div>
<div class="detail-row"><span>Vitamin C</span><span>{fmt(nutrition.totalMicros.vitaminC / div)} mg</span></div>
<div class="detail-row"><span>Vitamin D</span><span>{fmt(nutrition.totalMicros.vitaminD / div)} mcg</span></div>
<div class="detail-row"><span>Vitamin E</span><span>{fmt(nutrition.totalMicros.vitaminE / div)} mg</span></div>
<div class="detail-row"><span>Vitamin K</span><span>{fmt(nutrition.totalMicros.vitaminK / div)} mcg</span></div>
<div class="detail-row"><span>Vitamin B12</span><span>{fmt(nutrition.totalMicros.vitaminB12 / div)} mcg</span></div>
<div class="detail-row"><span>Folate</span><span>{fmt(nutrition.totalMicros.folate / div)} mcg</span></div>
</div>
<div class="detail-section">
<h4>{labels.minerals}</h4>
<div class="detail-row"><span>Calcium</span><span>{fmt(nutrition.totalMicros.calcium / div)} mg</span></div>
<div class="detail-row"><span>{isEnglish ? 'Iron' : 'Eisen'}</span><span>{fmt(nutrition.totalMicros.iron / div)} mg</span></div>
<div class="detail-row"><span>Magnesium</span><span>{fmt(nutrition.totalMicros.magnesium / div)} mg</span></div>
<div class="detail-row"><span>Potassium</span><span>{fmt(nutrition.totalMicros.potassium / div)} mg</span></div>
<div class="detail-row"><span>Sodium</span><span>{fmt(nutrition.totalMicros.sodium / div)} mg</span></div>
<div class="detail-row"><span>{isEnglish ? 'Zinc' : 'Zink'}</span><span>{fmt(nutrition.totalMicros.zinc / div)} mg</span></div>
</div>
{#if hasAminoAcids}
<div class="detail-section">
<h4>{labels.aminoAcids}</h4>
<div class="detail-row"><span>{isEnglish ? 'Leucine' : 'Leucin'}</span><span>{fmt(nutrition.totalAminoAcids.leucine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Isoleucine' : 'Isoleucin'}</span><span>{fmt(nutrition.totalAminoAcids.isoleucine / div)} g</span></div>
<div class="detail-row"><span>Valin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.valine / div)} g</span></div>
<div class="detail-row"><span>Lysin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.lysine / div)} g</span></div>
<div class="detail-row"><span>Methionin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.methionine / div)} g</span></div>
<div class="detail-row"><span>Phenylalanin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.phenylalanine / div)} g</span></div>
<div class="detail-row"><span>Threonin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.threonine / div)} g</span></div>
<div class="detail-row"><span>Tryptophan</span><span>{fmt(nutrition.totalAminoAcids.tryptophan / div)} g</span></div>
<div class="detail-row"><span>Histidin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.histidine / div)} g</span></div>
<div class="detail-row"><span>Arginin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.arginine / div)} g</span></div>
<div class="detail-row"><span>Alanin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.alanine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Aspartic Acid' : 'Asparaginsäure'}</span><span>{fmt(nutrition.totalAminoAcids.asparticAcid / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Cysteine' : 'Cystein'}</span><span>{fmt(nutrition.totalAminoAcids.cysteine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Glutamic Acid' : 'Glutaminsäure'}</span><span>{fmt(nutrition.totalAminoAcids.glutamicAcid / div)} g</span></div>
<div class="detail-row"><span>Glycin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.glycine / div)} g</span></div>
<div class="detail-row"><span>Prolin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.proline / div)} g</span></div>
<div class="detail-row"><span>Serin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.serine / div)} g</span></div>
<div class="detail-row"><span>Tyrosin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.tyrosine / div)} g</span></div>
</div>
{/if}
</div>
{/if}
{#if nutrition.coverage < 1}
<div class="coverage-warning">
{Math.round(nutrition.coverage * 100)}% {labels.coverage}
{#if nutrition.unmapped.length > 0}
{labels.unmapped}: {nutrition.unmapped.join(', ')}
{/if}
</div>
{/if}
<div class="details-toggle-row">
<button class="details-toggle" onclick={() => showDetails = !showDetails}>
{showDetails ? '' : '+'} {labels.details}
</button>
</div>
</div>
{/if}

View File

@@ -14,7 +14,6 @@
onapproved?: (event: CustomEvent) => void; onapproved?: (event: CustomEvent) => void;
onskipped?: () => void; onskipped?: () => void;
oncancelled?: () => void; oncancelled?: () => void;
onforceFullRetranslation?: () => void;
} }
let { let {
@@ -26,7 +25,6 @@
onapproved, onapproved,
onskipped, onskipped,
oncancelled, oncancelled,
onforceFullRetranslation
}: Props = $props(); }: Props = $props();
type TranslationState = 'idle' | 'translating' | 'preview' | 'approved' | 'error'; type TranslationState = 'idle' | 'translating' | 'preview' | 'approved' | 'error';
@@ -204,7 +202,7 @@
}); });
// Handle auto-translate button click // Handle auto-translate button click
async function handleAutoTranslate() { async function handleAutoTranslate(fullRetranslation = false) {
translationState = 'translating'; translationState = 'translating';
errorMessage = ''; errorMessage = '';
validationErrors = []; validationErrors = [];
@@ -217,7 +215,7 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
recipe: germanData, recipe: germanData,
fields: isEditMode && changedFields.length > 0 ? changedFields : undefined, fields: isEditMode && !fullRetranslation && changedFields.length > 0 ? changedFields : undefined,
oldRecipe: oldRecipeData, // For granular item-level change detection oldRecipe: oldRecipeData, // For granular item-level change detection
existingTranslation: englishData, // To merge with unchanged items existingTranslation: englishData, // To merge with unchanged items
}), }),
@@ -358,11 +356,6 @@
oncancelled?.(); oncancelled?.();
} }
// Handle force full retranslation
function handleForceFullRetranslation() {
onforceFullRetranslation?.();
}
// Get status badge color // Get status badge color
function getStatusColor(status: string): string { function getStatusColor(status: string): string {
switch (status) { switch (status) {
@@ -921,10 +914,10 @@ button:disabled {
<button class="btn-danger" onclick={handleCancel}> <button class="btn-danger" onclick={handleCancel}>
Cancel Cancel
</button> </button>
<button class="btn-secondary" onclick={handleForceFullRetranslation}> <button class="btn-secondary" onclick={() => handleAutoTranslate(true)}>
Vollständig neu übersetzen Vollständig neu übersetzen
</button> </button>
<button class="btn-secondary" onclick={handleAutoTranslate}> <button class="btn-secondary" onclick={() => handleAutoTranslate()}>
Re-translate Re-translate
</button> </button>
<button class="btn-primary" onclick={handleApprove}> <button class="btn-primary" onclick={handleApprove}>

15
src/lib/data/blsDb.ts Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,93 @@
/**
* Default amounts for ingredients listed without an explicit amount in a recipe.
* E.g., just "salt" with no quantity defaults to 1 pinch.
*
* Resolution order:
* 1. Exact match on normalized ingredient name
* 2. Category-based fallback (keyed by USDA food category prefix with '_')
* 3. Mark as unmapped
*/
export type DefaultAmount = { amount: number; unit: string };
/** Exact-match defaults for common ingredients listed without amounts */
export const DEFAULT_AMOUNTS: Record<string, DefaultAmount> = {
// Salt & pepper
'salt': { amount: 1, unit: 'pinch' },
'pepper': { amount: 1, unit: 'pinch' },
'black pepper': { amount: 1, unit: 'pinch' },
'white pepper': { amount: 1, unit: 'pinch' },
'sea salt': { amount: 1, unit: 'pinch' },
// Common spices
'cinnamon': { amount: 0.5, unit: 'tsp' },
'nutmeg': { amount: 0.25, unit: 'tsp' },
'paprika': { amount: 0.5, unit: 'tsp' },
'cumin': { amount: 0.5, unit: 'tsp' },
'turmeric': { amount: 0.25, unit: 'tsp' },
'chili flakes': { amount: 0.25, unit: 'tsp' },
'cayenne pepper': { amount: 0.25, unit: 'tsp' },
'garlic powder': { amount: 0.5, unit: 'tsp' },
'onion powder': { amount: 0.5, unit: 'tsp' },
'oregano': { amount: 0.5, unit: 'tsp' },
'thyme': { amount: 0.5, unit: 'tsp' },
'rosemary': { amount: 0.5, unit: 'tsp' },
// Fresh herbs
'parsley': { amount: 1, unit: 'tbsp' },
'basil': { amount: 1, unit: 'tbsp' },
'cilantro': { amount: 1, unit: 'tbsp' },
'coriander': { amount: 1, unit: 'tbsp' },
'dill': { amount: 1, unit: 'tbsp' },
'chives': { amount: 1, unit: 'tbsp' },
'mint': { amount: 1, unit: 'tbsp' },
// Oils/fats (when listed for greasing)
'oil': { amount: 1, unit: 'tbsp' },
'olive oil': { amount: 1, unit: 'tbsp' },
'vegetable oil': { amount: 1, unit: 'tbsp' },
'butter': { amount: 1, unit: 'tbsp' },
// Liquids
'water': { amount: 0, unit: 'ml' }, // excluded from calorie calc
'vanilla extract': { amount: 1, unit: 'tsp' },
'lemon juice': { amount: 1, unit: 'tbsp' },
'vinegar': { amount: 1, unit: 'tbsp' },
'soy sauce': { amount: 1, unit: 'tbsp' },
};
/** Category-based fallbacks when no exact match is found.
* Keyed with '_' prefix to distinguish from ingredient names. */
export const CATEGORY_FALLBACKS: Record<string, DefaultAmount> = {
'_Spices and Herbs': { amount: 0.5, unit: 'tsp' },
'_Fats and Oils': { amount: 1, unit: 'tbsp' },
'_Beverages': { amount: 100, unit: 'ml' },
};
/**
* Resolve a default amount for an ingredient that has no amount specified.
* Returns the default amount and unit, or null if no default is available.
*/
export function resolveDefaultAmount(
normalizedName: string,
usdaCategory?: string
): DefaultAmount | null {
// Exact match
if (DEFAULT_AMOUNTS[normalizedName]) {
return DEFAULT_AMOUNTS[normalizedName];
}
// Partial match: check if any key is contained in the name or vice versa
for (const [key, value] of Object.entries(DEFAULT_AMOUNTS)) {
if (normalizedName.includes(key) || key.includes(normalizedName)) {
return value;
}
}
// Category fallback
if (usdaCategory && CATEGORY_FALLBACKS[`_${usdaCategory}`]) {
return CATEGORY_FALLBACKS[`_${usdaCategory}`];
}
return null;
}

View File

@@ -0,0 +1,247 @@
/**
* Fast-path alias table mapping normalized English ingredient names to USDA nutrition DB names.
* Checked before the embedding-based matching to avoid unnecessary ML inference for common ingredients.
*
* Keys: normalized lowercase ingredient names (stripped of modifiers)
* Values: exact USDA FDC name as it appears in nutritionDb.ts
*
* Expand this table over time — run scripts/generate-ingredient-aliases.ts to suggest new entries.
*/
export const INGREDIENT_ALIASES: Record<string, string> = {
// Dairy & Eggs
'egg': 'Egg, whole, raw, fresh',
'eggs': 'Egg, whole, raw, fresh',
'egg yolk': 'Egg, yolk, raw, fresh',
'egg yolks': 'Egg, yolk, raw, fresh',
'egg white': 'Egg, white, raw, fresh',
'egg whites': 'Egg, white, raw, fresh',
'butter': 'Butter, salted',
'unsalted butter': 'Butter, without salt',
'milk': 'Milk, whole, 3.25% milkfat, with added vitamin D',
'whole milk': 'Milk, whole, 3.25% milkfat, with added vitamin D',
'cream': 'Cream, fluid, heavy whipping',
'heavy cream': 'Cream, fluid, heavy whipping',
'whipping cream': 'Cream, fluid, heavy whipping',
'sour cream': 'Cream, sour, cultured',
'cream cheese': 'Cheese, cream',
'parmesan': 'Cheese, parmesan, hard',
'mozzarella': 'Cheese, mozzarella, whole milk',
'cheddar': 'Cheese, cheddar',
'yogurt': 'Yogurt, plain, whole milk',
'greek yogurt': 'Yogurt, Greek, plain, whole milk',
// Fats & Oils
'olive oil': 'Oil, olive, salad or cooking',
'vegetable oil': 'Oil, canola',
'canola oil': 'Oil, canola',
'neutral oil': 'Oil, peanut, salad or cooking',
'peanut oil': 'Oil, peanut, salad or cooking',
'sunflower oil': 'Oil, sunflower, linoleic, (approx. 65%)',
'coconut oil': 'Oil, coconut',
'sesame oil': 'Oil, sesame, salad or cooking',
'lard': 'Lard',
'margarine': 'Margarine, regular, 80% fat, composite, stick, with salt',
// Flours & Grains
'flour': 'Wheat flour, white, all-purpose, enriched, bleached',
'all purpose flour': 'Wheat flour, white, all-purpose, enriched, bleached',
'all-purpose flour': 'Wheat flour, white, all-purpose, enriched, bleached',
'bread flour': 'Wheat flour, white, bread, enriched',
'whole wheat flour': 'Flour, whole wheat, unenriched',
'rye flour': 'Rye flour, dark',
'cornstarch': 'Cornstarch',
'corn starch': 'Cornstarch',
'rice': 'Rice, white, long-grain, regular, raw, enriched',
'white rice': 'Rice, white, long-grain, regular, raw, enriched',
'brown rice': 'Rice, brown, long-grain, raw (Includes foods for USDA\'s Food Distribution Program)',
'pasta': 'Pasta, dry, enriched',
'spaghetti': 'Pasta, dry, enriched',
'noodles': 'Noodles, egg, dry, enriched',
'oats': 'Oats (Includes foods for USDA\'s Food Distribution Program)',
'rolled oats': 'Oats (Includes foods for USDA\'s Food Distribution Program)',
'breadcrumbs': 'Bread, crumbs, dry, grated, plain',
// Sugars & Sweeteners
'sugar': 'Sugars, granulated',
'white sugar': 'Sugars, granulated',
'granulated sugar': 'Sugars, granulated',
'brown sugar': 'Sugars, brown',
'powdered sugar': 'Sugars, powdered',
'icing sugar': 'Sugars, powdered',
'honey': 'Honey',
'maple syrup': 'Syrups, maple',
'molasses': 'Molasses',
'vanilla sugar': 'Sugars, granulated', // approximate
// Leavening
'baking powder': 'Leavening agents, baking powder, double-acting, sodium aluminum sulfate',
'baking soda': 'Leavening agents, baking soda',
'bicarbonate of soda': 'Leavening agents, baking soda',
'yeast': 'Leavening agents, yeast, baker\'s, active dry',
'dry yeast': 'Leavening agents, yeast, baker\'s, active dry',
'fresh yeast': 'Leavening agents, yeast, baker\'s, compressed',
// Vegetables
'onion': 'Onions, raw',
'onions': 'Onions, raw',
'garlic': 'Garlic, raw',
'potato': 'Potatoes, flesh and skin, raw',
'potatoes': 'Potatoes, flesh and skin, raw',
'carrot': 'Carrots, raw',
'carrots': 'Carrots, raw',
'tomato': 'Tomatoes, red, ripe, raw, year round average',
'tomatoes': 'Tomatoes, red, ripe, raw, year round average',
'bell pepper': 'Peppers, sweet, red, raw',
'red bell pepper': 'Peppers, sweet, red, raw',
'green bell pepper': 'Peppers, sweet, green, raw',
'celery': 'Celery, cooked, boiled, drained, without salt',
'spinach': 'Spinach, raw',
'broccoli': 'Broccoli, raw',
'cauliflower': 'Cauliflower, raw',
'zucchini': 'Squash, summer, zucchini, includes skin, raw',
'courgette': 'Squash, summer, zucchini, includes skin, raw',
'cucumber': 'Cucumber, with peel, raw',
'lettuce': 'Lettuce, green leaf, raw',
'cabbage': 'Cabbage, common (danish, domestic, and pointed types), freshly harvest, raw',
'mushrooms': 'Mushrooms, white, raw',
'mushroom': 'Mushrooms, white, raw',
'leek': 'Leeks, (bulb and lower leaf-portion), raw',
'leeks': 'Leeks, (bulb and lower leaf-portion), raw',
'peas': 'Peas, green, raw',
'corn': 'Corn, sweet, yellow, raw',
'sweet corn': 'Corn, sweet, yellow, raw',
'eggplant': 'Eggplant, raw',
'aubergine': 'Eggplant, raw',
'pumpkin': 'Pumpkin, raw',
'sweet potato': 'Sweet potato, raw, unprepared (Includes foods for USDA\'s Food Distribution Program)',
'ginger': 'Ginger root, raw',
'shallot': 'Shallots, raw',
'shallots': 'Shallots, raw',
// Fruits
'lemon': 'Lemons, raw, without peel',
'lemon juice': 'Lemon juice, raw',
'lemon zest': 'Lemon peel, raw',
'lime': 'Limes, raw',
'lime juice': 'Lime juice, raw',
'orange': 'Oranges, raw, navels',
'orange juice': 'Orange juice, raw (Includes foods for USDA\'s Food Distribution Program)',
'apple': 'Apples, raw, with skin (Includes foods for USDA\'s Food Distribution Program)',
'banana': 'Bananas, raw',
'berries': 'Blueberries, raw',
'blueberries': 'Blueberries, raw',
'strawberries': 'Strawberries, raw',
'raspberries': 'Raspberries, raw',
'raisins': 'Raisins, dark, seedless (Includes foods for USDA\'s Food Distribution Program)',
'dried cranberries': 'Cranberries, dried, sweetened (Includes foods for USDA\'s Food Distribution Program)',
// Nuts & Seeds
'almonds': 'Nuts, almonds',
'walnuts': 'Nuts, walnuts, english',
'hazelnuts': 'Nuts, hazelnuts or filberts',
'peanuts': 'Peanuts, all types, raw',
'pine nuts': 'Nuts, pine nuts, dried',
'cashews': 'Nuts, cashew nuts, raw',
'pecans': 'Nuts, pecans',
'sesame seeds': 'Seeds, sesame seeds, whole, dried',
'sunflower seeds': 'Seeds, sunflower seed kernels, dried',
'flaxseed': 'Seeds, flaxseed',
'pumpkin seeds': 'Seeds, pumpkin and squash seed kernels, dried',
'poppy seeds': 'Seeds, sesame seeds, whole, dried', // approximate
'almond flour': 'Nuts, almonds, blanched',
'ground almonds': 'Nuts, almonds, blanched',
'coconut flakes': 'Nuts, coconut meat, dried (desiccated), sweetened, shredded',
'desiccated coconut': 'Nuts, coconut meat, dried (desiccated), sweetened, shredded',
'peanut butter': 'Peanut butter, smooth style, with salt',
// Meats
'chicken breast': 'Chicken, broiler or fryers, breast, skinless, boneless, meat only, raw',
'chicken thigh': 'Chicken, broilers or fryers, dark meat, thigh, meat only, raw',
'ground beef': 'Beef, grass-fed, ground, raw',
'minced meat': 'Beef, grass-fed, ground, raw',
'bacon': 'Pork, cured, bacon, unprepared',
'ham': 'Ham, sliced, regular (approximately 11% fat)',
'sausage': 'Sausage, pork, chorizo, link or ground, raw',
// Seafood
'salmon': 'Fish, salmon, Atlantic, wild, raw',
'tuna': 'Fish, tuna, light, canned in water, drained solids',
'shrimp': 'Crustaceans, shrimp, raw',
'prawns': 'Crustaceans, shrimp, raw',
'cod': 'Fish, cod, Atlantic, raw',
// Legumes
'chickpeas': 'Chickpeas (garbanzo beans, bengal gram), mature seeds, raw',
'lentils': 'Lentils, raw',
'black beans': 'Beans, black, mature seeds, raw',
'kidney beans': 'Beans, kidney, red, mature seeds, raw',
'white beans': 'Beans, white, mature seeds, raw',
'canned chickpeas': 'Chickpeas (garbanzo beans, bengal gram), mature seeds, canned, drained solids',
'canned beans': 'Beans, kidney, red, mature seeds, canned, drained solids',
'tofu': 'Tofu, raw, firm, prepared with calcium sulfate',
// Condiments & Sauces
'soy sauce': 'Soy sauce made from soy (tamari)',
'vinegar': 'Vinegar, distilled',
'apple cider vinegar': 'Vinegar, cider',
'balsamic vinegar': 'Vinegar, balsamic',
'mustard': 'Mustard, prepared, yellow',
'ketchup': 'Catsup',
'tomato paste': 'Tomato products, canned, paste, without salt added (Includes foods for USDA\'s Food Distribution Program)',
'tomato sauce': 'Tomato products, canned, sauce',
'canned tomatoes': 'Tomatoes, red, ripe, canned, packed in tomato juice',
'worcestershire sauce': 'Sauce, worcestershire',
'hot sauce': 'Sauce, hot chile, sriracha',
'mayonnaise': 'Salad dressing, mayonnaise, regular',
// Chocolate & Baking
'chocolate': 'Chocolate, dark, 70-85% cacao solids',
'dark chocolate': 'Chocolate, dark, 70-85% cacao solids',
'cocoa powder': 'Cocoa, dry powder, unsweetened',
'cocoa': 'Cocoa, dry powder, unsweetened',
'chocolate chips': 'Chocolate, dark, 60-69% cacao solids',
'vanilla extract': 'Vanilla extract',
'vanilla': 'Vanilla extract',
'gelatin': 'Gelatin desserts, dry mix',
// Beverages
'coffee': 'Beverages, coffee, brewed, prepared with tap water',
'tea': 'Beverages, tea, black, brewed, prepared with tap water',
'wine': 'Alcoholic beverage, wine, table, red',
'red wine': 'Alcoholic beverage, wine, table, red',
'white wine': 'Alcoholic beverage, wine, table, white',
'beer': 'Alcoholic beverage, beer, regular, all',
'coconut milk': 'Nuts, coconut milk, raw (liquid expressed from grated meat and water)',
// Misc
'salt': 'Salt, table',
'pepper': 'Spices, pepper, black',
'black pepper': 'Spices, pepper, black',
'cinnamon': 'Spices, cinnamon, ground',
'paprika': 'Spices, paprika',
'cumin': 'Spices, cumin seed',
'nutmeg': 'Spices, nutmeg, ground',
'chili powder': 'Spices, chili powder',
'oregano': 'Spices, oregano, dried',
'thyme': 'Spices, thyme, dried',
'rosemary': 'Spices, rosemary, dried',
'bay leaf': 'Spices, bay leaf',
'turmeric': 'Spices, turmeric, ground',
'basil': 'Spices, basil, dried',
'parsley': 'Spices, parsley, dried',
'dill': 'Spices, dill weed, dried',
'mint': 'Spearmint, fresh',
'cloves': 'Spices, cloves, ground',
'cardamom': 'Spices, cardamom',
'ginger powder': 'Spices, ginger, ground',
'curry powder': 'Spices, curry powder',
};
/**
* Look up a normalized ingredient name in the alias table.
* Returns the USDA name if found, null otherwise.
*/
export function lookupAlias(normalizedName: string): string | null {
return INGREDIENT_ALIASES[normalizedName] || null;
}

717861
src/lib/data/nutritionDb.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,177 @@
/**
* Unit canonicalization and gram conversion tables for recipe nutrition calculation.
*
* German and English recipe units are mapped to canonical keys.
* Ingredient-specific conversions (e.g., 1 tbsp butter = 14.2g) are resolved
* via USDA portion data at matching time — this file only handles unit normalization
* and ingredient-independent conversions.
*/
/** Maps various unit strings (German + English) to a canonical unit key */
export const UNIT_CANONICAL: Record<string, string> = {
// German → canonical
'EL': 'tbsp',
'el': 'tbsp',
'Esslöffel': 'tbsp',
'TL': 'tsp',
'tl': 'tsp',
'Teelöffel': 'tsp',
'Prise': 'pinch',
'Prisen': 'pinch',
'Msp': 'pinch',
'Msp.': 'pinch',
'Messerspitze': 'pinch',
'Bund': 'bunch',
'Stück': 'piece',
'Stk': 'piece',
'Stk.': 'piece',
'Scheibe': 'slice',
'Scheiben': 'slice',
'Zehe': 'clove',
'Zehen': 'clove',
'Blatt': 'leaf',
'Blätter': 'leaf',
'Zweig': 'sprig',
'Zweige': 'sprig',
'Dose': 'can',
'Dosen': 'can',
'Becher': 'cup',
'Tasse': 'cup',
'Tassen': 'cup',
'Packung': 'package',
'Pkg': 'package',
'Pkg.': 'package',
'Würfel': 'cube',
// English → canonical (passthrough + normalization)
'tbsp': 'tbsp',
'Tbsp': 'tbsp',
'tablespoon': 'tbsp',
'tablespoons': 'tbsp',
'tsp': 'tsp',
'Tsp': 'tsp',
'teaspoon': 'tsp',
'teaspoons': 'tsp',
'cup': 'cup',
'cups': 'cup',
'pinch': 'pinch',
'piece': 'piece',
'pieces': 'piece',
'slice': 'slice',
'slices': 'slice',
'clove': 'clove',
'cloves': 'clove',
'sprig': 'sprig',
'sprigs': 'sprig',
'bunch': 'bunch',
'leaf': 'leaf',
'leaves': 'leaf',
'can': 'can',
'package': 'package',
'cube': 'cube',
// Weight/volume units (already canonical, but normalize variants)
'g': 'g',
'gr': 'g',
'gram': 'g',
'grams': 'g',
'Gramm': 'g',
'kg': 'kg',
'kilogram': 'kg',
'ml': 'ml',
'mL': 'ml',
'milliliter': 'ml',
'Milliliter': 'ml',
'l': 'l',
'L': 'l',
'liter': 'l',
'Liter': 'l',
'oz': 'oz',
'ounce': 'oz',
'ounces': 'oz',
'lb': 'lb',
'lbs': 'lb',
'pound': 'lb',
'pounds': 'lb',
};
/** Direct gram conversions for weight/volume units (ingredient-independent) */
export const UNIT_TO_GRAMS: Record<string, number> = {
'g': 1,
'kg': 1000,
'oz': 28.3495,
'lb': 453.592,
// Volume units use water density (1 ml = 1g) as base;
// adjusted by ingredient-specific density when available
'ml': 1,
'l': 1000,
};
/**
* Fallback gram estimates for common measurement units when no USDA portion data is available.
* These are rough averages across common ingredients.
*/
export const UNIT_GRAM_FALLBACKS: Record<string, number> = {
'tbsp': 15, // ~15ml, varies by ingredient density
'tsp': 5, // ~5ml
'cup': 240, // US cup = ~240ml
'pinch': 0.3,
'slice': 30,
'clove': 3, // garlic clove
'sprig': 2,
'bunch': 30,
'leaf': 0.5,
'cube': 25, // bouillon cube
'can': 400, // standard can
'package': 200,
};
/** Canonicalize a unit string. Returns the canonical key, or the original lowercased string if unknown. */
export function canonicalizeUnit(unit: string): string {
const trimmed = unit.trim();
return UNIT_CANONICAL[trimmed] || UNIT_CANONICAL[trimmed.toLowerCase()] || trimmed.toLowerCase();
}
/**
* Get the gram weight for a given canonical unit, using USDA portion data when available.
* Falls back to standard conversions and then generic estimates.
*/
export function resolveGramsPerUnit(
canonicalUnit: string,
usdaPortions: { description: string; grams: number }[],
density?: number
): { grams: number; source: 'direct' | 'density' | 'usda_portion' | 'estimate' | 'none' } {
// Direct weight conversion
if (canonicalUnit in UNIT_TO_GRAMS) {
const baseGrams = UNIT_TO_GRAMS[canonicalUnit];
// Apply density for volume units
if ((canonicalUnit === 'ml' || canonicalUnit === 'l') && density) {
return { grams: baseGrams * density, source: 'density' };
}
return { grams: baseGrams, source: 'direct' };
}
// Try to match against USDA portions
if (usdaPortions.length > 0) {
const unitLower = canonicalUnit.toLowerCase();
for (const portion of usdaPortions) {
const descLower = portion.description.toLowerCase();
// Match "1 tbsp", "tbsp", "tablespoon", etc.
if (descLower.includes(unitLower) ||
(unitLower === 'tbsp' && descLower.includes('tablespoon')) ||
(unitLower === 'tsp' && descLower.includes('teaspoon')) ||
(unitLower === 'cup' && descLower.includes('cup')) ||
(unitLower === 'piece' && (descLower.includes('unit') || descLower.includes('medium') || descLower.includes('whole'))) ||
(unitLower === 'slice' && descLower.includes('slice'))) {
return { grams: portion.grams, source: 'usda_portion' };
}
}
}
// Fallback estimates
if (canonicalUnit in UNIT_GRAM_FALLBACKS) {
return { grams: UNIT_GRAM_FALLBACKS[canonicalUnit], source: 'estimate' };
}
return { grams: 0, source: 'none' };
}

View File

@@ -0,0 +1,302 @@
/**
* Reactive nutrition calculator factory for recipe calorie/macro display.
* Uses Svelte 5 runes ($state/$derived) with the factory pattern.
*
* Import without .ts extension: import { createNutritionCalculator } from '$lib/js/nutrition.svelte'
*
* NOTE: Does NOT import the full NUTRITION_DB — all per100g data comes pre-resolved
* in the NutritionMapping objects from the API to keep client bundle small.
*/
import type { NutritionMapping } from '$types/types';
export type MacroTotals = {
calories: number;
protein: number;
fat: number;
saturatedFat: number;
carbs: number;
fiber: number;
sugars: number;
};
export type MicroTotals = {
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;
};
export type AminoAcidTotals = {
isoleucine: number;
leucine: number;
lysine: number;
methionine: number;
phenylalanine: number;
threonine: number;
tryptophan: number;
valine: number;
histidine: number;
alanine: number;
arginine: number;
asparticAcid: number;
cysteine: number;
glutamicAcid: number;
glycine: number;
proline: number;
serine: number;
tyrosine: number;
};
const AMINO_ACID_KEYS: (keyof AminoAcidTotals)[] = [
'isoleucine', 'leucine', 'lysine', 'methionine', 'phenylalanine',
'threonine', 'tryptophan', 'valine', 'histidine', 'alanine',
'arginine', 'asparticAcid', 'cysteine', 'glutamicAcid', 'glycine',
'proline', 'serine', 'tyrosine',
];
export type IngredientNutrition = {
name: string;
calories: number;
mapped: boolean;
};
/** Parse a recipe amount string to a number */
function parseAmount(amount: string): number {
if (!amount?.trim()) return 0;
let s = amount.trim();
const rangeMatch = s.match(/^(\d+(?:[.,]\d+)?)\s*[-]\s*(\d+(?:[.,]\d+)?)$/);
if (rangeMatch) {
return (parseFloat(rangeMatch[1].replace(',', '.')) + parseFloat(rangeMatch[2].replace(',', '.'))) / 2;
}
s = s.replace(',', '.');
const fractionMatch = s.match(/^(\d+)\s*\/\s*(\d+)$/);
if (fractionMatch) return parseInt(fractionMatch[1]) / parseInt(fractionMatch[2]);
const mixedMatch = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
if (mixedMatch) return parseInt(mixedMatch[1]) + parseInt(mixedMatch[2]) / parseInt(mixedMatch[3]);
const parsed = parseFloat(s);
return isNaN(parsed) ? 0 : parsed;
}
/** Calculate grams for a single ingredient */
function calculateGrams(
amount: string,
mapping: NutritionMapping,
multiplier: number
): number {
if (mapping.excluded || !mapping.gramsPerUnit) return 0;
const parsedAmount = parseAmount(amount) || (mapping.defaultAmountUsed ? 1 : 0);
return parsedAmount * multiplier * mapping.gramsPerUnit;
}
export type ReferencedNutrition = {
shortName: string;
name: string;
nutrition: Record<string, number>;
baseMultiplier: number;
};
/** Strip HTML tags from a string */
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '');
}
/**
* Create a reactive nutrition calculator.
*
* @param getFlatIngredients - getter for flattened ingredient list
* @param getMappings - getter for nutrition mappings (with per100g resolved by the API)
* @param getMultiplier - getter for the current recipe multiplier
* @param getSectionNames - getter for section names (for dedup)
* @param getReferencedNutrition - getter for pre-computed nutrition from referenced recipes
*/
export function createNutritionCalculator(
getFlatIngredients: () => { name: string; unit: string; amount: string; sectionIndex: number; ingredientIndex: number; sectionName?: string }[],
getMappings: () => NutritionMapping[],
getMultiplier: () => number,
getSectionNames?: () => Set<string>,
getReferencedNutrition?: () => ReferencedNutrition[],
) {
const mappingIndex = $derived(
new Map(getMappings().map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m]))
);
/** Check if ingredient should be skipped (name matches a different section's name) */
function isSkippedDuplicate(ing: { name: string; sectionName?: string }): boolean {
if (!getSectionNames) return false;
const names = getSectionNames();
const stripped = stripHtml(ing.name).toLowerCase().trim();
const ownSection = (ing.sectionName || '').toLowerCase().trim();
return stripped !== '' && names.has(stripped) && stripped !== ownSection;
}
/** Check if ingredient is an anchor-tag reference to another recipe */
function isAnchorRef(ing: { name: string }): boolean {
return /<a\s/i.test(ing.name);
}
/** Check if ingredient should be excluded from direct nutrition calculation */
function shouldSkip(ing: { name: string; sectionName?: string }): boolean {
return isSkippedDuplicate(ing) || isAnchorRef(ing);
}
const perIngredient = $derived(
getFlatIngredients().map(ing => {
if (shouldSkip(ing)) return { name: ing.name, calories: 0, mapped: true };
const mapping = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
if (!mapping || mapping.matchMethod === 'none' || mapping.excluded || !mapping.per100g) {
return { name: ing.name, calories: 0, mapped: false };
}
const grams = calculateGrams(ing.amount, mapping, getMultiplier());
const calories = (grams / 100) * mapping.per100g.calories;
return { name: ing.name, calories, mapped: true };
})
);
/** Add referenced recipe nutrition totals, scaled by multiplier and baseMultiplier */
function addReferencedNutrition(result: Record<string, number>, mult: number) {
if (!getReferencedNutrition) return;
for (const ref of getReferencedNutrition()) {
const scale = mult * ref.baseMultiplier;
for (const [key, value] of Object.entries(ref.nutrition)) {
if (key in result && typeof value === 'number') {
result[key] += value * scale;
}
}
}
}
const totalMacros = $derived.by(() => {
const result: MacroTotals = { calories: 0, protein: 0, fat: 0, saturatedFat: 0, carbs: 0, fiber: 0, sugars: 0 };
const ingredients = getFlatIngredients();
const mult = getMultiplier();
for (const ing of ingredients) {
if (shouldSkip(ing)) continue;
const mapping = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
if (!mapping || mapping.matchMethod === 'none' || mapping.excluded || !mapping.per100g) continue;
const factor = calculateGrams(ing.amount, mapping, mult) / 100;
result.calories += factor * mapping.per100g.calories;
result.protein += factor * mapping.per100g.protein;
result.fat += factor * mapping.per100g.fat;
result.saturatedFat += factor * mapping.per100g.saturatedFat;
result.carbs += factor * mapping.per100g.carbs;
result.fiber += factor * mapping.per100g.fiber;
result.sugars += factor * mapping.per100g.sugars;
}
addReferencedNutrition(result, mult);
return result;
});
const totalMicros = $derived.by(() => {
const result: MicroTotals = {
calcium: 0, iron: 0, magnesium: 0, phosphorus: 0,
potassium: 0, sodium: 0, zinc: 0,
vitaminA: 0, vitaminC: 0, vitaminD: 0, vitaminE: 0,
vitaminK: 0, thiamin: 0, riboflavin: 0, niacin: 0,
vitaminB6: 0, vitaminB12: 0, folate: 0, cholesterol: 0,
};
const ingredients = getFlatIngredients();
const mult = getMultiplier();
for (const ing of ingredients) {
if (shouldSkip(ing)) continue;
const mapping = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
if (!mapping || mapping.matchMethod === 'none' || mapping.excluded || !mapping.per100g) continue;
const factor = calculateGrams(ing.amount, mapping, mult) / 100;
for (const key of Object.keys(result) as (keyof MicroTotals)[]) {
result[key] += factor * ((mapping.per100g as any)[key] || 0);
}
}
addReferencedNutrition(result as Record<string, number>, mult);
return result;
});
const totalAminoAcids = $derived.by(() => {
const result: AminoAcidTotals = {
isoleucine: 0, leucine: 0, lysine: 0, methionine: 0, phenylalanine: 0,
threonine: 0, tryptophan: 0, valine: 0, histidine: 0, alanine: 0,
arginine: 0, asparticAcid: 0, cysteine: 0, glutamicAcid: 0, glycine: 0,
proline: 0, serine: 0, tyrosine: 0,
};
const ingredients = getFlatIngredients();
const mult = getMultiplier();
for (const ing of ingredients) {
if (shouldSkip(ing)) continue;
const mapping = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
if (!mapping || mapping.matchMethod === 'none' || mapping.excluded || !mapping.per100g) continue;
const factor = calculateGrams(ing.amount, mapping, mult) / 100;
for (const key of AMINO_ACID_KEYS) {
result[key] += factor * ((mapping.per100g as any)[key] || 0);
}
}
addReferencedNutrition(result as Record<string, number>, mult);
return result;
});
const coverage = $derived.by(() => {
const ingredients = getFlatIngredients();
if (ingredients.length === 0) return 1;
let total = 0;
let mapped = 0;
for (const ing of ingredients) {
// Skipped duplicates and anchor-tag refs count as covered
if (shouldSkip(ing)) { total++; mapped++; continue; }
total++;
const m = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
// Manually excluded ingredients count as covered
if (m?.excluded) { total++; mapped++; continue; }
if (m && m.matchMethod !== 'none') mapped++;
}
return total > 0 ? mapped / total : 1;
});
const unmapped = $derived(
getFlatIngredients()
.filter(ing => {
if (shouldSkip(ing)) return false;
const m = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
if (m?.excluded) return false;
return !m || m.matchMethod === 'none';
})
.map(ing => stripHtml(ing.name))
);
return {
get perIngredient() { return perIngredient; },
get totalMacros() { return totalMacros; },
get totalMicros() { return totalMicros; },
get totalAminoAcids() { return totalAminoAcids; },
get coverage() { return coverage; },
get unmapped() { return unmapped; },
};
}

View File

@@ -0,0 +1,816 @@
/**
* Dual-source embedding-based ingredient matching engine.
* Priority: global overwrite → alias → BLS (German, primary) → USDA (English, fallback) → none
*
* BLS uses multilingual-e5-small for German ingredient names.
* USDA uses all-MiniLM-L6-v2 for English ingredient names.
*/
import { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { NUTRITION_DB, type NutritionEntry } from '$lib/data/nutritionDb';
import { BLS_DB, type BlsEntry } from '$lib/data/blsDb';
import { lookupAlias } from '$lib/data/ingredientAliases';
import { canonicalizeUnit, resolveGramsPerUnit } from '$lib/data/unitConversions';
import { resolveDefaultAmount } from '$lib/data/defaultAmounts';
import type { NutritionMapping, NutritionPer100g } from '$types/types';
import { NutritionOverwrite } from '$models/NutritionOverwrite';
const USDA_MODEL = 'Xenova/all-MiniLM-L6-v2';
const BLS_MODEL = 'Xenova/multilingual-e5-small';
const USDA_EMBEDDINGS_PATH = resolve('src/lib/data/nutritionEmbeddings.json');
const BLS_EMBEDDINGS_PATH = resolve('src/lib/data/blsEmbeddings.json');
const CONFIDENCE_THRESHOLD = 0.45;
// Lazy-loaded singletons — USDA
let usdaEmbedder: FeatureExtractionPipeline | null = null;
let usdaEmbeddingIndex: { fdcId: number; name: string; vector: number[] }[] | null = null;
let nutritionByFdcId: Map<number, NutritionEntry> | null = null;
let nutritionByName: Map<string, NutritionEntry> | null = null;
// Lazy-loaded singletons — BLS
let blsEmbedder: FeatureExtractionPipeline | null = null;
let blsEmbeddingIndex: { blsCode: string; name: string; vector: number[] }[] | null = null;
let blsByCode: Map<string, BlsEntry> | null = null;
/** Modifiers to strip from ingredient names before matching */
const STRIP_MODIFIERS = [
'warm', 'cold', 'hot', 'room temperature', 'lukewarm',
'fresh', 'freshly', 'dried', 'dry',
'finely', 'coarsely', 'roughly', 'thinly',
'chopped', 'diced', 'minced', 'sliced', 'grated', 'shredded',
'crushed', 'ground', 'whole', 'halved', 'quartered',
'peeled', 'unpeeled', 'pitted', 'seeded', 'deseeded',
'melted', 'softened', 'frozen', 'thawed', 'chilled',
'toasted', 'roasted', 'blanched', 'boiled', 'steamed',
'sifted', 'packed', 'loosely packed', 'firmly packed',
'small', 'medium', 'large', 'extra-large',
'organic', 'free-range', 'grass-fed',
'optional', 'to taste', 'as needed', 'for garnish', 'for serving',
'about', 'approximately', 'roughly',
];
/** German modifiers to strip */
const STRIP_MODIFIERS_DE = [
'warm', 'kalt', 'heiß', 'lauwarm', 'zimmerwarm',
'frisch', 'getrocknet', 'trocken',
'fein', 'grob', 'dünn',
'gehackt', 'gewürfelt', 'geschnitten', 'gerieben', 'geraspelt',
'gemahlen', 'ganz', 'halbiert', 'geviertelt',
'geschält', 'entkernt', 'entsteint',
'geschmolzen', 'weich', 'gefroren', 'aufgetaut', 'gekühlt',
'geröstet', 'blanchiert', 'gekocht', 'gedämpft',
'gesiebt',
'klein', 'mittel', 'groß',
'bio', 'optional', 'nach Geschmack', 'nach Bedarf', 'zum Garnieren',
'etwa', 'ungefähr', 'ca',
];
// ── USDA helpers ──
function getNutritionByName(): Map<string, NutritionEntry> {
if (!nutritionByName) {
nutritionByName = new Map();
for (const entry of NUTRITION_DB) nutritionByName.set(entry.name, entry);
}
return nutritionByName;
}
function getNutritionByFdcId(): Map<number, NutritionEntry> {
if (!nutritionByFdcId) {
nutritionByFdcId = new Map();
for (const entry of NUTRITION_DB) nutritionByFdcId.set(entry.fdcId, entry);
}
return nutritionByFdcId;
}
async function getUsdaEmbedder(): Promise<FeatureExtractionPipeline> {
if (!usdaEmbedder) {
usdaEmbedder = await pipeline('feature-extraction', USDA_MODEL, { dtype: 'q8' });
}
return usdaEmbedder;
}
function getUsdaEmbeddingIndex() {
if (!usdaEmbeddingIndex) {
const raw = JSON.parse(readFileSync(USDA_EMBEDDINGS_PATH, 'utf-8'));
usdaEmbeddingIndex = raw.entries;
}
return usdaEmbeddingIndex!;
}
// ── BLS helpers ──
function getBlsByCode(): Map<string, BlsEntry> {
if (!blsByCode) {
blsByCode = new Map();
for (const entry of BLS_DB) blsByCode.set(entry.blsCode, entry);
}
return blsByCode;
}
async function getBlsEmbedder(): Promise<FeatureExtractionPipeline> {
if (!blsEmbedder) {
blsEmbedder = await pipeline('feature-extraction', BLS_MODEL, { dtype: 'q8' });
}
return blsEmbedder;
}
function getBlsEmbeddingIndex() {
if (!blsEmbeddingIndex) {
try {
const raw = JSON.parse(readFileSync(BLS_EMBEDDINGS_PATH, 'utf-8'));
blsEmbeddingIndex = raw.entries;
} catch {
// BLS embeddings not yet generated — skip
blsEmbeddingIndex = [];
}
}
return blsEmbeddingIndex!;
}
// ── Shared ──
/** Normalize an ingredient name for matching (English) */
export function normalizeIngredientName(name: string): string {
let normalized = name.toLowerCase().trim();
normalized = normalized.replace(/\(.*?\)/g, '').trim();
for (const mod of STRIP_MODIFIERS) {
normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim();
}
normalized = normalized.replace(/\s+/g, ' ').replace(/,\s*$/, '').trim();
return normalized;
}
/** Normalize a German ingredient name for matching */
export function normalizeIngredientNameDe(name: string): string {
let normalized = name.toLowerCase().trim();
normalized = normalized.replace(/\(.*?\)/g, '').trim();
for (const mod of STRIP_MODIFIERS_DE) {
normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim();
}
normalized = normalized.replace(/\s+/g, ' ').replace(/,\s*$/, '').trim();
return normalized;
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
/** Replace German umlauts and ß for fuzzy substring matching */
function deUmlaut(s: string): string {
return s.replace(/ä/g, 'a').replace(/ö/g, 'o').replace(/ü/g, 'u').replace(/ß/g, 'ss');
}
/**
* Generate singular/stem forms for a German word for substring matching.
* Not full stemming — just common plural patterns.
*/
function germanSingulars(word: string): string[] {
const base = deUmlaut(word);
const forms = new Set([word, base]);
// -n: Tomaten→Tomate, Kartoffeln→Kartoffel
if (base.endsWith('n')) forms.add(base.slice(0, -1));
// -en: Bohnen→Bohn (then also try Bohne)
if (base.endsWith('en')) { forms.add(base.slice(0, -2)); forms.add(base.slice(0, -1)); }
// -er: Eier→Ei
if (base.endsWith('er')) forms.add(base.slice(0, -2));
// -e: Birne→Birn (for compound matching)
if (base.endsWith('e')) forms.add(base.slice(0, -1));
// -s: (loanwords)
if (base.endsWith('s')) forms.add(base.slice(0, -1));
return [...forms].filter(f => f.length >= 2);
}
/** BLS categories that are prepared dishes — exclude from embedding-only matching */
const EXCLUDED_BLS_CATEGORIES = new Set([
'Gerichte und Rezepte', 'Backwaren', 'Supplemente',
]);
/**
* Generate search forms for an ingredient name, including compound word parts
* and individual words for multi-word queries.
* "Zitronenschale" → ["zitronenschale", "zitronen", "zitrone", "schale", ...]
* "cinnamon stick" → ["cinnamon stick", "cinnamon", "stick", ...]
*/
function searchForms(query: string): string[] {
const forms = new Set(germanSingulars(query.toLowerCase()));
// Add individual words from multi-word queries
const words = query.toLowerCase().split(/\s+/);
for (const word of words) {
if (word.length >= 3) {
forms.add(word);
forms.add(deUmlaut(word));
for (const s of germanSingulars(word)) forms.add(s);
}
}
// Try splitting common German compound suffixes
const compoundSuffixes = [
'schale', 'saft', 'stange', 'stück', 'pulver', 'blatt', 'blätter',
'korn', 'körner', 'mehl', 'öl', 'ol', 'flocken', 'creme', 'mark',
'wasser', 'milch', 'sahne', 'butter', 'käse', 'kase', 'soße', 'sosse',
];
const base = deUmlaut(query.toLowerCase());
for (const suffix of compoundSuffixes) {
if (base.endsWith(suffix) && base.length > suffix.length + 2) {
const stem = base.slice(0, -suffix.length);
forms.add(stem);
for (const s of germanSingulars(stem)) forms.add(s);
}
}
return [...forms].filter(f => f.length >= 3);
}
/**
* Find substring matches in a name list. Returns indices of entries
* where any form of the query appears in the entry name.
*/
function findSubstringMatches(
query: string,
entries: { name: string }[],
): number[] {
const forms = searchForms(query);
const matches: number[] = [];
for (let i = 0; i < entries.length; i++) {
const entryName = deUmlaut(entries[i].name.toLowerCase());
for (const form of forms) {
if (entryName.includes(form)) {
matches.push(i);
break;
}
}
}
return matches;
}
/**
* Score a substring match, combining embedding similarity with heuristics:
* - Word-boundary matches preferred over mid-word matches
* - Shorter names are preferred (more likely base ingredients)
* - Names containing "roh" (raw) get a bonus
* - Names starting with the query get a bonus
*/
function substringMatchScore(
embeddingScore: number,
entryName: string,
queryForms: string[],
): number {
let score = embeddingScore;
const nameLower = deUmlaut(entryName.toLowerCase());
// Check how the query matches: word-start vs mid-compound vs trailing mention
let hasStartMatch = false;
let hasEarlyMatch = false; // within first 15 chars
let hasWordBoundaryMatch = false;
for (const form of queryForms) {
// Start match: name begins with the query
if (nameLower.startsWith(form + ' ') || nameLower.startsWith(form + ',') || nameLower === form) {
hasStartMatch = true;
}
// Early match: appears within first ~15 chars (likely the main ingredient)
const pos = nameLower.indexOf(form);
if (pos >= 0 && pos < 15) hasEarlyMatch = true;
// Word-boundary match
const wordBoundary = new RegExp(`(^|[\\s,/])${form}([\\s,/]|$)`);
if (wordBoundary.test(nameLower)) hasWordBoundaryMatch = true;
}
// Strong bonus for name starting with query form
if (hasStartMatch) score += 0.2;
// Moderate bonus for early appearance in name
else if (hasEarlyMatch) score += 0.12;
// Small bonus for word-boundary match
else if (hasWordBoundaryMatch) score += 0.05;
// Penalty for late/trailing mentions (e.g., "mit Zimt" at end of a dish name)
else score -= 0.05;
// Bonus for short names (base ingredients like "Apfel roh" vs long dish names)
// Short names get strong boost, long names get penalized
score += Math.max(-0.1, (20 - nameLower.length) * 0.008);
// Bonus for "roh" (raw) — but only if query starts the name (avoid boosting unrelated raw items)
if (/\broh\b/.test(nameLower) && (hasStartMatch || hasWordBoundaryMatch)) score += 0.1;
return score;
}
/**
* Find best BLS match: substring-first hybrid.
* 1. Find BLS entries whose name contains the ingredient (lexical match)
* 2. Among those, rank by embedding + heuristic score
* 3. If no lexical matches, fall back to full embedding search
*/
async function blsEmbeddingMatch(
ingredientNameDe: string
): Promise<{ entry: BlsEntry; confidence: number } | null> {
const index = getBlsEmbeddingIndex();
if (index.length === 0) return null;
const emb = await getBlsEmbedder();
const result = await emb(`query: ${ingredientNameDe}`, { pooling: 'mean', normalize: true });
const queryVector = Array.from(result.data as Float32Array);
const queryForms = searchForms(ingredientNameDe);
// Find lexical substring matches first
const substringIndices = findSubstringMatches(ingredientNameDe, index);
if (substringIndices.length > 0) {
let bestScore = -1;
let bestItem: typeof index[0] | null = null;
for (const idx of substringIndices) {
const item = index[idx];
const entry = getBlsByCode().get(item.blsCode);
if (entry && EXCLUDED_BLS_CATEGORIES.has(entry.category)) continue;
const embScore = cosineSimilarity(queryVector, item.vector);
const score = substringMatchScore(embScore, item.name, queryForms);
if (score > bestScore) {
bestScore = score;
bestItem = item;
}
}
if (bestItem) {
const entry = getBlsByCode().get(bestItem.blsCode);
if (entry) {
// Check if ANY substring match is a direct hit (query at start/early in name)
const nameNorm = deUmlaut(bestItem.name.toLowerCase());
const isDirectMatch = queryForms.some(f =>
nameNorm.startsWith(f + ' ') || nameNorm.startsWith(f + ',') ||
nameNorm.startsWith(f + '/') || nameNorm === f ||
(nameNorm.indexOf(f) >= 0 && nameNorm.indexOf(f) < 12)
);
// Only use substring match if it's a direct hit — otherwise the query
// word appears as a minor component in a dish name and we should
// fall through to full search / USDA
if (isDirectMatch) {
const conf = Math.min(Math.max(bestScore, 0.7), 1.0);
return { entry, confidence: conf };
}
}
}
}
// Fall back to full embedding search (excluding prepared dishes)
// Use higher threshold for pure embedding — short German words produce unreliable scores
const EMBEDDING_ONLY_THRESHOLD = 0.85;
let bestScore = -1;
let bestItem: typeof index[0] | null = null;
for (const item of index) {
const entry = getBlsByCode().get(item.blsCode);
if (entry && EXCLUDED_BLS_CATEGORIES.has(entry.category)) continue;
const score = cosineSimilarity(queryVector, item.vector);
if (score > bestScore) {
bestScore = score;
bestItem = item;
}
}
if (!bestItem || bestScore < EMBEDDING_ONLY_THRESHOLD) return null;
const entry = getBlsByCode().get(bestItem.blsCode);
if (!entry) return null;
return { entry, confidence: bestScore };
}
/** USDA categories that are prepared dishes — exclude from matching */
const EXCLUDED_USDA_CATEGORIES = new Set(['Restaurant Foods']);
/**
* Score a USDA substring match with heuristics similar to BLS.
*/
function usdaSubstringMatchScore(
embeddingScore: number,
entryName: string,
query: string,
): number {
let score = embeddingScore;
const nameLower = entryName.toLowerCase();
const queryForms = searchForms(query);
// Check match position
let hasStartMatch = false;
let hasEarlyMatch = false;
for (const form of queryForms) {
if (nameLower.startsWith(form + ',') || nameLower.startsWith(form + ' ') || nameLower === form) {
hasStartMatch = true;
}
const pos = nameLower.indexOf(form);
if (pos >= 0 && pos < 15) hasEarlyMatch = true;
}
if (hasStartMatch) score += 0.2;
else if (hasEarlyMatch) score += 0.1;
else score -= 0.05;
// Bonus for short names — but moderate to avoid "Bread, X" beating "Spices, X, ground"
score += Math.max(-0.1, (25 - nameLower.length) * 0.003);
// Bonus for "raw" — base ingredient indicator (only if direct match)
if (/\braw\b/.test(nameLower) && (hasStartMatch || hasEarlyMatch)) score += 0.1;
// Bonus for category-style entries ("Spices, X" / "Seeds, X" / "Oil, X")
if (/^(spices|seeds|oil|nuts|fish|cheese|milk|cream|butter|flour|sugar),/i.test(nameLower)) {
score += 0.08;
}
return score;
}
/**
* Find best USDA match: substring-first hybrid.
* Same strategy as BLS: lexical matches first, heuristic re-ranking, then fallback.
*/
async function usdaEmbeddingMatch(
ingredientNameEn: string
): Promise<{ entry: NutritionEntry; confidence: number } | null> {
const emb = await getUsdaEmbedder();
const index = getUsdaEmbeddingIndex();
const result = await emb(ingredientNameEn, { pooling: 'mean', normalize: true });
const queryVector = Array.from(result.data as Float32Array);
// Find lexical substring matches
const substringIndices = findSubstringMatches(ingredientNameEn, index);
if (substringIndices.length > 0) {
let bestScore = -1;
let bestItem: typeof index[0] | null = null;
for (const idx of substringIndices) {
const item = index[idx];
const entry = getNutritionByFdcId().get(item.fdcId);
if (entry && EXCLUDED_USDA_CATEGORIES.has(entry.category)) continue;
const embScore = cosineSimilarity(queryVector, item.vector);
const score = usdaSubstringMatchScore(embScore, item.name, ingredientNameEn);
if (score > bestScore) {
bestScore = score;
bestItem = item;
}
}
if (bestItem) {
const nutrition = getNutritionByFdcId().get(bestItem.fdcId);
if (nutrition) {
const nameNorm = bestItem.name.toLowerCase();
const forms = searchForms(ingredientNameEn);
const isDirectMatch = forms.some(f =>
nameNorm.startsWith(f + ',') || nameNorm.startsWith(f + ' ') ||
nameNorm === f || (nameNorm.indexOf(f) >= 0 && nameNorm.indexOf(f) < 15)
);
if (isDirectMatch) {
return { entry: nutrition, confidence: Math.min(Math.max(bestScore, 0.7), 1.0) };
}
}
}
}
// Full embedding search fallback (excluding restaurant foods)
let bestScore = -1;
let bestEntry: typeof index[0] | null = null;
for (const item of index) {
const entry = getNutritionByFdcId().get(item.fdcId);
if (entry && EXCLUDED_USDA_CATEGORIES.has(entry.category)) continue;
const score = cosineSimilarity(queryVector, item.vector);
if (score > bestScore) {
bestScore = score;
bestEntry = item;
}
}
if (!bestEntry || bestScore < CONFIDENCE_THRESHOLD) return null;
const nutrition = getNutritionByFdcId().get(bestEntry.fdcId);
if (!nutrition) return null;
return { entry: nutrition, confidence: bestScore };
}
/** Parse a recipe amount string to a number */
export function parseAmount(amount: string): number {
if (!amount || !amount.trim()) return 0;
let s = amount.trim();
const rangeMatch = s.match(/^(\d+(?:[.,]\d+)?)\s*[-]\s*(\d+(?:[.,]\d+)?)$/);
if (rangeMatch) {
return (parseFloat(rangeMatch[1].replace(',', '.')) + parseFloat(rangeMatch[2].replace(',', '.'))) / 2;
}
s = s.replace(',', '.');
const fractionMatch = s.match(/^(\d+)\s*\/\s*(\d+)$/);
if (fractionMatch) return parseInt(fractionMatch[1]) / parseInt(fractionMatch[2]);
const mixedMatch = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
if (mixedMatch) return parseInt(mixedMatch[1]) + parseInt(mixedMatch[2]) / parseInt(mixedMatch[3]);
const parsed = parseFloat(s);
return isNaN(parsed) ? 0 : parsed;
}
// ── Global overwrite cache ──
let overwriteCache: Map<string, any> | null = null;
let overwriteCacheTime = 0;
const OVERWRITE_CACHE_TTL = 60_000; // 1 minute
async function lookupGlobalOverwrite(normalizedNameDe: string): Promise<any | null> {
const now = Date.now();
if (!overwriteCache || now - overwriteCacheTime > OVERWRITE_CACHE_TTL) {
try {
const all = await NutritionOverwrite.find({}).lean();
overwriteCache = new Map(all.map((o: any) => [o.ingredientNameDe, o]));
overwriteCacheTime = now;
} catch {
overwriteCache = new Map();
overwriteCacheTime = now;
}
}
return overwriteCache.get(normalizedNameDe) || null;
}
/** Invalidate the overwrite cache (call after creating/updating/deleting overwrites) */
export function invalidateOverwriteCache() {
overwriteCache = null;
}
/**
* Match a single ingredient against BLS (German, primary) then USDA (English, fallback).
*/
export async function matchIngredient(
ingredientNameDe: string,
ingredientNameEn: string | undefined,
unit: string,
amount: string,
sectionIndex: number,
ingredientIndex: number,
): Promise<NutritionMapping> {
const normalizedEn = ingredientNameEn ? normalizeIngredientName(ingredientNameEn) : '';
const normalizedDe = normalizeIngredientNameDe(ingredientNameDe);
let source: 'bls' | 'usda' = 'usda';
let fdcId: number | undefined;
let blsCode: string | undefined;
let nutritionDbName: string | undefined;
let matchMethod: NutritionMapping['matchMethod'] = 'none';
let confidence = 0;
let portions: { description: string; grams: number }[] = [];
let category = '';
// 0. Check global overwrites (DB-stored manual mappings)
const overwrite = await lookupGlobalOverwrite(normalizedDe);
if (overwrite) {
if (overwrite.excluded || overwrite.source === 'skip') {
return {
sectionIndex, ingredientIndex,
ingredientName: ingredientNameEn || ingredientNameDe,
ingredientNameDe,
source: 'usda', matchMethod: 'manual', matchConfidence: 1,
gramsPerUnit: 0, defaultAmountUsed: false,
unitConversionSource: 'none', manuallyEdited: false, excluded: true,
};
}
if (overwrite.source === 'bls' && overwrite.blsCode) {
const entry = getBlsByCode().get(overwrite.blsCode);
if (entry) {
source = 'bls'; blsCode = overwrite.blsCode;
nutritionDbName = entry.nameDe; matchMethod = 'exact';
confidence = 1.0; category = entry.category;
}
} else if (overwrite.source === 'usda' && overwrite.fdcId) {
const entry = getNutritionByFdcId().get(overwrite.fdcId);
if (entry) {
source = 'usda'; fdcId = overwrite.fdcId;
nutritionDbName = entry.name; matchMethod = 'exact';
confidence = 1.0; portions = entry.portions; category = entry.category;
}
}
}
// 1. Try alias table (English, fast path → USDA)
if (matchMethod === 'none' && normalizedEn) {
const aliasResult = lookupAlias(normalizedEn);
if (aliasResult) {
const entry = getNutritionByName().get(aliasResult);
if (entry) {
source = 'usda';
fdcId = entry.fdcId;
nutritionDbName = entry.name;
matchMethod = 'exact';
confidence = 1.0;
portions = entry.portions;
category = entry.category;
}
}
}
// 2. Try BLS embedding match (German name, primary)
if (matchMethod === 'none' && normalizedDe) {
const blsResult = await blsEmbeddingMatch(normalizedDe);
if (blsResult) {
source = 'bls';
blsCode = blsResult.entry.blsCode;
nutritionDbName = blsResult.entry.nameDe;
matchMethod = 'embedding';
confidence = blsResult.confidence;
category = blsResult.entry.category;
// BLS has no portion data — will use unit conversion tables
}
}
// 3. Try USDA embedding match (English name, fallback)
if (matchMethod === 'none' && normalizedEn) {
const usdaResult = await usdaEmbeddingMatch(normalizedEn);
if (usdaResult) {
source = 'usda';
fdcId = usdaResult.entry.fdcId;
nutritionDbName = usdaResult.entry.name;
matchMethod = 'embedding';
confidence = usdaResult.confidence;
portions = usdaResult.entry.portions;
category = usdaResult.entry.category;
}
}
// Resolve unit conversion
const canonicalUnit = canonicalizeUnit(unit);
let parsedAmount = parseAmount(amount);
let defaultAmountUsed = false;
// If no amount given, try default amounts
if (!parsedAmount && matchMethod !== 'none') {
const nameForDefault = normalizedEn || normalizedDe;
const defaultAmt = resolveDefaultAmount(nameForDefault, category);
if (defaultAmt) {
parsedAmount = defaultAmt.amount;
const defaultCanonical = canonicalizeUnit(defaultAmt.unit);
const unitResolution = resolveGramsPerUnit(defaultCanonical, portions);
defaultAmountUsed = true;
return {
sectionIndex, ingredientIndex,
ingredientName: ingredientNameEn || ingredientNameDe,
ingredientNameDe,
source, fdcId, blsCode, nutritionDbName,
matchConfidence: confidence, matchMethod,
gramsPerUnit: unitResolution.grams,
defaultAmountUsed,
unitConversionSource: unitResolution.source,
manuallyEdited: false,
excluded: defaultAmt.amount === 0,
};
}
}
const unitResolution = resolveGramsPerUnit(canonicalUnit, portions);
return {
sectionIndex, ingredientIndex,
ingredientName: ingredientNameEn || ingredientNameDe,
ingredientNameDe,
source, fdcId, blsCode, nutritionDbName,
matchConfidence: confidence, matchMethod,
gramsPerUnit: unitResolution.grams,
defaultAmountUsed,
unitConversionSource: unitResolution.source,
manuallyEdited: false,
excluded: false,
};
}
/**
* Generate nutrition mappings for all ingredients in a recipe.
* Uses German names for BLS matching and English names for USDA fallback.
*/
export async function generateNutritionMappings(
ingredients: any[],
translatedIngredients?: any[],
): Promise<NutritionMapping[]> {
const mappings: NutritionMapping[] = [];
for (let sectionIdx = 0; sectionIdx < ingredients.length; sectionIdx++) {
const sectionDe = ingredients[sectionIdx];
const sectionEn = translatedIngredients?.[sectionIdx];
if (sectionDe.type === 'reference' || !sectionDe.list) continue;
for (let itemIdx = 0; itemIdx < sectionDe.list.length; itemIdx++) {
const itemDe = sectionDe.list[itemIdx];
const itemEn = sectionEn?.list?.[itemIdx];
const mapping = await matchIngredient(
itemDe.name,
itemEn?.name || undefined,
itemDe.unit || '',
itemDe.amount || '',
sectionIdx,
itemIdx,
);
mappings.push(mapping);
}
}
return mappings;
}
/** Look up a USDA NutritionEntry by fdcId */
export function getNutritionEntryByFdcId(fdcId: number): NutritionEntry | undefined {
return getNutritionByFdcId().get(fdcId);
}
/** Look up a BLS entry by blsCode */
export function getBlsEntryByCode(code: string): BlsEntry | undefined {
return getBlsByCode().get(code);
}
/** Resolve per100g data for a mapping from BLS or USDA */
export function resolvePer100g(mapping: any): NutritionPer100g | null {
if (mapping.blsCode && mapping.source === 'bls') {
const entry = getBlsByCode().get(mapping.blsCode);
return entry?.per100g ?? null;
}
if (mapping.fdcId) {
const entry = getNutritionByFdcId().get(mapping.fdcId);
return entry?.per100g ?? null;
}
return null;
}
/**
* Compute absolute nutrition totals for a recipe's ingredients using its nutritionMappings.
* Returns total nutrients (not per-100g), optionally scaled by a multiplier.
*/
export function computeRecipeNutritionTotals(
ingredients: any[],
nutritionMappings: any[],
multiplier = 1,
): Record<string, number> {
const index = new Map(
(nutritionMappings || []).map((m: any) => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
);
const totals: Record<string, number> = {};
// Collect section names for dedup (skip ingredients referencing earlier sections)
const sectionNames = new Set<string>();
for (let si = 0; si < ingredients.length; si++) {
const section = ingredients[si];
if (section.type === 'reference' || !section.list) {
if (section.name) sectionNames.add(stripHtml(section.name).toLowerCase().trim());
continue;
}
if (section.name) sectionNames.add(stripHtml(section.name).toLowerCase().trim());
}
for (let si = 0; si < ingredients.length; si++) {
const section = ingredients[si];
if (section.type === 'reference' || !section.list) continue;
const currentSectionName = section.name ? stripHtml(section.name).toLowerCase().trim() : '';
for (let ii = 0; ii < section.list.length; ii++) {
const item = section.list[ii];
const rawName = item.name || '';
const itemName = stripHtml(rawName).toLowerCase().trim();
// Skip anchor-tag references to other recipes (handled separately)
if (/<a\s/i.test(rawName)) continue;
// Skip if this ingredient name matches a DIFFERENT section's name
if (itemName && sectionNames.has(itemName) && itemName !== currentSectionName) continue;
const mapping = index.get(`${si}-${ii}`);
if (!mapping || mapping.matchMethod === 'none' || mapping.excluded) continue;
const per100g = resolvePer100g(mapping);
if (!per100g) continue;
const amount = parseAmount(item.amount || '') || (mapping.defaultAmountUsed ? 1 : 0);
const grams = amount * multiplier * (mapping.gramsPerUnit || 0);
const factor = grams / 100;
for (const [key, value] of Object.entries(per100g)) {
if (typeof value === 'number') {
totals[key] = (totals[key] || 0) + factor * value;
}
}
}
}
return totals;
}
/** Strip HTML tags from a string */
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '');
}

View File

@@ -0,0 +1,25 @@
import mongoose from 'mongoose';
/**
* Global nutrition overwrites — manually map ingredient names to BLS/USDA entries.
* Checked during nutrition generation before embedding search.
* Can also mark ingredients as excluded (skipped).
*/
const NutritionOverwriteSchema = new mongoose.Schema({
// The normalized ingredient name this overwrite matches (German, lowercase)
ingredientNameDe: { type: String, required: true },
// Optional English name for display
ingredientNameEn: { type: String },
// What to map to
source: { type: String, enum: ['bls', 'usda', 'skip'], required: true },
fdcId: { type: Number },
blsCode: { type: String },
nutritionDbName: { type: String },
// Whether this ingredient should be excluded from nutrition calculation
excluded: { type: Boolean, default: false },
}, { timestamps: true });
NutritionOverwriteSchema.index({ ingredientNameDe: 1 }, { unique: true });
delete mongoose.models.NutritionOverwrite;
export const NutritionOverwrite = mongoose.model('NutritionOverwrite', NutritionOverwriteSchema);

View File

@@ -163,6 +163,25 @@ const RecipeSchema = new mongoose.Schema(
} }
}, },
// Nutrition calorie/macro mapping for each ingredient
nutritionMappings: [{
sectionIndex: { type: Number, required: true },
ingredientIndex: { type: Number, required: true },
ingredientName: { type: String },
ingredientNameDe: { type: String },
source: { type: String, enum: ['bls', 'usda', 'manual'] },
fdcId: { type: Number },
blsCode: { type: String },
nutritionDbName: { type: String },
matchConfidence: { type: Number },
matchMethod: { type: String, enum: ['exact', 'embedding', 'manual', 'none'] },
gramsPerUnit: { type: Number },
defaultAmountUsed: { type: Boolean, default: false },
unitConversionSource: { type: String, enum: ['direct', 'density', 'usda_portion', 'estimate', 'manual', 'none'] },
manuallyEdited: { type: Boolean, default: false },
excluded: { type: Boolean, default: false },
}],
// Translation metadata for tracking changes // Translation metadata for tracking changes
translationMetadata: { translationMetadata: {
lastModifiedGerman: {type: Date}, lastModifiedGerman: {type: Date},
@@ -177,6 +196,6 @@ RecipeSchema.index({ "translations.en.translationStatus": 1 });
import type { RecipeModelType } from '$types/types'; import type { RecipeModelType } from '$types/types';
let _recipeModel: mongoose.Model<RecipeModelType>; // Delete cached model on HMR so schema changes (e.g. new fields) are picked up
try { _recipeModel = mongoose.model<RecipeModelType>("Recipe"); } catch { _recipeModel = mongoose.model<RecipeModelType>("Recipe", RecipeSchema); } delete mongoose.models.Recipe;
export const Recipe = _recipeModel; export const Recipe = mongoose.model<RecipeModelType>("Recipe", RecipeSchema);

View File

@@ -0,0 +1,308 @@
<script>
let { data } = $props();
const isEnglish = data.lang === 'en';
const recipeLang = data.recipeLang;
let processing = $state(false);
let singleProcessing = $state('');
/** @type {any} */
let batchResult = $state(null);
/** @type {any} */
let singleResult = $state(null);
let errorMsg = $state('');
let recipeName = $state('');
async function generateAll() {
processing = true;
errorMsg = '';
batchResult = null;
try {
const response = await fetch(`/api/${recipeLang}/nutrition/generate-all`, {
method: 'POST',
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Failed to generate nutrition mappings');
}
batchResult = result;
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'An error occurred';
} finally {
processing = false;
}
}
async function generateSingle() {
if (!recipeName.trim()) return;
singleProcessing = recipeName.trim();
singleResult = null;
errorMsg = '';
try {
const response = await fetch(`/api/${recipeLang}/nutrition/generate/${encodeURIComponent(recipeName.trim())}`, {
method: 'POST',
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Failed to generate nutrition mappings');
}
singleResult = result;
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'An error occurred';
} finally {
singleProcessing = '';
}
}
</script>
<style>
.container {
max-width: 1000px;
margin: 2rem auto;
padding: 2rem;
}
h1 {
color: var(--nord0);
margin-bottom: 0.5rem;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) h1 { color: white; }
}
:global(:root[data-theme="dark"]) h1 { color: white; }
.subtitle {
color: var(--nord3);
margin-bottom: 2rem;
}
.section {
background: var(--nord6, #eceff4);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .section { background: var(--nord1); }
}
:global(:root[data-theme="dark"]) .section { background: var(--nord1); }
.section h2 {
margin-top: 0;
font-size: 1.3rem;
}
.input-row {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.input-row input {
flex: 1;
min-width: 200px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #ccc);
border-radius: 6px;
font-size: 1rem;
background: transparent;
color: inherit;
}
button {
padding: 0.5rem 1.25rem;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
background: var(--nord10, #5e81ac);
color: white;
transition: opacity 150ms;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:hover:not(:disabled) {
opacity: 0.85;
}
.btn-danger {
background: var(--nord11, #bf616a);
}
.result-box {
margin-top: 1rem;
padding: 1rem;
border-radius: 6px;
background: var(--nord0, #2e3440);
color: var(--nord6, #eceff4);
font-family: monospace;
font-size: 0.85rem;
max-height: 400px;
overflow-y: auto;
}
.result-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.result-table th,
.result-table td {
text-align: left;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--nord3, #4c566a);
}
.result-table th {
color: var(--nord9, #81a1c1);
}
.coverage-bar {
display: inline-block;
height: 6px;
border-radius: 3px;
background: var(--nord14, #a3be8c);
vertical-align: middle;
}
.coverage-bar-bg {
display: inline-block;
width: 60px;
height: 6px;
border-radius: 3px;
background: var(--nord3, #4c566a);
vertical-align: middle;
margin-right: 0.4rem;
}
.error {
color: var(--nord11, #bf616a);
margin-top: 0.75rem;
font-weight: bold;
}
.summary {
display: flex;
gap: 2rem;
flex-wrap: wrap;
margin-top: 0.75rem;
font-size: 1.1rem;
}
.summary-stat {
text-align: center;
}
.summary-stat .value {
font-size: 1.8rem;
font-weight: bold;
color: var(--nord10, #5e81ac);
}
.summary-stat .label {
font-size: 0.85rem;
color: var(--nord3);
}
</style>
<svelte:head>
<title>{isEnglish ? 'Nutrition Mappings' : 'Nährwert-Zuordnungen'}</title>
</svelte:head>
<div class="container">
<h1>{isEnglish ? 'Nutrition Mappings' : 'Nährwert-Zuordnungen'}</h1>
<p class="subtitle">
{isEnglish
? 'Generate ingredient-to-calorie mappings using ML embeddings. Manually edited mappings are preserved.'
: 'Zutatenzuordnungen zu Kaloriendaten mittels ML-Embeddings generieren. Manuell bearbeitete Zuordnungen bleiben erhalten.'}
</p>
<!-- Single recipe -->
<div class="section">
<h2>{isEnglish ? 'Single Recipe' : 'Einzelnes Rezept'}</h2>
<div class="input-row">
<input
type="text"
placeholder={isEnglish ? 'Recipe short_name (e.g., maccaroni)' : 'Rezept short_name (z.B. maccaroni)'}
bind:value={recipeName}
onkeydown={(e) => e.key === 'Enter' && generateSingle()}
/>
<button disabled={!!singleProcessing || !recipeName.trim()} onclick={generateSingle}>
{singleProcessing ? (isEnglish ? 'Processing...' : 'Verarbeite...') : (isEnglish ? 'Generate' : 'Generieren')}
</button>
</div>
{#if singleResult}
<div class="result-box">
<p>{singleResult.count} {isEnglish ? 'ingredients mapped' : 'Zutaten zugeordnet'}</p>
<table class="result-table">
<thead><tr><th>#</th><th>{isEnglish ? 'Ingredient' : 'Zutat'}</th><th>{isEnglish ? 'Match' : 'Treffer'}</th><th>{isEnglish ? 'Confidence' : 'Konfidenz'}</th><th>g/unit</th></tr></thead>
<tbody>
{#each singleResult.mappings as m, i}
<tr>
<td>{i + 1}</td>
<td>{m.ingredientName}</td>
<td>{m.nutritionDbName || '—'}</td>
<td>{m.matchConfidence ? (m.matchConfidence * 100).toFixed(0) + '%' : '—'}</td>
<td>{m.gramsPerUnit || '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Batch all recipes -->
<div class="section">
<h2>{isEnglish ? 'Batch: All Recipes' : 'Batch: Alle Rezepte'}</h2>
<p>{isEnglish
? 'Regenerate nutrition mappings for all recipes. This may take a few minutes on first run (ML model loading).'
: 'Nährwertzuordnungen für alle Rezepte neu generieren. Beim ersten Durchlauf kann dies einige Minuten dauern (ML-Modell wird geladen).'}
</p>
<button class="btn-danger" disabled={processing} onclick={generateAll}>
{processing ? (isEnglish ? 'Processing all recipes...' : 'Verarbeite alle Rezepte...') : (isEnglish ? 'Generate All' : 'Alle generieren')}
</button>
{#if batchResult}
<div class="summary">
<div class="summary-stat">
<div class="value">{batchResult.recipes}</div>
<div class="label">{isEnglish ? 'Recipes' : 'Rezepte'}</div>
</div>
<div class="summary-stat">
<div class="value">{batchResult.totalMapped}/{batchResult.totalIngredients}</div>
<div class="label">{isEnglish ? 'Ingredients Mapped' : 'Zutaten zugeordnet'}</div>
</div>
<div class="summary-stat">
<div class="value">{batchResult.coverage}</div>
<div class="label">{isEnglish ? 'Coverage' : 'Abdeckung'}</div>
</div>
</div>
<div class="result-box">
<table class="result-table">
<thead><tr><th>{isEnglish ? 'Recipe' : 'Rezept'}</th><th>{isEnglish ? 'Mapped' : 'Zugeordnet'}</th><th>{isEnglish ? 'Coverage' : 'Abdeckung'}</th></tr></thead>
<tbody>
{#each batchResult.details as detail}
<tr>
<td>{detail.name}</td>
<td>{detail.mapped}/{detail.total}</td>
<td>
<span class="coverage-bar-bg">
<span class="coverage-bar" style="width: {detail.total ? (detail.mapped / detail.total * 60) : 0}px"></span>
</span>
{detail.total ? Math.round(detail.mapped / detail.total * 100) : 0}%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if errorMsg}
<p class="error">{errorMsg}</p>
{/if}
</div>

View File

@@ -33,6 +33,14 @@
: 'Dominante Farben aus Rezeptbildern für Ladeplatzhalter extrahieren', : 'Dominante Farben aus Rezeptbildern für Ladeplatzhalter extrahieren',
href: `/${data.recipeLang}/admin/image-colors`, href: `/${data.recipeLang}/admin/image-colors`,
icon: '🎨' icon: '🎨'
},
{
title: isEnglish ? 'Nutrition Mappings' : 'Nährwert-Zuordnungen',
description: isEnglish
? 'Generate or regenerate calorie and nutrition data for all recipes'
: 'Kalorien- und Nährwertdaten für alle Rezepte generieren oder aktualisieren',
href: `/${data.recipeLang}/admin/nutrition`,
icon: '🥗'
} }
]; ];
</script> </script>

View File

@@ -1,8 +1,10 @@
import type { Actions, PageServerLoad } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { redirect, fail } from "@sveltejs/kit"; import { redirect, fail } from "@sveltejs/kit";
import { Recipe } from '$models/Recipe'; import { Recipe } from '$models/Recipe';
import { NutritionOverwrite } from '$models/NutritionOverwrite';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { invalidateRecipeCaches } from '$lib/server/cache'; import { invalidateRecipeCaches } from '$lib/server/cache';
import { invalidateOverwriteCache } from '$lib/server/nutritionMatcher';
import { IMAGE_DIR } from '$env/static/private'; import { IMAGE_DIR } from '$env/static/private';
import { rename, access, unlink } from 'fs/promises'; import { rename, access, unlink } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
@@ -209,6 +211,44 @@ export const actions = {
}); });
} }
// Save nutrition mappings (deferred from nutrition UI)
const nutritionMappingsJson = formData.get('nutritionMappings_json')?.toString();
if (nutritionMappingsJson) {
try {
const mappings = JSON.parse(nutritionMappingsJson);
if (mappings.length > 0) {
await Recipe.updateOne(
{ short_name: recipeData.short_name },
{ $set: { nutritionMappings: mappings } }
);
}
} catch (e) {
console.error('Failed to save nutrition mappings:', e);
}
}
// Save global nutrition overwrites
const globalOverwritesJson = formData.get('globalOverwrites_json')?.toString();
if (globalOverwritesJson) {
try {
const overwrites = JSON.parse(globalOverwritesJson);
for (const ow of overwrites) {
if (ow.ingredientNameDe) {
await NutritionOverwrite.findOneAndUpdate(
{ ingredientNameDe: ow.ingredientNameDe },
ow,
{ upsert: true, runValidators: true }
);
}
}
if (overwrites.length > 0) {
invalidateOverwriteCache();
}
} catch (e) {
console.error('Failed to save global overwrites:', e);
}
}
// Invalidate recipe caches after successful update // Invalidate recipe caches after successful update
await invalidateRecipeCaches(); await invalidateRecipeCaches();

View File

@@ -170,33 +170,40 @@
return changed; return changed;
} }
// Show translation workflow before submission // Save recipe directly (no translation workflow)
function prepareSubmit() { async function saveRecipe() {
// Client-side validation
if (!short_name.trim()) { if (!short_name.trim()) {
toast.error('Bitte geben Sie einen Kurznamen ein'); toast.error('Bitte einen Kurznamen eingeben');
return; return;
} }
if (!card_data.name) { if (!card_data.name) {
toast.error('Bitte geben Sie einen Namen ein'); toast.error('Bitte einen Namen eingeben');
return; return;
} }
// Mark translation as needing update if fields changed
// Only detect changed fields if there's an existing translation if (translationData) {
changedFields = translationData ? detectChangedFields() : []; const changed = detectChangedFields();
showTranslationWorkflow = true; if (changed.length > 0) {
translationData.translationStatus = 'needs_update';
// Scroll to translation section translationData.changedFields = changed;
setTimeout(() => { }
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' }); }
}, 100); await tick();
formElement.requestSubmit();
} }
// Force full retranslation of entire recipe // Open translation workflow (optional)
function forceFullRetranslation() { function openTranslation() {
changedFields = []; if (!short_name.trim()) {
toast.error('Bitte einen Kurznamen eingeben');
return;
}
if (!card_data.name) {
toast.error('Bitte einen Namen eingeben');
return;
}
changedFields = translationData ? detectChangedFields() : [];
showTranslationWorkflow = true; showTranslationWorkflow = true;
setTimeout(() => { setTimeout(() => {
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' }); document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100); }, 100);
@@ -237,6 +244,151 @@
showTranslationWorkflow = false; showTranslationWorkflow = false;
} }
// Nutrition state — all edits are local until form save
let nutritionMappings = $state<any[]>(data.recipe.nutritionMappings || []);
let generatingNutrition = $state(false);
let searchQueries = $state<Record<string, string>>({});
let searchResults = $state<Record<string, { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number }[]>>({});
let searchTimers = $state<Record<string, ReturnType<typeof setTimeout>>>({});
let globalToggle = $state<Record<string, boolean>>({});
function mappingKey(m: any) {
return `${m.sectionIndex}-${m.ingredientIndex}`;
}
// Global overwrites loaded from DB — used to init toggle state
let globalOverwriteNames = $state<Set<string>>(new Set());
async function loadGlobalOverwrites() {
try {
const res = await fetch('/api/nutrition/overwrites');
if (res.ok) {
const overwrites: any[] = await res.json();
globalOverwriteNames = new Set(overwrites.map((o: any) => o.ingredientNameDe));
}
} catch { /* ignore */ }
}
// Ensure globalToggle entries exist for all mappings, init from DB overwrites
function initGlobalToggles() {
for (const m of nutritionMappings) {
const key = mappingKey(m);
if (!(key in globalToggle)) {
const deName = (m.ingredientNameDe || m.ingredientName || '').toLowerCase().trim();
globalToggle[key] = globalOverwriteNames.has(deName);
}
}
}
// Pre-init all toggles to false (prevents bind:checked={undefined}), then load real state
initGlobalToggles();
if (nutritionMappings.length > 0) {
loadGlobalOverwrites().then(() => {
// Re-init with real overwrite data (overwrite the false defaults)
for (const m of nutritionMappings) {
const key = mappingKey(m);
const deName = (m.ingredientNameDe || m.ingredientName || '').toLowerCase().trim();
globalToggle[key] = globalOverwriteNames.has(deName);
}
});
}
async function generateNutrition() {
generatingNutrition = true;
try {
const res = await fetch(`/api/rezepte/nutrition/generate/${encodeURIComponent(short_name.trim())}?preview=true`, { method: 'POST' });
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || `HTTP ${res.status}`);
}
const result = await res.json();
// Merge: keep local manual edits, use auto for the rest
const manualMap = new Map(
nutritionMappings
.filter((m: any) => m.manuallyEdited)
.map((m: any) => [mappingKey(m), m])
);
nutritionMappings = result.mappings.map((m: any) => {
const key = mappingKey(m);
return manualMap.get(key) || m;
});
await loadGlobalOverwrites();
initGlobalToggles();
const mapped = nutritionMappings.filter((m: any) => m.matchMethod !== 'none').length;
toast.success(`Nährwerte generiert: ${mapped}/${result.count} Zutaten zugeordnet`);
} catch (e: any) {
toast.error(`Fehler: ${e.message}`);
} finally {
generatingNutrition = false;
}
}
function handleSearchInput(key: string, value: string) {
searchQueries[key] = value;
if (searchTimers[key]) clearTimeout(searchTimers[key]);
if (value.length < 2) {
searchResults[key] = [];
return;
}
searchTimers[key] = setTimeout(async () => {
try {
const res = await fetch(`/api/nutrition/search?q=${encodeURIComponent(value)}`);
if (res.ok) searchResults[key] = await res.json();
} catch { /* ignore */ }
}, 250);
}
function assignNutritionEntry(mapping: any, entry: { source: 'bls' | 'usda'; id: string; name: string }) {
const key = mappingKey(mapping);
if (entry.source === 'bls') {
mapping.blsCode = entry.id;
mapping.source = 'bls';
} else {
mapping.fdcId = parseInt(entry.id);
mapping.source = 'usda';
}
mapping.nutritionDbName = entry.name;
mapping.matchMethod = 'manual';
mapping.matchConfidence = 1;
mapping.excluded = false;
mapping.manuallyEdited = true;
searchResults[key] = [];
searchQueries[key] = '';
const isGlobal = globalToggle[key] || false;
toast.success(`${mapping.ingredientNameDe}${entry.name}${isGlobal ? ' (global)' : ''}`);
}
function skipIngredient(mapping: any) {
const key = mappingKey(mapping);
mapping.excluded = true;
mapping.matchMethod = 'manual';
mapping.manuallyEdited = true;
searchResults[key] = [];
searchQueries[key] = '';
const isGlobal = globalToggle[key] || false;
toast.success(`${mapping.ingredientNameDe} übersprungen${isGlobal ? ' (global)' : ''}`);
}
async function revertToAuto(mapping: any) {
mapping.manuallyEdited = false;
mapping.excluded = false;
await generateNutrition();
}
function getGlobalOverwrites() {
return nutritionMappings
.filter((m: any) => globalToggle[mappingKey(m)])
.map((m: any) => ({
ingredientNameDe: (m.ingredientNameDe || m.ingredientName).toLowerCase().trim(),
ingredientNameEn: m.ingredientName,
source: m.excluded ? 'skip' : (m.source || 'usda'),
fdcId: m.fdcId,
blsCode: m.blsCode,
nutritionDbName: m.nutritionDbName,
excluded: m.excluded || false,
}));
}
// Display form errors if any // Display form errors if any
$effect(() => { $effect(() => {
if (form?.error) { if (form?.error) {
@@ -327,6 +479,34 @@
font-size: 1.3rem; font-size: 1.3rem;
color: white; color: white;
} }
.fab-save {
position: fixed;
bottom: 0;
right: 0;
width: 1rem;
height: 1rem;
padding: 2rem;
margin: 2rem;
border-radius: var(--radius-pill);
background-color: var(--red);
display: grid;
justify-content: center;
align-content: center;
z-index: 100;
animation: unset !important;
}
.fab-save:hover, .fab-save:focus {
background-color: var(--nord0) !important;
}
.fab-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media screen and (max-width: 500px) {
.fab-save {
margin: 1rem;
}
}
.submit_buttons { .submit_buttons {
display: flex; display: flex;
margin-inline: auto; margin-inline: auto;
@@ -336,11 +516,6 @@
align-items: center; align-items: center;
gap: 2rem; gap: 2rem;
} }
.submit_buttons p {
padding: 0;
padding-right: 0.5em;
margin: 0;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .title { :global(:root:not([data-theme="light"])) .title {
background-color: var(--nord6-dark); background-color: var(--nord6-dark);
@@ -381,6 +556,302 @@
max-width: 800px; max-width: 800px;
text-align: center; text-align: center;
} }
.nutrition-section {
max-width: 1000px;
margin: 1.5rem auto;
}
.nutrition-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.nutrition-header h3 {
margin: 0;
}
.regenerate-btn {
background: var(--color-primary);
color: var(--color-text-on-primary);
border: none;
border-radius: var(--radius-pill);
padding: 0.4rem 1rem;
font-size: 0.85rem;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.regenerate-btn:hover {
opacity: 0.85;
}
.regenerate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.nutrition-table-wrapper {
background: var(--color-bg-secondary);
border-radius: 12px;
padding: 1rem;
overflow-x: auto;
}
.nutrition-result-summary {
margin: 0 0 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.nutrition-result-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.nutrition-result-table th,
.nutrition-result-table td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--color-bg-elevated);
}
.nutrition-result-table th {
color: var(--color-text-secondary);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.unmapped-row {
opacity: 0.6;
}
.search-cell {
position: relative;
}
.search-input {
display: inline !important;
width: 100%;
padding: 0.3rem 0.5rem !important;
margin: 0 !important;
border: 1px solid var(--color-bg-elevated) !important;
border-radius: 6px !important;
background: var(--color-bg-primary) !important;
color: var(--color-text-primary) !important;
font-size: 0.85rem !important;
scale: 1 !important;
}
.search-input:hover,
.search-input:focus-visible {
scale: 1 !important;
border-color: var(--color-primary) !important;
}
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
list-style: none;
margin: 2px 0 0;
padding: 0;
background: var(--color-bg-primary);
border: 1px solid var(--color-bg-elevated);
border-radius: 8px;
max-height: 240px;
overflow-y: auto;
min-width: 300px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.search-dropdown li button {
display: block;
width: 100%;
text-align: left;
padding: 0.4rem 0.6rem;
border: none;
background: none;
color: var(--color-text-primary);
font-size: 0.8rem;
cursor: pointer;
}
.search-dropdown li button:hover {
background: var(--color-bg-tertiary);
}
.search-cal {
color: var(--color-text-secondary);
margin-left: 0.5rem;
font-size: 0.75rem;
}
.source-badge {
display: inline-block;
font-size: 0.6rem;
font-weight: 700;
padding: 0.1rem 0.35rem;
border-radius: 4px;
margin-right: 0.3rem;
background: var(--nord10);
color: white;
vertical-align: middle;
}
.source-badge.bls {
background: var(--nord14);
color: var(--nord0);
}
.source-badge.skip {
background: var(--nord11);
color: white;
}
.manual-indicator {
display: inline-block;
font-size: 0.55rem;
font-weight: 700;
color: var(--nord13);
margin-left: 0.2rem;
vertical-align: super;
}
.excluded-row {
opacity: 0.4;
}
.excluded-row td {
text-decoration: line-through;
}
.excluded-row td:last-child,
.excluded-row td:nth-last-child(2),
.excluded-row td:nth-last-child(3),
.excluded-row td:nth-last-child(4) {
text-decoration: none;
}
.manual-row {
border-left: 2px solid var(--nord13);
}
.excluded-label {
font-style: italic;
color: var(--nord11);
font-size: 0.8rem;
}
.en-name {
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.current-match {
display: block;
font-size: 0.8rem;
margin-bottom: 0.2rem;
color: var(--color-text-primary);
}
.current-match.manual-match {
color: var(--nord13);
}
.search-input.has-match {
opacity: 0.5;
font-size: 0.75rem !important;
}
.search-input.has-match:focus {
opacity: 1;
font-size: 0.85rem !important;
}
.row-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.3rem;
}
.row-controls :global(.toggle-wrapper) {
font-size: 0.75rem;
}
.row-controls :global(.toggle-wrapper label) {
gap: 0.4rem;
}
.row-controls :global(.toggle-track),
.row-controls :global(input[type="checkbox"]) {
width: 32px !important;
height: 18px !important;
}
.row-controls :global(.toggle-track::before),
.row-controls :global(input[type="checkbox"]::before) {
width: 14px !important;
height: 14px !important;
}
.row-controls :global(.toggle-track.checked::before),
.row-controls :global(input[type="checkbox"]:checked::before) {
transform: translateX(14px) !important;
}
.revert-btn {
background: none;
border: 1px solid var(--color-bg-elevated);
border-radius: 4px;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.15rem 0.4rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
transition: all var(--transition-fast);
}
.revert-btn:hover {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
.skip-btn {
background: none;
border: 1px solid var(--color-bg-elevated);
border-radius: 4px;
color: var(--nord11);
cursor: pointer;
padding: 0.15rem 0.4rem;
font-size: 0.8rem;
line-height: 1;
transition: all var(--transition-fast);
}
.skip-btn:hover {
background: var(--nord11);
color: white;
}
.skip-btn.active {
background: var(--nord11);
color: white;
}
.skip-btn.active:hover {
background: var(--nord14);
color: var(--nord0);
}
.gpu-input {
display: inline !important;
width: 4em !important;
padding: 0.2rem 0.3rem !important;
margin: 0 !important;
border: 1px solid var(--color-bg-elevated) !important;
border-radius: 4px !important;
background: var(--color-bg-primary) !important;
color: var(--color-text-primary) !important;
font-size: 0.8rem !important;
scale: 1 !important;
text-align: right;
}
.gpu-input:hover, .gpu-input:focus-visible {
scale: 1 !important;
}
.section-actions {
display: flex;
gap: 0.75rem;
}
.section-btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.section-btn:hover {
opacity: 0.85;
}
.section-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.translation-section-trigger {
max-width: 1000px;
margin: 1.5rem auto;
}
</style> </style>
<svelte:head> <svelte:head>
@@ -535,35 +1006,133 @@
<input type="hidden" name="addendum" value={addendum} /> <input type="hidden" name="addendum" value={addendum} />
</div> </div>
{#if !showTranslationWorkflow} <!-- Nutrition section -->
<div class="submit_buttons"> <input type="hidden" name="nutritionMappings_json" value={JSON.stringify(nutritionMappings)} />
<button <input type="hidden" name="globalOverwrites_json" value={JSON.stringify(getGlobalOverwrites())} />
type="button"
class="action_button" {#if nutritionMappings.length > 0}
onclick={prepareSubmit} <div class="nutrition-section">
disabled={submitting} <div class="nutrition-header">
style="background-color: var(--nord14);" <h3>Nährwerte</h3>
> <button type="button" class="regenerate-btn" onclick={generateNutrition} disabled={generatingNutrition || !short_name.trim()}>
<p>Speichern & Übersetzung aktualisieren</p> {generatingNutrition ? 'Generiere…' : 'Neu generieren'}
<Check fill="white" width="2rem" height="2rem" />
</button>
{#if translationData}
<button
type="button"
class="action_button"
onclick={forceFullRetranslation}
disabled={submitting}
style="background-color: var(--nord12);"
>
<p>Komplett neu übersetzen</p>
<Check fill="white" width="2rem" height="2rem" />
</button> </button>
{/if} </div>
<div class="nutrition-table-wrapper">
<p class="nutrition-result-summary">
{nutritionMappings.filter((m) => m.matchMethod !== 'none').length}/{nutritionMappings.length} Zutaten zugeordnet
</p>
<table class="nutrition-result-table">
<thead><tr><th>#</th><th>Zutat</th><th>Quelle</th><th>Treffer / Suche</th><th>Konf.</th><th>g/u</th><th></th></tr></thead>
<tbody>
{#each nutritionMappings as m, i (mappingKey(m))}
{@const key = mappingKey(m)}
<tr class:unmapped-row={m.matchMethod === 'none' && !m.excluded} class:excluded-row={m.excluded} class:manual-row={m.manuallyEdited && !m.excluded}>
<td>{i + 1}</td>
<td>
{m.ingredientNameDe || m.ingredientName}
{#if m.ingredientName && m.ingredientName !== m.ingredientNameDe}
<span class="en-name">({m.ingredientName})</span>
{/if}
</td>
<td>
{#if m.excluded}
<span class="source-badge skip">SKIP</span>
{:else if m.matchMethod !== 'none'}
<span class="source-badge" class:bls={m.source === 'bls'}>{(m.source || 'usda').toUpperCase()}</span>
{#if m.manuallyEdited}<span class="manual-indicator" title="Manuell zugeordnet">M</span>{/if}
{:else}
{/if}
</td>
<td>
<div class="search-cell">
{#if m.excluded}
<span class="excluded-label">Übersprungen</span>
{:else if m.matchMethod !== 'none' && !searchQueries[key]}
<span class="current-match" class:manual-match={m.manuallyEdited}>{m.nutritionDbName || '—'}</span>
{/if}
<input
type="text"
class="search-input"
class:has-match={m.matchMethod !== 'none' && !m.excluded && !searchQueries[key]}
placeholder={m.excluded ? 'Suche für neuen Treffer…' : (m.matchMethod !== 'none' ? 'Überschreiben…' : 'BLS/USDA suchen…')}
value={searchQueries[key] || ''}
oninput={(e) => handleSearchInput(key, e.currentTarget.value)}
/>
{#if searchResults[key]?.length > 0}
<ul class="search-dropdown">
{#each searchResults[key] as result (result.id)}
<li>
<button
type="button"
onclick={() => assignNutritionEntry(m, result)}
>
<span class="source-badge" class:bls={result.source === 'bls'}>{result.source.toUpperCase()}</span>
{result.name}
<span class="search-cal">{Math.round(result.calories)} kcal</span>
</button>
</li>
{/each}
</ul>
{/if}
<div class="row-controls">
<Toggle checked={globalToggle[key] ?? false} label="global" onchange={() => { globalToggle[key] = !globalToggle[key]; }} />
{#if m.manuallyEdited || m.excluded}
<button type="button" class="revert-btn" onclick={() => revertToAuto(m)} title="Zurück auf automatisch">auto</button>
{/if}
</div>
</div>
</td>
<td>{m.matchConfidence ? (m.matchConfidence * 100).toFixed(0) + '%' : '—'}</td>
<td>
{#if m.manuallyEdited}
<input type="number" class="gpu-input" min="0" step="0.1" bind:value={m.gramsPerUnit} />
{:else}
{m.gramsPerUnit || '—'}
{/if}
</td>
<td>
<button
type="button"
class="skip-btn"
class:active={m.excluded}
onclick={() => m.excluded ? revertToAuto(m) : skipIngredient(m)}
title={m.excluded ? 'Wieder aktivieren' : 'Überspringen'}
>
{m.excluded ? '↩' : '✕'}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="nutrition-section">
<h3>Nährwerte</h3>
<div class="section-actions">
<button type="button" class="section-btn" onclick={generateNutrition} disabled={generatingNutrition || !short_name.trim()}>
{generatingNutrition ? 'Generiere…' : 'Generieren'}
</button>
</div>
</div>
{/if}
{#if !translationData && !showTranslationWorkflow}
<div class="translation-section-trigger">
<h3>Übersetzung</h3>
<div class="section-actions">
<button type="button" class="section-btn" onclick={openTranslation} disabled={submitting}>
Übersetzen
</button>
</div>
</div> </div>
{/if} {/if}
</form> </form>
{#if showTranslationWorkflow} {#if translationData || showTranslationWorkflow}
<div id="translation-section"> <div id="translation-section">
<TranslationApproval <TranslationApproval
germanData={currentRecipeData} germanData={currentRecipeData}
@@ -574,7 +1143,17 @@
onapproved={handleTranslationApproved} onapproved={handleTranslationApproved}
onskipped={handleTranslationSkipped} onskipped={handleTranslationSkipped}
oncancelled={handleTranslationCancelled} oncancelled={handleTranslationCancelled}
onforceFullRetranslation={forceFullRetranslation}
/> />
</div> </div>
{/if} {/if}
<!-- FAB save button -->
<button
type="button"
class="fab-save action_button"
onclick={saveRecipe}
disabled={submitting}
aria-label="Rezept speichern"
>
<Check fill="white" width="2rem" height="2rem" />
</button>

View File

@@ -4,6 +4,7 @@ import { dbConnect } from '$utils/db';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { RecipeModelType, IngredientItem, InstructionItem } from '$types/types'; import type { RecipeModelType, IngredientItem, InstructionItem } from '$types/types';
import { isEnglish } from '$lib/server/recipeHelpers'; import { isEnglish } from '$lib/server/recipeHelpers';
import { getNutritionEntryByFdcId, getBlsEntryByCode, computeRecipeNutritionTotals } from '$lib/server/nutritionMatcher';
/** Recursively map populated baseRecipeRef to resolvedRecipe field */ /** Recursively map populated baseRecipeRef to resolvedRecipe field */
function mapBaseRecipeRefs(items: any[]): any[] { function mapBaseRecipeRefs(items: any[]): any[] {
@@ -22,6 +23,91 @@ function mapBaseRecipeRefs(items: any[]): any[] {
}); });
} }
/** Resolve per100g nutrition data into mappings so client doesn't need the full DB */
function resolveNutritionData(mappings: any[]): any[] {
if (!mappings || mappings.length === 0) return [];
return mappings.map((m: any) => {
if (m.matchMethod === 'none') return m;
// BLS source: look up by blsCode
if (m.blsCode && m.source === 'bls') {
const entry = getBlsEntryByCode(m.blsCode);
if (entry) return { ...m, per100g: entry.per100g };
}
// USDA source: look up by fdcId
if (m.fdcId) {
const entry = getNutritionEntryByFdcId(m.fdcId);
if (entry) return { ...m, per100g: entry.per100g };
}
return m;
});
}
/** Parse anchor href from ingredient name, return short_name or null */
function parseAnchorRecipeRef(ingredientName: string): string | null {
const match = ingredientName.match(/<a\s+href=["']?([^"' >]+)["']?[^>]*>/i);
if (!match) return null;
let href = match[1].trim();
// Strip query params (e.g., ?multiplier={{multiplier}})
href = href.split('?')[0];
// Skip external links
if (href.startsWith('http') || href.includes('://')) return null;
// Strip leading path components like /rezepte/ or ./
href = href.replace(/^(\.?\/?rezepte\/|\.\/|\/)/, '');
// Skip if contains a dot (file extensions, external domains)
if (href.includes('.')) return null;
return href || null;
}
/**
* Build nutrition totals for referenced recipes:
* 1. Base recipe references (type='reference' with populated baseRecipeRef)
* 2. Anchor-tag references in ingredient names (<a href=...>)
*/
async function resolveReferencedNutrition(
ingredients: any[],
): Promise<{ shortName: string; name: string; nutrition: Record<string, number>; baseMultiplier: number }[]> {
const results: { shortName: string; name: string; nutrition: Record<string, number>; baseMultiplier: number }[] = [];
const processedSlugs = new Set<string>();
for (const section of ingredients) {
// Type 1: Base recipe references
if (section.type === 'reference' && section.baseRecipeRef) {
const ref = section.baseRecipeRef;
const slug = ref.short_name;
if (processedSlugs.has(slug)) continue;
processedSlugs.add(slug);
if (ref.nutritionMappings?.length > 0) {
const mult = section.baseMultiplier || 1;
const nutrition = computeRecipeNutritionTotals(ref.ingredients || [], ref.nutritionMappings, 1);
results.push({ shortName: slug, name: ref.name, nutrition, baseMultiplier: mult });
}
}
// Type 2: Anchor-tag references in ingredient names
if (section.list) {
for (const item of section.list) {
const refSlug = parseAnchorRecipeRef(item.name || '');
if (!refSlug || processedSlugs.has(refSlug)) continue;
processedSlugs.add(refSlug);
// Look up the referenced recipe
const refRecipe = await Recipe.findOne({ short_name: refSlug })
.select('short_name name ingredients nutritionMappings portions')
.lean();
if (!refRecipe?.nutritionMappings?.length) continue;
const nutrition = computeRecipeNutritionTotals(
refRecipe.ingredients || [], refRecipe.nutritionMappings, 1
);
results.push({ shortName: refSlug, name: refRecipe.name, nutrition, baseMultiplier: 1 });
}
}
}
return results;
}
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
await dbConnect(); await dbConnect();
const en = isEnglish(params.recipeLang!); const en = isEnglish(params.recipeLang!);
@@ -34,25 +120,25 @@ export const GET: RequestHandler = async ({ params }) => {
? [ ? [
{ {
path: 'translations.en.ingredients.baseRecipeRef', path: 'translations.en.ingredients.baseRecipeRef',
select: 'short_name name ingredients instructions translations', select: 'short_name name ingredients instructions translations nutritionMappings portions',
populate: { populate: {
path: 'ingredients.baseRecipeRef', path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients instructions translations', select: 'short_name name ingredients instructions translations nutritionMappings portions',
populate: { populate: {
path: 'ingredients.baseRecipeRef', path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients instructions translations' select: 'short_name name ingredients instructions translations nutritionMappings portions'
} }
} }
}, },
{ {
path: 'translations.en.instructions.baseRecipeRef', path: 'translations.en.instructions.baseRecipeRef',
select: 'short_name name ingredients instructions translations', select: 'short_name name ingredients instructions translations nutritionMappings portions',
populate: { populate: {
path: 'instructions.baseRecipeRef', path: 'instructions.baseRecipeRef',
select: 'short_name name ingredients instructions translations', select: 'short_name name ingredients instructions translations nutritionMappings portions',
populate: { populate: {
path: 'instructions.baseRecipeRef', path: 'instructions.baseRecipeRef',
select: 'short_name name ingredients instructions translations' select: 'short_name name ingredients instructions translations nutritionMappings portions'
} }
} }
} }
@@ -60,13 +146,13 @@ export const GET: RequestHandler = async ({ params }) => {
: [ : [
{ {
path: 'ingredients.baseRecipeRef', path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations', select: 'short_name name ingredients translations nutritionMappings portions',
populate: { populate: {
path: 'ingredients.baseRecipeRef', path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations', select: 'short_name name ingredients translations nutritionMappings portions',
populate: { populate: {
path: 'ingredients.baseRecipeRef', path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations' select: 'short_name name ingredients translations nutritionMappings portions'
} }
} }
}, },
@@ -126,6 +212,7 @@ export const GET: RequestHandler = async ({ params }) => {
total_time: t.total_time || rawRecipe.total_time || '', total_time: t.total_time || rawRecipe.total_time || '',
translationStatus: t.translationStatus, translationStatus: t.translationStatus,
germanShortName: rawRecipe.short_name, germanShortName: rawRecipe.short_name,
nutritionMappings: resolveNutritionData(rawRecipe.nutritionMappings || []),
}; };
if (recipe.ingredients) { if (recipe.ingredients) {
@@ -135,6 +222,9 @@ export const GET: RequestHandler = async ({ params }) => {
recipe.instructions = mapBaseRecipeRefs(recipe.instructions as any[]); recipe.instructions = mapBaseRecipeRefs(recipe.instructions as any[]);
} }
// Resolve nutrition from referenced recipes (base refs + anchor tags)
recipe.referencedNutrition = await resolveReferencedNutrition(rawRecipe.ingredients || []);
// Merge English alt/caption with original image paths // Merge English alt/caption with original image paths
const imagesArray = Array.isArray(rawRecipe.images) ? rawRecipe.images : (rawRecipe.images ? [rawRecipe.images] : []); const imagesArray = Array.isArray(rawRecipe.images) ? rawRecipe.images : (rawRecipe.images ? [rawRecipe.images] : []);
if (imagesArray.length > 0) { if (imagesArray.length > 0) {
@@ -152,11 +242,14 @@ export const GET: RequestHandler = async ({ params }) => {
// German: pass through with base recipe ref mapping // German: pass through with base recipe ref mapping
let recipe = JSON.parse(JSON.stringify(rawRecipe)); let recipe = JSON.parse(JSON.stringify(rawRecipe));
recipe.nutritionMappings = resolveNutritionData(recipe.nutritionMappings || []);
if (recipe.ingredients) { if (recipe.ingredients) {
recipe.ingredients = mapBaseRecipeRefs(recipe.ingredients); recipe.ingredients = mapBaseRecipeRefs(recipe.ingredients);
} }
if (recipe.instructions) { if (recipe.instructions) {
recipe.instructions = mapBaseRecipeRefs(recipe.instructions); recipe.instructions = mapBaseRecipeRefs(recipe.instructions);
} }
// Resolve nutrition from referenced recipes (base refs + anchor tags)
recipe.referencedNutrition = await resolveReferencedNutrition(rawRecipe.ingredients || []);
return json(recipe); return json(recipe);
}; };

View File

@@ -0,0 +1,80 @@
import { json, error, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import { isEnglish } from '$lib/server/recipeHelpers';
import { getNutritionEntryByFdcId, getBlsEntryByCode, invalidateOverwriteCache } from '$lib/server/nutritionMatcher';
import { NutritionOverwrite } from '$models/NutritionOverwrite';
import { canonicalizeUnit, resolveGramsPerUnit } from '$lib/data/unitConversions';
import type { NutritionMapping } from '$types/types';
/** PATCH: Update individual nutrition mappings (manual edit UI) */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
await locals.auth();
await dbConnect();
const en = isEnglish(params.recipeLang!);
const query = en
? { 'translations.en.short_name': params.name }
: { short_name: params.name };
const recipe = await Recipe.findOne(query);
if (!recipe) throw error(404, 'Recipe not found');
const updates: (Partial<NutritionMapping> & { global?: boolean; ingredientNameDe?: string })[] = await request.json();
const mappings: any[] = recipe.nutritionMappings || [];
for (const update of updates) {
// If global flag is set, also create/update a NutritionOverwrite
if (update.global && update.ingredientNameDe) {
const owData: Record<string, any> = {
ingredientNameDe: update.ingredientNameDe.toLowerCase().trim(),
source: update.excluded ? 'skip' : (update.source || 'usda'),
excluded: update.excluded || false,
};
if (update.ingredientName) owData.ingredientNameEn = update.ingredientName;
if (update.fdcId) owData.fdcId = update.fdcId;
if (update.blsCode) owData.blsCode = update.blsCode;
if (update.nutritionDbName) owData.nutritionDbName = update.nutritionDbName;
await NutritionOverwrite.findOneAndUpdate(
{ ingredientNameDe: owData.ingredientNameDe },
owData,
{ upsert: true, runValidators: true },
);
invalidateOverwriteCache();
}
// Resolve gramsPerUnit from source DB portions if not provided
if (!update.gramsPerUnit && !update.excluded) {
if (update.blsCode && update.source === 'bls') {
update.gramsPerUnit = 1;
update.unitConversionSource = update.unitConversionSource || 'manual';
} else if (update.fdcId) {
const entry = getNutritionEntryByFdcId(update.fdcId);
if (entry) {
const resolved = resolveGramsPerUnit('g', entry.portions);
update.gramsPerUnit = resolved.grams;
update.unitConversionSource = resolved.source;
}
}
}
// Clean up non-schema fields before saving
delete update.global;
delete update.ingredientNameDe;
const idx = mappings.findIndex(
(m: any) => m.sectionIndex === update.sectionIndex && m.ingredientIndex === update.ingredientIndex
);
if (idx >= 0) {
Object.assign(mappings[idx], update, { manuallyEdited: true });
} else {
mappings.push({ ...update, manuallyEdited: true });
}
}
recipe.nutritionMappings = mappings;
await recipe.save();
return json({ updated: updates.length });
};

View File

@@ -0,0 +1,51 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import { generateNutritionMappings } from '$lib/server/nutritionMatcher';
export const POST: RequestHandler = async ({ locals }) => {
await locals.auth();
await dbConnect();
const recipes = await Recipe.find({}).lean();
const results: { name: string; mapped: number; total: number }[] = [];
for (const recipe of recipes) {
const ingredients = recipe.ingredients || [];
const translatedIngredients = recipe.translations?.en?.ingredients;
// Preserve manually edited mappings
const existingMappings = recipe.nutritionMappings || [];
const manualMappings = new Map(
existingMappings
.filter((m: any) => m.manuallyEdited)
.map((m: any) => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
);
const newMappings = await generateNutritionMappings(ingredients, translatedIngredients);
const finalMappings = newMappings.map(m => {
const key = `${m.sectionIndex}-${m.ingredientIndex}`;
return manualMappings.get(key) || m;
});
await Recipe.updateOne(
{ _id: recipe._id },
{ $set: { nutritionMappings: finalMappings } }
);
const mapped = finalMappings.filter(m => m.matchMethod !== 'none').length;
results.push({ name: recipe.name, mapped, total: finalMappings.length });
}
const totalMapped = results.reduce((sum, r) => sum + r.mapped, 0);
const totalIngredients = results.reduce((sum, r) => sum + r.total, 0);
return json({
recipes: results.length,
totalIngredients,
totalMapped,
coverage: totalIngredients ? (totalMapped / totalIngredients * 100).toFixed(1) + '%' : '0%',
details: results,
});
};

View File

@@ -0,0 +1,50 @@
import { json, error, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import { isEnglish } from '$lib/server/recipeHelpers';
import { generateNutritionMappings } from '$lib/server/nutritionMatcher';
export const POST: RequestHandler = async ({ params, locals, url }) => {
await locals.auth();
await dbConnect();
const en = isEnglish(params.recipeLang!);
const query = en
? { 'translations.en.short_name': params.name }
: { short_name: params.name };
const recipe = await Recipe.findOne(query).lean();
if (!recipe) throw error(404, 'Recipe not found');
const ingredients = recipe.ingredients || [];
const translatedIngredients = recipe.translations?.en?.ingredients;
const newMappings = await generateNutritionMappings(ingredients, translatedIngredients);
const preview = url.searchParams.get('preview') === 'true';
// In preview mode, return pure auto-matches without saving (client merges manual edits)
if (preview) {
return json({ mappings: newMappings, count: newMappings.length });
}
// Preserve manually edited mappings
const existingMappings = recipe.nutritionMappings || [];
const manualMappings = new Map(
existingMappings
.filter((m: any) => m.manuallyEdited)
.map((m: any) => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
);
// Merge: keep manual edits, use new auto-matches for the rest
const finalMappings = newMappings.map(m => {
const key = `${m.sectionIndex}-${m.ingredientIndex}`;
return manualMappings.get(key) || m;
});
await Recipe.updateOne(
{ _id: recipe._id },
{ $set: { nutritionMappings: finalMappings } }
);
return json({ mappings: finalMappings, count: finalMappings.length });
};

View File

@@ -0,0 +1,60 @@
import { json, error, type RequestHandler } from '@sveltejs/kit';
import { dbConnect } from '$utils/db';
import { NutritionOverwrite } from '$models/NutritionOverwrite';
import { invalidateOverwriteCache } from '$lib/server/nutritionMatcher';
/** GET: List all global nutrition overwrites */
export const GET: RequestHandler = async ({ locals }) => {
await locals.auth();
await dbConnect();
const overwrites = await NutritionOverwrite.find({}).sort({ ingredientNameDe: 1 }).lean();
return json(overwrites);
};
/** POST: Create a new global nutrition overwrite */
export const POST: RequestHandler = async ({ request, locals }) => {
await locals.auth();
await dbConnect();
const body = await request.json();
if (!body.ingredientNameDe || !body.source) {
throw error(400, 'ingredientNameDe and source are required');
}
const data: Record<string, any> = {
ingredientNameDe: body.ingredientNameDe.toLowerCase().trim(),
source: body.source,
};
if (body.ingredientNameEn) data.ingredientNameEn = body.ingredientNameEn;
if (body.fdcId) data.fdcId = body.fdcId;
if (body.blsCode) data.blsCode = body.blsCode;
if (body.nutritionDbName) data.nutritionDbName = body.nutritionDbName;
if (body.source === 'skip') data.excluded = true;
const overwrite = await NutritionOverwrite.findOneAndUpdate(
{ ingredientNameDe: data.ingredientNameDe },
data,
{ upsert: true, new: true, runValidators: true },
).lean();
invalidateOverwriteCache();
return json(overwrite, { status: 201 });
};
/** DELETE: Remove a global nutrition overwrite by ingredientNameDe */
export const DELETE: RequestHandler = async ({ request, locals }) => {
await locals.auth();
await dbConnect();
const body = await request.json();
if (!body.ingredientNameDe) {
throw error(400, 'ingredientNameDe is required');
}
const result = await NutritionOverwrite.deleteOne({
ingredientNameDe: body.ingredientNameDe.toLowerCase().trim(),
});
invalidateOverwriteCache();
return json({ deleted: result.deletedCount });
};

View File

@@ -0,0 +1,48 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { NUTRITION_DB } from '$lib/data/nutritionDb';
import { BLS_DB } from '$lib/data/blsDb';
import { fuzzyScore } from '$lib/js/fuzzy';
/** GET: Search BLS + USDA nutrition databases by fuzzy name match */
export const GET: RequestHandler = async ({ url }) => {
const q = (url.searchParams.get('q') || '').toLowerCase().trim();
if (q.length < 2) return json([]);
const scored: { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number; score: number }[] = [];
// Search BLS (primary)
for (const entry of BLS_DB) {
const scoreDe = fuzzyScore(q, entry.nameDe.toLowerCase());
const scoreEn = entry.nameEn ? fuzzyScore(q, entry.nameEn.toLowerCase()) : 0;
const best = Math.max(scoreDe, scoreEn);
if (best > 0) {
scored.push({
source: 'bls',
id: entry.blsCode,
name: `${entry.nameDe}${entry.nameEn ? ` (${entry.nameEn})` : ''}`,
category: entry.category,
calories: entry.per100g.calories,
score: best,
});
}
}
// Search USDA
for (const entry of NUTRITION_DB) {
const s = fuzzyScore(q, entry.name.toLowerCase());
if (s > 0) {
scored.push({
source: 'usda',
id: String(entry.fdcId),
name: entry.name,
category: entry.category,
calories: entry.per100g.calories,
score: s,
});
}
}
// Sort by score descending, return top 30 (without score field)
scored.sort((a, b) => b.score - a.score);
return json(scored.slice(0, 30).map(({ score, ...rest }) => rest));
};

View File

@@ -1,3 +1,40 @@
// Nutrition per-100g data shape (shared by BLS and USDA sources)
export type NutritionPer100g = {
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;
// Amino acids (g/100g) — available from BLS, partially from USDA
isoleucine?: number; leucine?: number; lysine?: number; methionine?: number;
phenylalanine?: number; threonine?: number; tryptophan?: number; valine?: number;
histidine?: number; alanine?: number; arginine?: number; asparticAcid?: number;
cysteine?: number; glutamicAcid?: number; glycine?: number; proline?: number;
serine?: number; tyrosine?: number;
};
// Nutrition mapping for calorie/macro tracking per ingredient
export type NutritionMapping = {
sectionIndex: number;
ingredientIndex: number;
ingredientName?: string;
ingredientNameDe?: string;
source?: 'bls' | 'usda' | 'manual';
fdcId?: number;
blsCode?: string;
nutritionDbName?: string;
matchConfidence?: number;
matchMethod: 'exact' | 'embedding' | 'manual' | 'none';
gramsPerUnit?: number;
defaultAmountUsed?: boolean;
unitConversionSource: 'direct' | 'density' | 'usda_portion' | 'estimate' | 'manual' | 'none';
manuallyEdited: boolean;
excluded: boolean;
per100g?: NutritionPer100g;
};
// Translation status enum // Translation status enum
export type TranslationStatus = 'pending' | 'approved' | 'needs_update'; export type TranslationStatus = 'pending' | 'approved' | 'needs_update';
@@ -163,6 +200,7 @@ export type RecipeModelType = {
addendum?: string addendum?: string
note?: string; note?: string;
isBaseRecipe?: boolean; isBaseRecipe?: boolean;
nutritionMappings?: NutritionMapping[];
translations?: { translations?: {
en?: TranslatedRecipeType; en?: TranslatedRecipeType;
}; };