From 524efda272d81dea7bda8b8482c2b1ae75abdbb1 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sun, 4 Jan 2026 23:41:47 +0100 Subject: [PATCH] fix: enable nested base recipe references to display correctly - 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. --- src/lib/components/IngredientsPage.svelte | 61 +++++++++++------- src/lib/components/InstructionsPage.svelte | 63 ++++++++++++------- src/lib/components/TranslationApproval.svelte | 45 ++++++++----- .../api/recipes/items/[name]/+server.ts | 49 +++++++++++---- .../api/rezepte/items/[name]/+server.ts | 49 +++++++++++---- 5 files changed, 180 insertions(+), 87 deletions(-) diff --git a/src/lib/components/IngredientsPage.svelte b/src/lib/components/IngredientsPage.svelte index 4cbd1c47..f7c98607 100644 --- a/src/lib/components/IngredientsPage.svelte +++ b/src/lib/components/IngredientsPage.svelte @@ -7,27 +7,32 @@ import HefeSwapper from './HefeSwapper.svelte'; let { data } = $props(); -// Flatten ingredient references for display -const flattenedIngredients = $derived.by(() => { - if (!data.ingredients) return []; +// Recursively flatten nested ingredient references +function flattenIngredientReferences(items, lang, visited = new Set()) { + const result = []; - return data.ingredients.flatMap((item) => { + for (const item of items) { 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 - const lang = data.lang || 'de'; const ingredientsToUse = (lang === 'en' && item.resolvedRecipe.translations?.en?.ingredients) ? item.resolvedRecipe.translations.en.ingredients : item.resolvedRecipe.ingredients || []; - // Filter to only sections (not nested references) - const baseIngredients = item.includeIngredients - ? ingredientsToUse.filter(i => i.type === 'section' || !i.type) - : []; + // Recursively flatten nested references + const flattenedNested = flattenIngredientReferences(ingredientsToUse, lang, newVisited); - // Combine all items into one section + // Combine all items into one list const combinedList = []; // Add items before @@ -35,12 +40,14 @@ const flattenedIngredients = $derived.by(() => { combinedList.push(...item.itemsBefore); } - // Add base recipe ingredients - baseIngredients.forEach(section => { - if (section.list) { - combinedList.push(...section.list); - } - }); + // Add base recipe ingredients (now recursively flattened) + if (item.includeIngredients) { + flattenedNested.forEach(section => { + if (section.list) { + combinedList.push(...section.list); + } + }); + } // Add items after if (item.itemsAfter && item.itemsAfter.length > 0) { @@ -49,12 +56,11 @@ const flattenedIngredients = $derived.by(() => { // Push as one section with optional label 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) ? item.resolvedRecipe.translations.en.name : item.resolvedRecipe.name; - sections.push({ + result.push({ type: 'section', name: item.showLabel ? (item.labelOverride || baseRecipeName) : '', list: combinedList, @@ -62,13 +68,20 @@ const flattenedIngredients = $derived.by(() => { short_name: item.resolvedRecipe.short_name }); } - - return sections; + } else if (item.type === 'section' || !item.type) { + // Regular section - pass through + result.push(item); } + } - // Regular section - pass through - return [item]; - }); + return result; +} + +// 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); diff --git a/src/lib/components/InstructionsPage.svelte b/src/lib/components/InstructionsPage.svelte index 5119963d..5b2f3c5d 100644 --- a/src/lib/components/InstructionsPage.svelte +++ b/src/lib/components/InstructionsPage.svelte @@ -3,25 +3,32 @@ let { data } = $props(); let multiplier = $state(data.multiplier || 1); -// Flatten instruction references for display -const flattenedInstructions = $derived.by(() => { - if (!data.instructions) return []; +// Recursively flatten nested instruction references +function flattenInstructionReferences(items, lang, visited = new Set()) { + const result = []; - return data.instructions.flatMap((item) => { + for (const item of items) { 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 - const lang = data.lang || 'de'; const instructionsToUse = (lang === 'en' && item.resolvedRecipe.translations?.en?.instructions) ? item.resolvedRecipe.translations.en.instructions : item.resolvedRecipe.instructions || []; - // Filter to only sections (not nested references) - const baseInstructions = item.includeInstructions - ? instructionsToUse.filter(i => i.type === 'section' || !i.type) - : []; + // Recursively flatten nested references + const flattenedNested = flattenInstructionReferences(instructionsToUse, lang, newVisited); - // Combine all steps into one section + // Combine all steps into one list const combinedSteps = []; // Add steps before @@ -29,12 +36,14 @@ const flattenedInstructions = $derived.by(() => { combinedSteps.push(...item.stepsBefore); } - // Add base recipe instructions - baseInstructions.forEach(section => { - if (section.steps) { - combinedSteps.push(...section.steps); - } - }); + // Add base recipe instructions (now recursively flattened) + if (item.includeInstructions) { + flattenedNested.forEach(section => { + if (section.steps) { + combinedSteps.push(...section.steps); + } + }); + } // Add steps after if (item.stepsAfter && item.stepsAfter.length > 0) { @@ -43,26 +52,32 @@ const flattenedInstructions = $derived.by(() => { // Push as one section with optional label 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) ? item.resolvedRecipe.translations.en.name : item.resolvedRecipe.name; - return [{ + result.push({ type: 'section', name: item.showLabel ? (item.labelOverride || baseRecipeName) : '', steps: combinedSteps, isReference: item.showLabel, short_name: item.resolvedRecipe.short_name - }]; + }); } - - return []; + } else if (item.type === 'section' || !item.type) { + // Regular section - pass through + result.push(item); } + } - // Regular section - pass through - return [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'); diff --git a/src/lib/components/TranslationApproval.svelte b/src/lib/components/TranslationApproval.svelte index d32fbc92..5c658373 100644 --- a/src/lib/components/TranslationApproval.svelte +++ b/src/lib/components/TranslationApproval.svelte @@ -27,7 +27,7 @@ let translationMetadata: any = null; // Track base recipes that need translation - let untranslatedBaseRecipes: { id: string, name: string }[] = []; + let untranslatedBaseRecipes: { shortName: string, name: string }[] = []; let checkingBaseRecipes = false; // Sync base recipe references from German to English @@ -36,21 +36,32 @@ 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 - const germanBaseRecipeIds = new Set(); + const germanBaseRecipeShortNames = new Set(); + const baseRecipeRefMap = new Map(); // Map short_name to baseRecipeRef (ID or object) + (germanData.ingredients || []).forEach((ing: any) => { 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) => { 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 (germanBaseRecipeIds.size === 0) { + if (germanBaseRecipeShortNames.size === 0) { if (!editableEnglish) { editableEnglish = { ...germanData, @@ -64,25 +75,25 @@ } // 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(); - for (const recipeId of germanBaseRecipeIds) { + for (const shortName of germanBaseRecipeShortNames) { try { - const response = await fetch(`/api/rezepte/${recipeId}`); + const response = await fetch(`/api/rezepte/items/${shortName}`); if (response.ok) { const recipe = await response.json(); if (!recipe.translations?.en) { - untranslated.push({ id: recipeId, name: recipe.name }); + untranslated.push({ shortName, name: recipe.name }); } else { - baseRecipeTranslations.set(recipeId, { + baseRecipeTranslations.set(shortName, { deName: recipe.name, enName: recipe.translations.en.name }); } } } catch (error) { - console.error(`Error fetching base recipe ${recipeId}:`, error); + console.error(`Error fetching base recipe ${shortName}:`, error); } } @@ -104,14 +115,16 @@ translationStatus: 'pending', ingredients: JSON.parse(JSON.stringify(germanData.ingredients || [])).map((ing: any) => { 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 ing; }), instructions: JSON.parse(JSON.stringify(germanData.instructions || [])).map((inst: any) => { 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 inst; @@ -125,7 +138,8 @@ ingredients: germanData.ingredients.map((germanIng: any, index: number) => { if (germanIng.type === 'reference' && germanIng.baseRecipeRef) { // 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]; // If English already has this reference at same position, keep it @@ -148,7 +162,8 @@ instructions: germanData.instructions.map((germanInst: any, index: number) => { if (germanInst.type === 'reference' && germanInst.baseRecipeRef) { // 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]; // If English already has this reference at same position, keep it diff --git a/src/routes/api/recipes/items/[name]/+server.ts b/src/routes/api/recipes/items/[name]/+server.ts index 4eaaddd6..3589d23d 100644 --- a/src/routes/api/recipes/items/[name]/+server.ts +++ b/src/routes/api/recipes/items/[name]/+server.ts @@ -17,11 +17,27 @@ export const GET: RequestHandler = async ({ params }) => { }) .populate({ path: 'translations.en.ingredients.baseRecipeRef', - select: 'short_name name ingredients instructions translations' + 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' + } + } }) .populate({ path: 'translations.en.instructions.baseRecipeRef', - select: 'short_name name ingredients instructions translations' + 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' + } + } }) .lean(); @@ -64,23 +80,32 @@ export const GET: RequestHandler = async ({ params }) => { germanShortName: recipe.short_name, }; - // Map populated base recipe refs to resolvedRecipe field - if (englishRecipe.ingredients) { - englishRecipe.ingredients = englishRecipe.ingredients.map((item: any) => { + // Recursively map populated base recipe refs to resolvedRecipe field + function mapBaseRecipeRefs(items: any[]): any[] { + return items.map((item: any) => { 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; }); } + if (englishRecipe.ingredients) { + englishRecipe.ingredients = mapBaseRecipeRefs(englishRecipe.ingredients); + } + if (englishRecipe.instructions) { - englishRecipe.instructions = englishRecipe.instructions.map((item: any) => { - if (item.type === 'reference' && item.baseRecipeRef) { - return { ...item, resolvedRecipe: item.baseRecipeRef }; - } - return item; - }); + englishRecipe.instructions = mapBaseRecipeRefs(englishRecipe.instructions); } // Merge English alt/caption with original image paths diff --git a/src/routes/api/rezepte/items/[name]/+server.ts b/src/routes/api/rezepte/items/[name]/+server.ts index db9ac568..eb9d76a7 100644 --- a/src/routes/api/rezepte/items/[name]/+server.ts +++ b/src/routes/api/rezepte/items/[name]/+server.ts @@ -9,11 +9,27 @@ export const GET: RequestHandler = async ({params}) => { let recipe = await Recipe.findOne({ short_name: params.name}) .populate({ path: 'ingredients.baseRecipeRef', - select: 'short_name name ingredients translations' + select: 'short_name name ingredients translations', + populate: { + path: 'ingredients.baseRecipeRef', + select: 'short_name name ingredients translations', + populate: { + path: 'ingredients.baseRecipeRef', + select: 'short_name name ingredients translations' + } + } }) .populate({ path: 'instructions.baseRecipeRef', - select: 'short_name name instructions translations' + select: 'short_name name instructions translations', + populate: { + path: 'instructions.baseRecipeRef', + select: 'short_name name instructions translations', + populate: { + path: 'instructions.baseRecipeRef', + select: 'short_name name instructions translations' + } + } }) .lean() as RecipeModelType[]; @@ -22,23 +38,32 @@ export const GET: RequestHandler = async ({params}) => { throw error(404, "Recipe not found") } - // Map populated refs to resolvedRecipe field - if (recipe?.ingredients) { - recipe.ingredients = recipe.ingredients.map((item: any) => { + // Recursively map populated refs to resolvedRecipe field + function mapBaseRecipeRefs(items: any[]): any[] { + return items.map((item: any) => { 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; }); } + if (recipe?.ingredients) { + recipe.ingredients = mapBaseRecipeRefs(recipe.ingredients); + } + if (recipe?.instructions) { - recipe.instructions = recipe.instructions.map((item: any) => { - if (item.type === 'reference' && item.baseRecipeRef) { - return { ...item, resolvedRecipe: item.baseRecipeRef }; - } - return item; - }); + recipe.instructions = mapBaseRecipeRefs(recipe.instructions); } return json(recipe);