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
This commit is contained in:
@@ -4,6 +4,7 @@ import { dbConnect } from '$utils/db';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RecipeModelType, IngredientItem, InstructionItem } from '$types/types';
|
||||
import { isEnglish } from '$lib/server/recipeHelpers';
|
||||
import { getNutritionEntryByFdcId, getBlsEntryByCode, computeRecipeNutritionTotals } from '$lib/server/nutritionMatcher';
|
||||
|
||||
/** Recursively map populated baseRecipeRef to resolvedRecipe field */
|
||||
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 }) => {
|
||||
await dbConnect();
|
||||
const en = isEnglish(params.recipeLang!);
|
||||
@@ -34,25 +120,25 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
? [
|
||||
{
|
||||
path: 'translations.en.ingredients.baseRecipeRef',
|
||||
select: 'short_name name ingredients instructions translations',
|
||||
select: 'short_name name ingredients instructions translations nutritionMappings portions',
|
||||
populate: {
|
||||
path: 'ingredients.baseRecipeRef',
|
||||
select: 'short_name name ingredients instructions translations',
|
||||
select: 'short_name name ingredients instructions translations nutritionMappings portions',
|
||||
populate: {
|
||||
path: 'ingredients.baseRecipeRef',
|
||||
select: 'short_name name ingredients instructions translations'
|
||||
select: 'short_name name ingredients instructions translations nutritionMappings portions'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'translations.en.instructions.baseRecipeRef',
|
||||
select: 'short_name name ingredients instructions translations',
|
||||
select: 'short_name name ingredients instructions translations nutritionMappings portions',
|
||||
populate: {
|
||||
path: 'instructions.baseRecipeRef',
|
||||
select: 'short_name name ingredients instructions translations',
|
||||
select: 'short_name name ingredients instructions translations nutritionMappings portions',
|
||||
populate: {
|
||||
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',
|
||||
select: 'short_name name ingredients translations',
|
||||
select: 'short_name name ingredients translations nutritionMappings portions',
|
||||
populate: {
|
||||
path: 'ingredients.baseRecipeRef',
|
||||
select: 'short_name name ingredients translations',
|
||||
select: 'short_name name ingredients translations nutritionMappings portions',
|
||||
populate: {
|
||||
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 || '',
|
||||
translationStatus: t.translationStatus,
|
||||
germanShortName: rawRecipe.short_name,
|
||||
nutritionMappings: resolveNutritionData(rawRecipe.nutritionMappings || []),
|
||||
};
|
||||
|
||||
if (recipe.ingredients) {
|
||||
@@ -135,6 +222,9 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
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
|
||||
const imagesArray = Array.isArray(rawRecipe.images) ? rawRecipe.images : (rawRecipe.images ? [rawRecipe.images] : []);
|
||||
if (imagesArray.length > 0) {
|
||||
@@ -152,11 +242,14 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
|
||||
// German: pass through with base recipe ref mapping
|
||||
let recipe = JSON.parse(JSON.stringify(rawRecipe));
|
||||
recipe.nutritionMappings = resolveNutritionData(recipe.nutritionMappings || []);
|
||||
if (recipe.ingredients) {
|
||||
recipe.ingredients = mapBaseRecipeRefs(recipe.ingredients);
|
||||
}
|
||||
if (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);
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
Reference in New Issue
Block a user