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();
// 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);

View File

@@ -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');

View File

@@ -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<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) => {
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<string, { deName: string, enName: string }>();
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

View File

@@ -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

View File

@@ -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);