fix: enable nested base recipe references to display correctly
All checks were successful
CI / update (push) Successful in 1m10s

- Add recursive population for nested base recipe references (up to 3 levels deep) in API endpoints
- Implement recursive mapping of baseRecipeRef to resolvedRecipe for all nesting levels
- Add recursive flattening functions in frontend components to handle nested references
- Fix TranslationApproval to use short_name instead of ObjectId for base recipe lookups
- Add circular reference detection to prevent infinite loops

This ensures that when Recipe A references Recipe B as a base, and Recipe B references Recipe C, all three recipes' content is properly displayed.
This commit is contained in:
2026-01-04 23:41:47 +01:00
parent 8a152c5fb2
commit 8eacf1f5d0
5 changed files with 180 additions and 87 deletions

View File

@@ -7,27 +7,32 @@ import HefeSwapper from './HefeSwapper.svelte';
let { data } = $props(); let { data } = $props();
// Flatten ingredient references for display // Recursively flatten nested ingredient references
const flattenedIngredients = $derived.by(() => { function flattenIngredientReferences(items, lang, visited = new Set()) {
if (!data.ingredients) return []; const result = [];
return data.ingredients.flatMap((item) => { for (const item of items) {
if (item.type === 'reference' && item.resolvedRecipe) { if (item.type === 'reference' && item.resolvedRecipe) {
const sections = []; // Prevent circular references
const recipeId = item.resolvedRecipe._id?.toString() || item.resolvedRecipe.short_name;
if (visited.has(recipeId)) {
console.warn('Circular reference detected:', recipeId);
continue;
}
const newVisited = new Set(visited);
newVisited.add(recipeId);
// Get translated or original ingredients // Get translated or original ingredients
const lang = data.lang || 'de';
const ingredientsToUse = (lang === 'en' && const ingredientsToUse = (lang === 'en' &&
item.resolvedRecipe.translations?.en?.ingredients) item.resolvedRecipe.translations?.en?.ingredients)
? item.resolvedRecipe.translations.en.ingredients ? item.resolvedRecipe.translations.en.ingredients
: item.resolvedRecipe.ingredients || []; : item.resolvedRecipe.ingredients || [];
// Filter to only sections (not nested references) // Recursively flatten nested references
const baseIngredients = item.includeIngredients const flattenedNested = flattenIngredientReferences(ingredientsToUse, lang, newVisited);
? ingredientsToUse.filter(i => i.type === 'section' || !i.type)
: [];
// Combine all items into one section // Combine all items into one list
const combinedList = []; const combinedList = [];
// Add items before // Add items before
@@ -35,12 +40,14 @@ const flattenedIngredients = $derived.by(() => {
combinedList.push(...item.itemsBefore); combinedList.push(...item.itemsBefore);
} }
// Add base recipe ingredients // Add base recipe ingredients (now recursively flattened)
baseIngredients.forEach(section => { if (item.includeIngredients) {
flattenedNested.forEach(section => {
if (section.list) { if (section.list) {
combinedList.push(...section.list); combinedList.push(...section.list);
} }
}); });
}
// Add items after // Add items after
if (item.itemsAfter && item.itemsAfter.length > 0) { if (item.itemsAfter && item.itemsAfter.length > 0) {
@@ -49,12 +56,11 @@ const flattenedIngredients = $derived.by(() => {
// Push as one section with optional label // Push as one section with optional label
if (combinedList.length > 0) { if (combinedList.length > 0) {
// Use labelOverride if present, otherwise use base recipe name (translated if viewing in English)
const baseRecipeName = (lang === 'en' && item.resolvedRecipe.translations?.en?.name) const baseRecipeName = (lang === 'en' && item.resolvedRecipe.translations?.en?.name)
? item.resolvedRecipe.translations.en.name ? item.resolvedRecipe.translations.en.name
: item.resolvedRecipe.name; : item.resolvedRecipe.name;
sections.push({ result.push({
type: 'section', type: 'section',
name: item.showLabel ? (item.labelOverride || baseRecipeName) : '', name: item.showLabel ? (item.labelOverride || baseRecipeName) : '',
list: combinedList, list: combinedList,
@@ -62,13 +68,20 @@ const flattenedIngredients = $derived.by(() => {
short_name: item.resolvedRecipe.short_name short_name: item.resolvedRecipe.short_name
}); });
} }
} else if (item.type === 'section' || !item.type) {
return sections; // Regular section - pass through
result.push(item);
}
} }
// Regular section - pass through return result;
return [item]; }
});
// Flatten ingredient references for display
const flattenedIngredients = $derived.by(() => {
if (!data.ingredients) return [];
const lang = data.lang || 'de';
return flattenIngredientReferences(data.ingredients, lang);
}); });
let multiplier = $state(data.multiplier || 1); let multiplier = $state(data.multiplier || 1);

View File

@@ -3,25 +3,32 @@ let { data } = $props();
let multiplier = $state(data.multiplier || 1); let multiplier = $state(data.multiplier || 1);
// Flatten instruction references for display // Recursively flatten nested instruction references
const flattenedInstructions = $derived.by(() => { function flattenInstructionReferences(items, lang, visited = new Set()) {
if (!data.instructions) return []; const result = [];
return data.instructions.flatMap((item) => { for (const item of items) {
if (item.type === 'reference' && item.resolvedRecipe) { if (item.type === 'reference' && item.resolvedRecipe) {
// Prevent circular references
const recipeId = item.resolvedRecipe._id?.toString() || item.resolvedRecipe.short_name;
if (visited.has(recipeId)) {
console.warn('Circular reference detected:', recipeId);
continue;
}
const newVisited = new Set(visited);
newVisited.add(recipeId);
// Get translated or original instructions // Get translated or original instructions
const lang = data.lang || 'de';
const instructionsToUse = (lang === 'en' && const instructionsToUse = (lang === 'en' &&
item.resolvedRecipe.translations?.en?.instructions) item.resolvedRecipe.translations?.en?.instructions)
? item.resolvedRecipe.translations.en.instructions ? item.resolvedRecipe.translations.en.instructions
: item.resolvedRecipe.instructions || []; : item.resolvedRecipe.instructions || [];
// Filter to only sections (not nested references) // Recursively flatten nested references
const baseInstructions = item.includeInstructions const flattenedNested = flattenInstructionReferences(instructionsToUse, lang, newVisited);
? instructionsToUse.filter(i => i.type === 'section' || !i.type)
: [];
// Combine all steps into one section // Combine all steps into one list
const combinedSteps = []; const combinedSteps = [];
// Add steps before // Add steps before
@@ -29,12 +36,14 @@ const flattenedInstructions = $derived.by(() => {
combinedSteps.push(...item.stepsBefore); combinedSteps.push(...item.stepsBefore);
} }
// Add base recipe instructions // Add base recipe instructions (now recursively flattened)
baseInstructions.forEach(section => { if (item.includeInstructions) {
flattenedNested.forEach(section => {
if (section.steps) { if (section.steps) {
combinedSteps.push(...section.steps); combinedSteps.push(...section.steps);
} }
}); });
}
// Add steps after // Add steps after
if (item.stepsAfter && item.stepsAfter.length > 0) { if (item.stepsAfter && item.stepsAfter.length > 0) {
@@ -43,26 +52,32 @@ const flattenedInstructions = $derived.by(() => {
// Push as one section with optional label // Push as one section with optional label
if (combinedSteps.length > 0) { if (combinedSteps.length > 0) {
// Use labelOverride if present, otherwise use base recipe name (translated if viewing in English)
const baseRecipeName = (lang === 'en' && item.resolvedRecipe.translations?.en?.name) const baseRecipeName = (lang === 'en' && item.resolvedRecipe.translations?.en?.name)
? item.resolvedRecipe.translations.en.name ? item.resolvedRecipe.translations.en.name
: item.resolvedRecipe.name; : item.resolvedRecipe.name;
return [{ result.push({
type: 'section', type: 'section',
name: item.showLabel ? (item.labelOverride || baseRecipeName) : '', name: item.showLabel ? (item.labelOverride || baseRecipeName) : '',
steps: combinedSteps, steps: combinedSteps,
isReference: item.showLabel, isReference: item.showLabel,
short_name: item.resolvedRecipe.short_name short_name: item.resolvedRecipe.short_name
}];
}
return [];
}
// Regular section - pass through
return [item];
}); });
}
} else if (item.type === 'section' || !item.type) {
// Regular section - pass through
result.push(item);
}
}
return result;
}
// Flatten instruction references for display
const flattenedInstructions = $derived.by(() => {
if (!data.instructions) return [];
const lang = data.lang || 'de';
return flattenInstructionReferences(data.instructions, lang);
}); });
const isEnglish = $derived(data.lang === 'en'); const isEnglish = $derived(data.lang === 'en');

View File

@@ -27,7 +27,7 @@
let translationMetadata: any = null; let translationMetadata: any = null;
// Track base recipes that need translation // Track base recipes that need translation
let untranslatedBaseRecipes: { id: string, name: string }[] = []; let untranslatedBaseRecipes: { shortName: string, name: string }[] = [];
let checkingBaseRecipes = false; let checkingBaseRecipes = false;
// Sync base recipe references from German to English // Sync base recipe references from German to English
@@ -36,21 +36,32 @@
checkingBaseRecipes = true; checkingBaseRecipes = true;
// Helper to extract short_name from baseRecipeRef (which might be an object or string)
const getShortName = (baseRecipeRef: any): string => {
return typeof baseRecipeRef === 'object' ? baseRecipeRef.short_name : baseRecipeRef;
};
// Collect all base recipe references from German data // Collect all base recipe references from German data
const germanBaseRecipeIds = new Set<string>(); const germanBaseRecipeShortNames = new Set<string>();
const baseRecipeRefMap = new Map<string, any>(); // Map short_name to baseRecipeRef (ID or object)
(germanData.ingredients || []).forEach((ing: any) => { (germanData.ingredients || []).forEach((ing: any) => {
if (ing.type === 'reference' && ing.baseRecipeRef) { if (ing.type === 'reference' && ing.baseRecipeRef) {
germanBaseRecipeIds.add(ing.baseRecipeRef); const shortName = getShortName(ing.baseRecipeRef);
germanBaseRecipeShortNames.add(shortName);
baseRecipeRefMap.set(shortName, ing.baseRecipeRef);
} }
}); });
(germanData.instructions || []).forEach((inst: any) => { (germanData.instructions || []).forEach((inst: any) => {
if (inst.type === 'reference' && inst.baseRecipeRef) { if (inst.type === 'reference' && inst.baseRecipeRef) {
germanBaseRecipeIds.add(inst.baseRecipeRef); const shortName = getShortName(inst.baseRecipeRef);
germanBaseRecipeShortNames.add(shortName);
baseRecipeRefMap.set(shortName, inst.baseRecipeRef);
} }
}); });
// If no base recipes in German, just initialize editableEnglish from German data if needed // If no base recipes in German, just initialize editableEnglish from German data if needed
if (germanBaseRecipeIds.size === 0) { if (germanBaseRecipeShortNames.size === 0) {
if (!editableEnglish) { if (!editableEnglish) {
editableEnglish = { editableEnglish = {
...germanData, ...germanData,
@@ -64,25 +75,25 @@
} }
// Fetch all base recipes and check their English translations // Fetch all base recipes and check their English translations
const untranslated: { id: string, name: string }[] = []; const untranslated: { shortName: string, name: string }[] = [];
const baseRecipeTranslations = new Map<string, { deName: string, enName: string }>(); const baseRecipeTranslations = new Map<string, { deName: string, enName: string }>();
for (const recipeId of germanBaseRecipeIds) { for (const shortName of germanBaseRecipeShortNames) {
try { try {
const response = await fetch(`/api/rezepte/${recipeId}`); const response = await fetch(`/api/rezepte/items/${shortName}`);
if (response.ok) { if (response.ok) {
const recipe = await response.json(); const recipe = await response.json();
if (!recipe.translations?.en) { if (!recipe.translations?.en) {
untranslated.push({ id: recipeId, name: recipe.name }); untranslated.push({ shortName, name: recipe.name });
} else { } else {
baseRecipeTranslations.set(recipeId, { baseRecipeTranslations.set(shortName, {
deName: recipe.name, deName: recipe.name,
enName: recipe.translations.en.name enName: recipe.translations.en.name
}); });
} }
} }
} catch (error) { } catch (error) {
console.error(`Error fetching base recipe ${recipeId}:`, error); console.error(`Error fetching base recipe ${shortName}:`, error);
} }
} }
@@ -104,14 +115,16 @@
translationStatus: 'pending', translationStatus: 'pending',
ingredients: JSON.parse(JSON.stringify(germanData.ingredients || [])).map((ing: any) => { ingredients: JSON.parse(JSON.stringify(germanData.ingredients || [])).map((ing: any) => {
if (ing.type === 'reference' && ing.baseRecipeRef) { if (ing.type === 'reference' && ing.baseRecipeRef) {
const translation = baseRecipeTranslations.get(ing.baseRecipeRef); const shortName = getShortName(ing.baseRecipeRef);
const translation = baseRecipeTranslations.get(shortName);
return translation ? { ...ing, name: translation.enName } : ing; return translation ? { ...ing, name: translation.enName } : ing;
} }
return ing; return ing;
}), }),
instructions: JSON.parse(JSON.stringify(germanData.instructions || [])).map((inst: any) => { instructions: JSON.parse(JSON.stringify(germanData.instructions || [])).map((inst: any) => {
if (inst.type === 'reference' && inst.baseRecipeRef) { if (inst.type === 'reference' && inst.baseRecipeRef) {
const translation = baseRecipeTranslations.get(inst.baseRecipeRef); const shortName = getShortName(inst.baseRecipeRef);
const translation = baseRecipeTranslations.get(shortName);
return translation ? { ...inst, name: translation.enName } : inst; return translation ? { ...inst, name: translation.enName } : inst;
} }
return inst; return inst;
@@ -125,7 +138,8 @@
ingredients: germanData.ingredients.map((germanIng: any, index: number) => { ingredients: germanData.ingredients.map((germanIng: any, index: number) => {
if (germanIng.type === 'reference' && germanIng.baseRecipeRef) { if (germanIng.type === 'reference' && germanIng.baseRecipeRef) {
// This is a base recipe reference - use English base recipe name // This is a base recipe reference - use English base recipe name
const translation = baseRecipeTranslations.get(germanIng.baseRecipeRef); const shortName = getShortName(germanIng.baseRecipeRef);
const translation = baseRecipeTranslations.get(shortName);
const englishIng = editableEnglish.ingredients[index]; const englishIng = editableEnglish.ingredients[index];
// If English already has this reference at same position, keep it // If English already has this reference at same position, keep it
@@ -148,7 +162,8 @@
instructions: germanData.instructions.map((germanInst: any, index: number) => { instructions: germanData.instructions.map((germanInst: any, index: number) => {
if (germanInst.type === 'reference' && germanInst.baseRecipeRef) { if (germanInst.type === 'reference' && germanInst.baseRecipeRef) {
// This is a base recipe reference - use English base recipe name // This is a base recipe reference - use English base recipe name
const translation = baseRecipeTranslations.get(germanInst.baseRecipeRef); const shortName = getShortName(germanInst.baseRecipeRef);
const translation = baseRecipeTranslations.get(shortName);
const englishInst = editableEnglish.instructions[index]; const englishInst = editableEnglish.instructions[index];
// If English already has this reference at same position, keep it // If English already has this reference at same position, keep it

View File

@@ -17,11 +17,27 @@ export const GET: RequestHandler = async ({ params }) => {
}) })
.populate({ .populate({
path: 'translations.en.ingredients.baseRecipeRef', path: 'translations.en.ingredients.baseRecipeRef',
select: 'short_name name ingredients instructions translations',
populate: {
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients instructions translations',
populate: {
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients instructions translations' select: 'short_name name ingredients instructions translations'
}
}
}) })
.populate({ .populate({
path: 'translations.en.instructions.baseRecipeRef', path: 'translations.en.instructions.baseRecipeRef',
select: 'short_name name ingredients instructions translations',
populate: {
path: 'instructions.baseRecipeRef',
select: 'short_name name ingredients instructions translations',
populate: {
path: 'instructions.baseRecipeRef',
select: 'short_name name ingredients instructions translations' select: 'short_name name ingredients instructions translations'
}
}
}) })
.lean(); .lean();
@@ -64,23 +80,32 @@ export const GET: RequestHandler = async ({ params }) => {
germanShortName: recipe.short_name, germanShortName: recipe.short_name,
}; };
// Map populated base recipe refs to resolvedRecipe field // Recursively map populated base recipe refs to resolvedRecipe field
if (englishRecipe.ingredients) { function mapBaseRecipeRefs(items: any[]): any[] {
englishRecipe.ingredients = englishRecipe.ingredients.map((item: any) => { return items.map((item: any) => {
if (item.type === 'reference' && item.baseRecipeRef) { if (item.type === 'reference' && item.baseRecipeRef) {
return { ...item, resolvedRecipe: item.baseRecipeRef }; const resolvedRecipe = { ...item.baseRecipeRef };
// Recursively map nested baseRecipeRefs
if (resolvedRecipe.ingredients) {
resolvedRecipe.ingredients = mapBaseRecipeRefs(resolvedRecipe.ingredients);
}
if (resolvedRecipe.instructions) {
resolvedRecipe.instructions = mapBaseRecipeRefs(resolvedRecipe.instructions);
}
return { ...item, resolvedRecipe };
} }
return item; return item;
}); });
} }
if (englishRecipe.instructions) { if (englishRecipe.ingredients) {
englishRecipe.instructions = englishRecipe.instructions.map((item: any) => { englishRecipe.ingredients = mapBaseRecipeRefs(englishRecipe.ingredients);
if (item.type === 'reference' && item.baseRecipeRef) {
return { ...item, resolvedRecipe: item.baseRecipeRef };
} }
return item;
}); if (englishRecipe.instructions) {
englishRecipe.instructions = mapBaseRecipeRefs(englishRecipe.instructions);
} }
// Merge English alt/caption with original image paths // Merge English alt/caption with original image paths

View File

@@ -8,12 +8,28 @@ export const GET: RequestHandler = async ({params}) => {
await dbConnect(); await dbConnect();
let recipe = await Recipe.findOne({ short_name: params.name}) let recipe = await Recipe.findOne({ short_name: params.name})
.populate({ .populate({
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations',
populate: {
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations',
populate: {
path: 'ingredients.baseRecipeRef', path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations' select: 'short_name name ingredients translations'
}
}
}) })
.populate({ .populate({
path: 'instructions.baseRecipeRef',
select: 'short_name name instructions translations',
populate: {
path: 'instructions.baseRecipeRef',
select: 'short_name name instructions translations',
populate: {
path: 'instructions.baseRecipeRef', path: 'instructions.baseRecipeRef',
select: 'short_name name instructions translations' select: 'short_name name instructions translations'
}
}
}) })
.lean() as RecipeModelType[]; .lean() as RecipeModelType[];
@@ -22,23 +38,32 @@ export const GET: RequestHandler = async ({params}) => {
throw error(404, "Recipe not found") throw error(404, "Recipe not found")
} }
// Map populated refs to resolvedRecipe field // Recursively map populated refs to resolvedRecipe field
if (recipe?.ingredients) { function mapBaseRecipeRefs(items: any[]): any[] {
recipe.ingredients = recipe.ingredients.map((item: any) => { return items.map((item: any) => {
if (item.type === 'reference' && item.baseRecipeRef) { if (item.type === 'reference' && item.baseRecipeRef) {
return { ...item, resolvedRecipe: item.baseRecipeRef }; const resolvedRecipe = { ...item.baseRecipeRef };
// Recursively map nested baseRecipeRefs
if (resolvedRecipe.ingredients) {
resolvedRecipe.ingredients = mapBaseRecipeRefs(resolvedRecipe.ingredients);
}
if (resolvedRecipe.instructions) {
resolvedRecipe.instructions = mapBaseRecipeRefs(resolvedRecipe.instructions);
}
return { ...item, resolvedRecipe };
} }
return item; return item;
}); });
} }
if (recipe?.instructions) { if (recipe?.ingredients) {
recipe.instructions = recipe.instructions.map((item: any) => { recipe.ingredients = mapBaseRecipeRefs(recipe.ingredients);
if (item.type === 'reference' && item.baseRecipeRef) {
return { ...item, resolvedRecipe: item.baseRecipeRef };
} }
return item;
}); if (recipe?.instructions) {
recipe.instructions = mapBaseRecipeRefs(recipe.instructions);
} }
return json(recipe); return json(recipe);