feat: Add recipe as ingredient (#4800)

Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
gpotter@gmail.com
2025-11-03 21:57:57 -08:00
committed by GitHub
parent ff42964537
commit 60d9294861
27 changed files with 1037 additions and 80 deletions

File diff suppressed because one or more lines are too long

View File

@@ -139,7 +139,7 @@
color="secondary" color="secondary"
density="compact" density="compact"
/> />
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto"> <div :key="`${ingredientData.ingredient?.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
<RecipeIngredientListItem <RecipeIngredientListItem
:ingredient="ingredientData.ingredient" :ingredient="ingredientData.ingredient"
:scale="recipeSection.recipeScale" :scale="recipeSection.recipeScale"
@@ -287,12 +287,35 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
continue; continue;
} }
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => { const shoppingListIngredients: ShoppingListIngredient[] = [];
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []); function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
return { const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
if (ing.referencedRecipe) {
// Recursively flatten all ingredients in the referenced recipe
return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
const calculatedQty = (ing.quantity || 1) * (subIng.quantity || 1);
// Pass the referenced recipe name as the section title
return flattenRecipeIngredients(
{ ...subIng, quantity: calculatedQty },
"",
);
});
}
else {
// Regular ingredient
return [{
checked: !householdsWithFood.includes(userHousehold.value), checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing, ingredient: {
}; ...ing,
title: ing.title || parentTitle,
},
}];
}
}
recipe.recipeIngredient.forEach((ing) => {
const flattened = flattenRecipeIngredients(ing, "");
shoppingListIngredients.push(...flattened);
}); });
let currentTitle = ""; let currentTitle = "";
@@ -301,6 +324,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
if (ing.ingredient.title) { if (ing.ingredient.title) {
currentTitle = ing.ingredient.title; currentTitle = ing.ingredient.title;
} }
else if (ing.ingredient.referencedRecipe?.name) {
currentTitle = ing.ingredient.referencedRecipe.name;
}
// If this is the first item in the section, create a new section // If this is the first item in the section, create a new section
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) { if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
@@ -316,7 +342,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
} }
// Store the on-hand ingredients for later // Store the on-hand ingredients for later
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []); const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
if (householdsWithFood.includes(userHousehold.value)) { if (householdsWithFood.includes(userHousehold.value)) {
onHandIngs.push(ing); onHandIngs.push(ing);
return sections; return sections;

View File

@@ -141,6 +141,13 @@ function save() {
dialog.value = false; dialog.value = false;
} }
function open() {
dialog.value = true;
}
function close() {
dialog.value = false;
}
const i18n = useI18n(); const i18n = useI18n();
const utilities = [ const utilities = [
@@ -160,4 +167,10 @@ const utilities = [
action: splitByNumberedLine, action: splitByNumberedLine,
}, },
]; ];
// Expose functions to parent components
defineExpose({
open,
close,
});
</script> </script>

View File

@@ -69,7 +69,14 @@
:label="$t('recipe.nutrition')" :label="$t('recipe.nutrition')"
/> />
</v-row> </v-row>
<v-row no-gutters /> <v-row no-gutters>
<v-switch
v-model="preferences.expandChildRecipes"
hide-details
color="primary"
:label="$t('recipe.include-linked-recipe-ingredients')"
/>
</v-row>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>

View File

@@ -41,6 +41,7 @@
</v-text-field> </v-text-field>
</v-col> </v-col>
<v-col <v-col
v-if="!state.isRecipe"
sm="12" sm="12"
md="3" md="3"
cols="12" cols="12"
@@ -97,6 +98,7 @@
<!-- Foods Input --> <!-- Foods Input -->
<v-col <v-col
v-if="!state.isRecipe"
m="12" m="12"
md="3" md="3"
cols="12" cols="12"
@@ -151,6 +153,33 @@
</template> </template>
</v-autocomplete> </v-autocomplete>
</v-col> </v-col>
<!-- Recipe Input -->
<v-col
v-if="state.isRecipe"
m="12"
md="6"
cols="12"
class=""
>
<v-autocomplete
ref="search.query"
v-model="model.referencedRecipe"
v-model:search="search.query.value"
auto-select-first
hide-details
density="compact"
variant="solo"
return-object
:items="search.data.value || []"
item-title="name"
class="mx-1 py-0"
placeholder="Choose Recipe"
clearable
label="Recipe"
>
<template #prepend />
</v-autocomplete>
</v-col>
<v-col <v-col
sm="12" sm="12"
md="" md=""
@@ -173,6 +202,7 @@
class="my-auto d-flex" class="my-auto d-flex"
:buttons="btns" :buttons="btns"
@toggle-section="toggleTitle" @toggle-section="toggleTitle"
@toggle-subrecipe="toggleIsRecipe"
@insert-above="$emit('insert-above')" @insert-above="$emit('insert-above')"
@insert-below="$emit('insert-below')" @insert-below="$emit('insert-below')"
@delete="$emit('delete')" @delete="$emit('delete')"
@@ -195,6 +225,8 @@ import { useI18n } from "vue-i18n";
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store"; import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
import { useNuxtApp } from "#app"; import { useNuxtApp } from "#app";
import type { RecipeIngredient } from "~/lib/api/types/recipe"; import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
// defineModel replaces modelValue prop // defineModel replaces modelValue prop
const model = defineModel<RecipeIngredient>({ required: true }); const model = defineModel<RecipeIngredient>({ required: true });
@@ -204,6 +236,10 @@ const props = defineProps({
type: String, type: String,
default: "body", default: "body",
}, },
isRecipe: {
type: Boolean,
default: false,
},
unitError: { unitError: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -247,6 +283,7 @@ const { $globals } = useNuxtApp();
const state = reactive({ const state = reactive({
showTitle: false, showTitle: false,
isRecipe: props.isRecipe,
}); });
const contextMenuOptions = computed(() => { const contextMenuOptions = computed(() => {
@@ -255,6 +292,10 @@ const contextMenuOptions = computed(() => {
text: i18n.t("recipe.toggle-section"), text: i18n.t("recipe.toggle-section"),
event: "toggle-section", event: "toggle-section",
}, },
{
text: i18n.t("recipe.toggle-recipe"),
event: "toggle-subrecipe",
},
{ {
text: i18n.t("recipe.insert-above"), text: i18n.t("recipe.insert-above"),
event: "insert-above", event: "insert-above",
@@ -303,6 +344,25 @@ async function createAssignFood() {
foodAutocomplete.value?.blur(); foodAutocomplete.value?.blur();
} }
// Recipes
const route = useRoute();
const $auth = useMealieAuth();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const search = useRecipeSearch(api);
const loading = ref(false);
const selectedIndex = ref(-1);
// Reset or Grab Recipes on Change
watch(loading, (val) => {
if (!val) {
search.query.value = "";
selectedIndex.value = -1;
search.data.value = [];
}
});
// Units // Units
const unitStore = useUnitStore(); const unitStore = useUnitStore();
const unitsData = useUnitData(); const unitsData = useUnitData();
@@ -323,6 +383,17 @@ function toggleTitle() {
state.showTitle = !state.showTitle; state.showTitle = !state.showTitle;
} }
function toggleIsRecipe() {
if (state.isRecipe) {
model.value.referencedRecipe = undefined;
}
else {
model.value.unit = undefined;
model.value.food = undefined;
}
state.isRecipe = !state.isRecipe;
}
function handleUnitEnter() { function handleUnitEnter() {
if ( if (
model.value.unit === undefined model.value.unit === undefined

View File

@@ -13,6 +13,10 @@
class="text-bold d-inline" class="text-bold d-inline"
:source="parsedIng.note" :source="parsedIng.note"
/> />
<template v-else-if="parsedIng.recipeLink">
<SafeMarkdown v-if="parsedIng.recipeLink" class="text-bold d-inline" :source="parsedIng.recipeLink" />
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
</template>
<template v-else> <template v-else>
<SafeMarkdown <SafeMarkdown
v-if="parsedIng.name" v-if="parsedIng.name"
@@ -39,9 +43,12 @@ interface Props {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
scale: 1, scale: 1,
}); });
const route = useRoute();
const $auth = useMealieAuth();
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
const parsedIng = computed(() => { const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.scale); return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
}); });
</script> </script>

View File

@@ -20,6 +20,29 @@
persistent-hint persistent-hint
rows="4" rows="4"
/> />
<div v-if="childRecipes?.length">
<v-card-text class="pt-6 pb-0">
{{ $t('recipe.include-linked-recipes') }}
</v-card-text>
<v-list>
<v-list-item
v-for="(childRecipe, i) in childRecipes"
:key="childRecipe.recipeId + i"
density="compact"
class="my-0 py-0"
@click="childRecipe.checked = !childRecipe.checked"
>
<v-checkbox
hide-details
density="compact"
:input-value="childRecipe.checked"
:label="childRecipe.name"
class="my-0 py-0"
color="secondary"
/>
</v-list-item>
</v-list>
</div>
<v-container> <v-container>
<v-row> <v-row>
<v-col cols="6"> <v-col cols="6">
@@ -166,6 +189,21 @@ onMounted(async () => {
lastMadeReady.value = true; lastMadeReady.value = true;
}); });
const childRecipes = computed(() => {
return props.recipe.recipeIngredient?.map((ingredient) => {
if (ingredient.referencedRecipe) {
return {
checked: false, // Default value for checked
recipeId: ingredient.referencedRecipe.id || "", // Non-nullable recipeId
...ingredient.referencedRecipe, // Spread the rest of the referencedRecipe properties
};
}
else {
return undefined;
}
}).filter(recipe => recipe !== undefined); // Filter out undefined values
});
whenever( whenever(
() => madeThisDialog.value, () => madeThisDialog.value,
() => { () => {
@@ -250,6 +288,37 @@ async function createTimelineEvent() {
} }
} }
for (const childRecipe of childRecipes.value || []) {
if (!childRecipe.checked) {
continue;
}
const childTimelineEvent = {
...newTimelineEvent.value,
recipeId: childRecipe.recipeId,
eventMessage: i18n.t("recipe.made-for-recipe", { recipe: childRecipe.name }),
image: undefined,
};
try {
await userApi.recipes.createTimelineEvent(childTimelineEvent);
}
catch (error) {
console.error(`Failed to create timeline event for child recipe ${childRecipe.slug}:`, error);
}
if (
newTimelineEvent.value.timestamp
&& (!childRecipe.lastMade || newTimelineEvent.value.timestamp > childRecipe.lastMade)
) {
try {
await userApi.recipes.updateLastMade(childRecipe.slug || "", newTimelineEvent.value.timestamp);
}
catch (error) {
console.error(`Failed to update last made date for child recipe ${childRecipe.slug}:`, error);
}
}
}
// update the image, if provided // update the image, if provided
let imageError = false; let imageError = false;
if (newTimelineEventImage.value) { if (newTimelineEventImage.value) {
@@ -268,7 +337,6 @@ async function createTimelineEvent() {
console.error("Failed to upload image for timeline event:", error); console.error("Failed to upload image for timeline event:", error);
} }
} }
if (imageError) { if (imageError) {
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image")); alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
} }

View File

@@ -290,10 +290,13 @@ watch(isParsing, () => {
*/ */
async function saveRecipe() { async function saveRecipe() {
const { data } = await api.recipes.updateOne(recipe.value.slug, recipe.value); const { data, error } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
if (!error) {
setMode(PageMode.VIEW); setMode(PageMode.VIEW);
}
if (data?.slug) { if (data?.slug) {
router.push(`/g/${groupSlug.value}/r/` + data.slug); router.push(`/g/${groupSlug.value}/r/` + data.slug);
recipe.value = data as NoUndefinedField<Recipe>;
} }
} }

View File

@@ -30,6 +30,7 @@
v-for="(ingredient, index) in recipe.recipeIngredient" v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId" :key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]" v-model="recipe.recipeIngredient[index]"
:is-recipe="ingredientIsRecipe(ingredient)"
enable-drag-handle enable-drag-handle
enable-context-menu enable-context-menu
class="list-group-item" class="list-group-item"
@@ -69,15 +70,59 @@
<span>{{ parserToolTip }}</span> <span>{{ parserToolTip }}</span>
</v-tooltip> </v-tooltip>
<RecipeDialogBulkAdd <RecipeDialogBulkAdd
ref="domBulkAddDialog"
class="mx-1 mb-1" class="mx-1 mb-1"
style="display: none"
@bulk-data="addIngredient" @bulk-data="addIngredient"
/> />
<BaseButton <div class="d-inline-flex split-button">
class="mb-1" <!-- Main button: Add Food -->
<v-btn
color="success"
class="split-main ml-2"
@click="addIngredient" @click="addIngredient"
> >
{{ $t("general.add") }} <v-icon start>
</BaseButton> {{ $globals.icons.createAlt }}
</v-icon>
{{ $t('general.add') || 'Add Food' }}
</v-btn>
<!-- Dropdown button -->
<v-menu>
<template #activator="{ props }">
<v-btn
color="success"
class="split-dropdown"
v-bind="props"
>
<v-icon>{{ $globals.icons.chevronDown }}</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.foods"
:title="$t('new-recipe.add-food')"
@click="addIngredient"
/>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.silverwareForkKnife"
:title="$t('new-recipe.add-recipe')"
@click="addRecipe"
/>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.create"
:title="$t('new-recipe.bulk-add')"
@click="showBulkAdd"
/>
</v-list>
</v-menu>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -85,16 +130,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus"; import { VueDraggable } from "vue-draggable-plus";
import type { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe"; import type { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue"; import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import { usePageState } from "~/composables/recipe-page/shared-state"; import { usePageState } from "~/composables/recipe-page/shared-state";
import { uuid4 } from "~/composables/use-utils"; import { uuid4 } from "~/composables/use-utils";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true }); const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const ingredientsWithRecipe = new Map<string, boolean>();
const i18n = useI18n(); const i18n = useI18n();
const drag = ref(false); const drag = ref(false);
const domBulkAddDialog = ref<InstanceType<typeof RecipeDialogBulkAdd> | null>(null);
const { toggleIsParsing } = usePageState(recipe.value.slug); const { toggleIsParsing } = usePageState(recipe.value.slug);
const hasFoodOrUnit = computed(() => { const hasFoodOrUnit = computed(() => {
@@ -118,6 +165,22 @@ const parserToolTip = computed(() => {
return i18n.t("recipe.parse-ingredients"); return i18n.t("recipe.parse-ingredients");
}); });
function showBulkAdd() {
domBulkAddDialog.value?.open();
}
function ingredientIsRecipe(ingredient: RecipeIngredient): boolean {
if (ingredient.referencedRecipe) {
return true;
}
if (ingredient.referenceId) {
return !!ingredientsWithRecipe.get(ingredient.referenceId);
}
return false;
}
function addIngredient(ingredients: Array<string> | null = null) { function addIngredient(ingredients: Array<string> | null = null) {
if (ingredients?.length) { if (ingredients?.length) {
const newIngredients = ingredients.map((x) => { const newIngredients = ingredients.map((x) => {
@@ -150,6 +213,41 @@ function addIngredient(ingredients: Array<string> | null = null) {
} }
} }
function addRecipe(recipes: Array<string> | null = null) {
const refId = uuid4();
ingredientsWithRecipe.set(refId, true);
if (recipes?.length) {
const newRecipes = recipes.map((x) => {
return {
referenceId: refId,
title: "",
note: x,
unit: undefined,
referencedRecipe: undefined,
quantity: 1,
};
});
if (newRecipes) {
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
recipe.value.recipeIngredient.push(...newRecipes);
}
}
else {
recipe.value.recipeIngredient.push({
referenceId: refId,
title: "",
note: "",
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
referencedRecipe: undefined,
quantity: 1,
});
}
}
function insertNewIngredient(dest: number) { function insertNewIngredient(dest: number) {
recipe.value.recipeIngredient.splice(dest, 0, { recipe.value.recipeIngredient.splice(dest, 0, {
referenceId: uuid4(), referenceId: uuid4(),
@@ -163,3 +261,22 @@ function insertNewIngredient(dest: number) {
}); });
} }
</script> </script>
<style scoped>
.split-button {
border-radius: 4px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.split-main {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-dropdown {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
min-width: 30px;
padding-left: 0;
padding-right: 0;
}
</style>

View File

@@ -268,6 +268,11 @@ const state = reactive({
function shouldReview(ing: ParsedIngredient): boolean { function shouldReview(ing: ParsedIngredient): boolean {
console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing); console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing);
if (ing.ingredient.referencedRecipe) {
console.debug("No review needed for sub-recipe ingredient");
return false;
}
if ((ing.confidence?.average || 0) < confidenceThreshold) { if ((ing.confidence?.average || 0) < confidenceThreshold) {
console.debug("Needs review due to low confidence:", ing.confidence?.average); console.debug("Needs review due to low confidence:", ing.confidence?.average);
return true; return true;
@@ -364,12 +369,21 @@ async function parseIngredients() {
} }
state.loading.parser = true; state.loading.parser = true;
try { try {
const ingsAsString = props.ingredients.map(ing => parseIngredientText(ing, 1, false) ?? ""); const ingsAsString = props.ingredients
.filter(ing => !ing.referencedRecipe)
.map(ing => parseIngredientText(ing, 1, false) ?? "");
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString); const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
if (error || !data) { if (error || !data) {
throw new Error("Failed to parse ingredients"); throw new Error("Failed to parse ingredients");
} }
parsedIngs.value = data; parsedIngs.value = data;
const parsed = data ?? [];
const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({
input: ing.note || "",
confidence: {},
ingredient: ing,
}));
parsedIngs.value = [...parsed, ...recipeRefs];
state.currentParsedIndex = -1; state.currentParsedIndex = -1;
state.allReviewed = false; state.allReviewed = false;
createdUnits.clear(); createdUnits.clear();

View File

@@ -262,32 +262,55 @@ const ingredientSections = computed<IngredientSection[]>(() => {
if (!props.recipe.recipeIngredient) { if (!props.recipe.recipeIngredient) {
return []; return [];
} }
const addIngredientsToSections = (ingredients: RecipeIngredient[], sections: IngredientSection[], title: string | null) => {
return props.recipe.recipeIngredient.reduce((sections, ingredient) => { // If title is set, ensure the section exists before adding ingredients
// if title append new section to the end of the array let section: IngredientSection | undefined;
if (ingredient.title) { if (title) {
sections.push({ section = sections.find(sec => sec.sectionName === title);
sectionName: ingredient.title, if (!section) {
ingredients: [ingredient], section = { sectionName: title, ingredients: [] };
}); sections.push(section);
}
return sections;
} }
// append new section if first ingredients.forEach((ingredient) => {
if (preferences.value.expandChildRecipes && ingredient.referencedRecipe?.recipeIngredient?.length) {
// Recursively add to the section for this referenced recipe
addIngredientsToSections(
ingredient.referencedRecipe.recipeIngredient,
sections,
"",
);
}
else {
const sectionName = title || ingredient.title || "";
if (sectionName) {
let sec = sections.find(sec => sec.sectionName === sectionName);
if (!sec) {
sec = { sectionName, ingredients: [] };
sections.push(sec);
}
ingredient.title = sectionName;
sec.ingredients.push(ingredient);
}
else {
if (sections.length === 0) { if (sections.length === 0) {
sections.push({ sections.push({
sectionName: "", sectionName: "",
ingredients: [ingredient], ingredients: [ingredient],
}); });
return sections;
} }
else {
// otherwise add ingredient to last section in the array
sections[sections.length - 1].ingredients.push(ingredient); sections[sections.length - 1].ingredients.push(ingredient);
}
}
}
});
};
const sections: IngredientSection[] = [];
addIngredientsToSections(props.recipe.recipeIngredient, sections, null);
return sections; return sections;
}, [] as IngredientSection[]);
}); });
// Group instructions by section so we can style them independently // Group instructions by section so we can style them independently

View File

@@ -1,6 +1,6 @@
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
import { useFraction } from "./use-fraction"; import { useFraction } from "./use-fraction";
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, RecipeIngredient } from "~/lib/api/types/recipe"; import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
const { frac } = useFraction(); const { frac } = useFraction();
@@ -36,8 +36,28 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
return returnVal; return returnVal;
} }
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) { function useRecipeLink(recipe: Recipe | undefined, groupSlug: string | undefined): string | undefined {
const { quantity, food, unit, note, title } = ingredient; if (!(recipe && recipe.slug && recipe.name && groupSlug)) {
return undefined;
}
return `<a href="/g/${groupSlug}/r/${recipe.slug}" target="_blank">${recipe.name}</a>`;
}
type ParsedIngredientText = {
quantity?: string;
unit?: string;
name?: string;
note?: string;
/**
* If the ingredient is a linked recipe, an HTML link to the referenced recipe, otherwise undefined.
*/
recipeLink?: string;
};
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
const { quantity, food, unit, note, referencedRecipe } = ingredient;
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0); const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
const usePluralFood = (!quantity) || quantity * scale > 1; const usePluralFood = (!quantity) || quantity * scale > 1;
@@ -62,15 +82,16 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
} }
} }
// TODO: Add support for sub-recipes here?
const unitName = useUnitName(unit || undefined, usePluralUnit); const unitName = useUnitName(unit || undefined, usePluralUnit);
const foodName = useFoodName(food || undefined, usePluralFood); const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
return { return {
title: title ? sanitizeIngredientHTML(title) : undefined,
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined, quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined, unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
name: foodName ? sanitizeIngredientHTML(foodName) : undefined, name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
note: note ? sanitizeIngredientHTML(note) : undefined, note: note ? sanitizeIngredientHTML(note) : undefined,
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
}; };
} }

View File

@@ -8,6 +8,7 @@ export interface UserPrintPreferences {
showDescription: boolean; showDescription: boolean;
showNotes: boolean; showNotes: boolean;
showNutrition: boolean; showNutrition: boolean;
expandChildRecipes: boolean;
} }
export interface UserSearchQuery { export interface UserSearchQuery {
@@ -91,6 +92,7 @@ export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
imagePosition: "left", imagePosition: "left",
showDescription: true, showDescription: true,
showNotes: true, showNotes: true,
expandChildRecipes: false,
}, },
{ mergeDefaults: true }, { mergeDefaults: true },
// we cast to a Ref because by default it will return an optional type ref // we cast to a Ref because by default it will return an optional type ref

View File

@@ -448,7 +448,9 @@
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns", "split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
"import-by-url": "Import a recipe by URL", "import-by-url": "Import a recipe by URL",
"create-manually": "Create a recipe manually", "create-manually": "Create a recipe manually",
"make-recipe-image": "Make this the recipe image" "make-recipe-image": "Make this the recipe image",
"add-food": "Add Food",
"add-recipe": "Add Recipe"
}, },
"page": { "page": {
"404-page-not-found": "404 Page not found", "404-page-not-found": "404 Page not found",
@@ -590,6 +592,7 @@
"made-this": "I Made This", "made-this": "I Made This",
"how-did-it-turn-out": "How did it turn out?", "how-did-it-turn-out": "How did it turn out?",
"user-made-this": "{user} made this", "user-made-this": "{user} made this",
"made-for-recipe": "Made for {recipe}",
"added-to-timeline": "Added to timeline", "added-to-timeline": "Added to timeline",
"failed-to-add-to-timeline": "Failed to add to timeline", "failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-update-recipe": "Failed to update recipe", "failed-to-update-recipe": "Failed to update recipe",
@@ -691,7 +694,10 @@
"upload-images": "Upload images", "upload-images": "Upload images",
"upload-more-images": "Upload more images", "upload-more-images": "Upload more images",
"set-as-cover-image": "Set as recipe cover image", "set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image" "cover-image": "Cover image",
"include-linked-recipes": "Include Linked Recipes",
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
"toggle-recipe": "Toggle Recipe"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Recipe Finder", "recipe-finder": "Recipe Finder",

View File

@@ -309,6 +309,7 @@ export interface RecipeIngredient {
quantity?: number | null; quantity?: number | null;
unit?: IngredientUnit | CreateIngredientUnit | null; unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null; food?: IngredientFood | CreateIngredientFood | null;
referencedRecipe?: Recipe | null;
note?: string | null; note?: string | null;
display?: string; display?: string;
title?: string | null; title?: string | null;
@@ -396,6 +397,129 @@ export interface CreateIngredientFoodAlias {
name: string; name: string;
[k: string]: unknown; [k: string]: unknown;
} }
export interface Recipe {
id?: string | null;
userId?: string;
householdId?: string;
groupId?: string;
name?: string | null;
slug?: string;
image?: unknown;
recipeServings?: number;
recipeYieldQuantity?: number;
recipeYield?: string | null;
totalTime?: string | null;
prepTime?: string | null;
cookTime?: string | null;
performTime?: string | null;
description?: string | null;
recipeCategory?: RecipeCategory[] | null;
tags?: RecipeTag[] | null;
tools?: RecipeTool[];
rating?: number | null;
orgURL?: string | null;
dateAdded?: string | null;
dateUpdated?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
lastMade?: string | null;
recipeIngredient?: RecipeIngredient[];
recipeInstructions?: RecipeStep[] | null;
nutrition?: Nutrition | null;
settings?: RecipeSettings | null;
assets?: RecipeAsset[] | null;
notes?: RecipeNote[] | null;
extras?: {
[k: string]: unknown;
} | null;
comments?: RecipeCommentOut[] | null;
[k: string]: unknown;
}
export interface RecipeCategory {
id?: string | null;
groupId?: string | null;
name: string;
slug: string;
[k: string]: unknown;
}
export interface RecipeTag {
id?: string | null;
groupId?: string | null;
name: string;
slug: string;
[k: string]: unknown;
}
export interface RecipeTool {
id: string;
groupId?: string | null;
name: string;
slug: string;
householdsWithTool?: string[];
[k: string]: unknown;
}
export interface RecipeStep {
id?: string | null;
title?: string | null;
summary?: string | null;
text: string;
ingredientReferences?: IngredientReferences[];
[k: string]: unknown;
}
export interface IngredientReferences {
referenceId?: string | null;
[k: string]: unknown;
}
export interface Nutrition {
calories?: string | null;
carbohydrateContent?: string | null;
cholesterolContent?: string | null;
fatContent?: string | null;
fiberContent?: string | null;
proteinContent?: string | null;
saturatedFatContent?: string | null;
sodiumContent?: string | null;
sugarContent?: string | null;
transFatContent?: string | null;
unsaturatedFatContent?: string | null;
[k: string]: unknown;
}
export interface RecipeSettings {
public?: boolean;
showNutrition?: boolean;
showAssets?: boolean;
landscapeView?: boolean;
disableComments?: boolean;
locked?: boolean;
[k: string]: unknown;
}
export interface RecipeAsset {
name: string;
icon: string;
fileName?: string | null;
[k: string]: unknown;
}
export interface RecipeNote {
title: string;
text: string;
[k: string]: unknown;
}
export interface RecipeCommentOut {
recipeId: string;
text: string;
id: string;
createdAt: string;
updatedAt: string;
userId: string;
user: UserBase;
[k: string]: unknown;
}
export interface UserBase {
id: string;
username?: string | null;
admin: boolean;
fullName?: string | null;
[k: string]: unknown;
}
export interface ShoppingListAddRecipeParamsBulk { export interface ShoppingListAddRecipeParamsBulk {
recipeIncrementQuantity?: number; recipeIncrementQuantity?: number;
recipeIngredients?: RecipeIngredient[] | null; recipeIngredients?: RecipeIngredient[] | null;
@@ -413,6 +537,7 @@ export interface ShoppingListItemBase {
quantity?: number; quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit | null; unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null; food?: IngredientFood | CreateIngredientFood | null;
referencedRecipe?: Recipe | null;
note?: string | null; note?: string | null;
display?: string; display?: string;
shoppingListId: string; shoppingListId: string;
@@ -429,6 +554,7 @@ export interface ShoppingListItemCreate {
quantity?: number; quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit | null; unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null; food?: IngredientFood | CreateIngredientFood | null;
referencedRecipe?: Recipe | null;
note?: string | null; note?: string | null;
display?: string; display?: string;
shoppingListId: string; shoppingListId: string;
@@ -453,6 +579,7 @@ export interface ShoppingListItemOut {
quantity?: number; quantity?: number;
unit?: IngredientUnit | null; unit?: IngredientUnit | null;
food?: IngredientFood | null; food?: IngredientFood | null;
referencedRecipe?: Recipe | null;
note?: string | null; note?: string | null;
display?: string; display?: string;
shoppingListId: string; shoppingListId: string;
@@ -492,6 +619,7 @@ export interface ShoppingListItemUpdate {
quantity?: number; quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit | null; unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null; food?: IngredientFood | CreateIngredientFood | null;
referencedRecipe?: Recipe | null;
note?: string | null; note?: string | null;
display?: string; display?: string;
shoppingListId: string; shoppingListId: string;
@@ -509,6 +637,7 @@ export interface ShoppingListItemUpdateBulk {
quantity?: number; quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit | null; unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null; food?: IngredientFood | CreateIngredientFood | null;
referencedRecipe?: Recipe | null;
note?: string | null; note?: string | null;
display?: string; display?: string;
shoppingListId: string; shoppingListId: string;
@@ -595,28 +724,6 @@ export interface RecipeSummary {
updatedAt?: string | null; updatedAt?: string | null;
lastMade?: string | null; lastMade?: string | null;
} }
export interface RecipeCategory {
id?: string | null;
groupId?: string | null;
name: string;
slug: string;
[k: string]: unknown;
}
export interface RecipeTag {
id?: string | null;
groupId?: string | null;
name: string;
slug: string;
[k: string]: unknown;
}
export interface RecipeTool {
id: string;
groupId?: string | null;
name: string;
slug: string;
householdsWithTool?: string[];
[k: string]: unknown;
}
export interface ShoppingListRemoveRecipeParams { export interface ShoppingListRemoveRecipeParams {
recipeDecrementQuantity?: number; recipeDecrementQuantity?: number;
} }
@@ -682,6 +789,7 @@ export interface RecipeIngredientBase {
quantity?: number | null; quantity?: number | null;
unit?: IngredientUnit | CreateIngredientUnit | null; unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null; food?: IngredientFood | CreateIngredientFood | null;
referencedRecipe?: Recipe | null;
note?: string | null; note?: string | null;
display?: string; display?: string;
} }

View File

@@ -214,6 +214,7 @@ export interface RecipeIngredient {
quantity?: number | null; quantity?: number | null;
unit?: IngredientUnit | CreateIngredientUnit | null; unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null; food?: IngredientFood | CreateIngredientFood | null;
referencedRecipe?: Recipe | null;
note?: string | null; note?: string | null;
display?: string; display?: string;
title?: string | null; title?: string | null;
@@ -349,6 +350,7 @@ export interface RecipeIngredientBase {
quantity?: number | null; quantity?: number | null;
unit?: IngredientUnit | CreateIngredientUnit | null; unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null; food?: IngredientFood | CreateIngredientFood | null;
referencedRecipe?: Recipe | null;
note?: string | null; note?: string | null;
display?: string; display?: string;
} }

View File

@@ -0,0 +1,40 @@
"""'Add referenced_recipe to ingredients'
Revision ID: 1d9a002d7234
Revises: e6bb583aac2d
Create Date: 2025-09-10 19:21:48.479101
"""
import sqlalchemy as sa
from alembic import op
import mealie.db.migration_types
# revision identifiers, used by Alembic.
revision = "1d9a002d7234"
down_revision: str | None = "e6bb583aac2d"
branch_labels: str | tuple[str, ...] | None = None
depends_on: str | tuple[str, ...] | None = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("recipes_ingredients", schema=None) as batch_op:
batch_op.add_column(sa.Column("referenced_recipe_id", mealie.db.migration_types.GUID(), nullable=True))
batch_op.create_index(
batch_op.f("ix_recipes_ingredients_referenced_recipe_id"), ["referenced_recipe_id"], unique=False
)
batch_op.create_foreign_key("fk_recipe_subrecipe", "recipes", ["referenced_recipe_id"], ["id"])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("recipes_ingredients", schema=None) as batch_op:
batch_op.drop_constraint("fk_recipe_subrecipe", type_="foreignkey")
batch_op.drop_index(batch_op.f("ix_recipes_ingredients_referenced_recipe_id"))
batch_op.drop_column("referenced_recipe_id")
# ### end Alembic commands ###

View File

@@ -22,6 +22,14 @@ class PermissionDenied(Exception):
pass pass
class RecursiveRecipe(Exception):
"""
This exception is raised when a recipe references itself, either directly or indirectly.
"""
pass
class SlugError(Exception): class SlugError(Exception):
""" """
This exception is raised when the recipe name generates an invalid slug. This exception is raised when the recipe name generates an invalid slug.
@@ -47,6 +55,7 @@ def mealie_registered_exceptions(t: Translator) -> dict:
PermissionDenied: t.t("exceptions.permission-denied"), PermissionDenied: t.t("exceptions.permission-denied"),
NoEntryFound: t.t("exceptions.no-entry-found"), NoEntryFound: t.t("exceptions.no-entry-found"),
IntegrityError: t.t("exceptions.integrity-error"), IntegrityError: t.t("exceptions.integrity-error"),
RecursiveRecipe: t.t("exceptions.recursive-recipe-link"),
} }

View File

@@ -16,7 +16,7 @@ from .._model_utils.guid import GUID
if TYPE_CHECKING: if TYPE_CHECKING:
from ..group import Group from ..group import Group
from ..household import Household from ..household import Household
from .recipe import RecipeModel
households_to_ingredient_foods = sa.Table( households_to_ingredient_foods = sa.Table(
"households_to_ingredient_foods", "households_to_ingredient_foods",
@@ -358,6 +358,12 @@ class RecipeIngredientModel(SqlAlchemyBase, BaseMixins):
reference_id: Mapped[GUID | None] = mapped_column(GUID) # Reference Links reference_id: Mapped[GUID | None] = mapped_column(GUID) # Reference Links
# Recipe Reference
referenced_recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
referenced_recipe: Mapped["RecipeModel"] = orm.relationship(
"RecipeModel", back_populates="referenced_ingredients", foreign_keys=[referenced_recipe_id]
)
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
note_normalized: Mapped[str | None] = mapped_column(String, index=True) note_normalized: Mapped[str | None] = mapped_column(String, index=True)
original_text_normalized: Mapped[str | None] = mapped_column(String, index=True) original_text_normalized: Mapped[str | None] = mapped_column(String, index=True)

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm.session import object_session
from mealie.db.models._model_utils.auto_init import auto_init from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from ..household.household_to_recipe import HouseholdToRecipe from ..household.household_to_recipe import HouseholdToRecipe
@@ -22,7 +23,6 @@ from .api_extras import ApiExtras, api_extras
from .assets import RecipeAsset from .assets import RecipeAsset
from .category import recipes_to_categories from .category import recipes_to_categories
from .comment import RecipeComment from .comment import RecipeComment
from .ingredient import RecipeIngredientModel
from .instruction import RecipeInstruction from .instruction import RecipeInstruction
from .note import Note from .note import Note
from .nutrition import Nutrition from .nutrition import Nutrition
@@ -100,11 +100,17 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
) )
tools: Mapped[list["Tool"]] = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes") tools: Mapped[list["Tool"]] = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes")
recipe_ingredient: Mapped[list[RecipeIngredientModel]] = orm.relationship( recipe_ingredient: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", "RecipeIngredientModel",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="RecipeIngredientModel.position", order_by="RecipeIngredientModel.position",
collection_class=ordering_list("position"), collection_class=ordering_list("position"),
foreign_keys="RecipeIngredientModel.recipe_id",
)
referenced_ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel",
foreign_keys="RecipeIngredientModel.referenced_recipe_id",
back_populates="referenced_recipe",
) )
recipe_instructions: Mapped[list[RecipeInstruction]] = orm.relationship( recipe_instructions: Mapped[list[RecipeInstruction]] = orm.relationship(
"RecipeInstruction", "RecipeInstruction",

View File

@@ -33,6 +33,7 @@
}, },
"exceptions": { "exceptions": {
"permission_denied": "You do not have permission to perform this action", "permission_denied": "You do not have permission to perform this action",
"recursive-recipe-link": "A recipe cannot reference itself, either directly or indirectly",
"no-entry-found": "The requested resource was not found", "no-entry-found": "The requested resource was not found",
"integrity-error": "Database integrity error", "integrity-error": "Database integrity error",
"username-conflict-error": "This username is already taken", "username-conflict-error": "This username is already taken",

View File

@@ -94,6 +94,12 @@ class RecipeController(BaseRecipeController):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ErrorResponse.respond(message="Recipe already exists") status_code=status.HTTP_400_BAD_REQUEST, detail=ErrorResponse.respond(message="Recipe already exists")
) )
elif thrownType == exceptions.RecursiveRecipe:
self.logger.error("Recursive Recipe Link Error on recipe controller action")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse.respond(message=self.t("exceptions.recursive-recipe-link")),
)
elif thrownType == exceptions.SlugError: elif thrownType == exceptions.SlugError:
self.logger.error("Failed to generate a valid slug from recipe name") self.logger.error("Failed to generate a valid slug from recipe name")
raise HTTPException( raise HTTPException(

View File

@@ -14,6 +14,7 @@ from mealie.db.models.recipe import IngredientFoodModel
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.mealie_model import UpdatedAtField from mealie.schema._mealie.mealie_model import UpdatedAtField
from mealie.schema._mealie.types import NoneFloat from mealie.schema._mealie.types import NoneFloat
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.response.pagination import PaginationBase from mealie.schema.response.pagination import PaginationBase
INGREDIENT_QTY_PRECISION = 3 INGREDIENT_QTY_PRECISION = 3
@@ -155,8 +156,9 @@ class RecipeIngredientBase(MealieModel):
quantity: NoneFloat = 0 quantity: NoneFloat = 0
unit: IngredientUnit | CreateIngredientUnit | None = None unit: IngredientUnit | CreateIngredientUnit | None = None
food: IngredientFood | CreateIngredientFood | None = None food: IngredientFood | CreateIngredientFood | None = None
note: str | None = "" referenced_recipe: Recipe | None = None
note: str | None = ""
display: str = "" display: str = ""
""" """
How the ingredient should be displayed How the ingredient should be displayed

View File

@@ -20,6 +20,7 @@ from mealie.schema.household.group_shopping_list import (
ShoppingListOut, ShoppingListOut,
ShoppingListSave, ShoppingListSave,
) )
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_ingredient import ( from mealie.schema.recipe.recipe_ingredient import (
IngredientFood, IngredientFood,
IngredientUnit, IngredientUnit,
@@ -315,10 +316,22 @@ class ShoppingListService:
list_items: list[ShoppingListItemCreate] = [] list_items: list[ShoppingListItemCreate] = []
for ingredient in recipe_ingredients: for ingredient in recipe_ingredients:
if isinstance(ingredient.referenced_recipe, Recipe):
# Recursively process sub-recipe ingredients
sub_recipe = ingredient.referenced_recipe
sub_scale = (ingredient.quantity or 1) * scale
sub_items = self.get_shopping_list_items_from_recipe(
list_id,
sub_recipe.id,
sub_scale,
sub_recipe.recipe_ingredient,
)
list_items.extend(sub_items)
continue
if isinstance(ingredient.food, IngredientFood): if isinstance(ingredient.food, IngredientFood):
food_id = ingredient.food.id food_id = ingredient.food.id
label_id = ingredient.food.label_id label_id = ingredient.food.label_id
else: else:
food_id = None food_id = None
label_id = None label_id = None

View File

@@ -369,6 +369,27 @@ class RecipeService(RecipeServiceBase):
return new_recipe return new_recipe
def has_recursive_recipe_link(self, recipe: Recipe, visited: set[str] | None = None):
"""Recursively checks if a recipe links to itself through its ingredients."""
if visited is None:
visited = set()
recipe_id = str(getattr(recipe, "id", None))
if recipe_id in visited:
return True
visited.add(recipe_id)
ingredients = getattr(recipe, "recipe_ingredient", [])
for ing in ingredients:
try:
sub_recipe = self.get_one(ing.referenced_recipe.id)
except (AttributeError, exceptions.NoEntryFound):
continue
if self.has_recursive_recipe_link(sub_recipe, visited):
return True
return False
def _pre_update_check(self, slug_or_id: str | UUID, new_data: Recipe) -> Recipe: def _pre_update_check(self, slug_or_id: str | UUID, new_data: Recipe) -> Recipe:
""" """
gets the recipe from the database and performs a check to see if the user can update the recipe. gets the recipe from the database and performs a check to see if the user can update the recipe.
@@ -399,6 +420,9 @@ class RecipeService(RecipeServiceBase):
if setting_lock and not self.can_lock_unlock(recipe): if setting_lock and not self.can_lock_unlock(recipe):
raise exceptions.PermissionDenied("You do not have permission to lock/unlock this recipe.") raise exceptions.PermissionDenied("You do not have permission to lock/unlock this recipe.")
if self.has_recursive_recipe_link(new_data):
raise exceptions.RecursiveRecipe("Recursive recipe link detected. Update aborted.")
return recipe return recipe
def update_one(self, slug_or_id: str | UUID, update_data: Recipe) -> Recipe: def update_one(self, slug_or_id: str | UUID, update_data: Recipe) -> Recipe:

View File

@@ -11,6 +11,7 @@ from mealie.schema.household.group_shopping_list import (
ShoppingListOut, ShoppingListOut,
) )
from mealie.schema.recipe.recipe import Recipe from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
from tests import utils from tests import utils
from tests.utils import api_routes from tests.utils import api_routes
from tests.utils.assertion_helpers import assert_deserialize from tests.utils.assertion_helpers import assert_deserialize
@@ -245,6 +246,121 @@ def test_shopping_lists_add_recipes(
assert refs_by_id[str(recipe.id)]["recipeQuantity"] == 2 assert refs_by_id[str(recipe.id)]["recipeQuantity"] == 2
def test_shopping_lists_add_nested_recipe_ingredients(
api_client: TestClient,
unique_user: TestUser,
shopping_lists: list[ShoppingListOut],
):
"""Test that adding a recipe with nested recipe ingredients flattens all ingredients (a -> b -> c)."""
shopping_list = random.choice(shopping_lists)
database = unique_user.repos
# Create three food items for the base recipes
food_c = database.ingredient_foods.create(
SaveIngredientFood(
name=random_string(10),
group_id=unique_user.group_id,
)
)
food_b = database.ingredient_foods.create(
SaveIngredientFood(
name=random_string(10),
group_id=unique_user.group_id,
)
)
food_a = database.ingredient_foods.create(
SaveIngredientFood(
name=random_string(10),
group_id=unique_user.group_id,
)
)
# Create recipe_c with a single food ingredient (base recipe)
recipe_c: Recipe = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note=f"ingredient from recipe c - {food_c.name}", food=food_c, quantity=1),
],
)
)
# Create recipe_b with its own food ingredient and a reference to recipe_c (c -> b)
recipe_b: Recipe = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note=f"ingredient from recipe b - {food_b.name}", food=food_b, quantity=2),
RecipeIngredient(note="nested recipe c", referenced_recipe=recipe_c),
],
)
)
# Create recipe_a with its own food ingredient and a reference to recipe_b (b -> a, creating chain c -> b -> a)
recipe_a: Recipe = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note=f"ingredient from recipe a - {food_a.name}", food=food_a, quantity=3),
RecipeIngredient(note="nested recipe b", referenced_recipe=recipe_b),
],
)
)
# Add recipe_a to the shopping list
response = api_client.post(
api_routes.households_shopping_lists_item_id_recipe(shopping_list.id),
json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe_a.id).model_dump()]),
headers=unique_user.token,
)
assert response.status_code == 200
# Get the shopping list and verify all ingredients from a, b, and c are flattened
response = api_client.get(
api_routes.households_shopping_lists_item_id(shopping_list.id),
headers=unique_user.token,
)
shopping_list_data = utils.assert_deserialize(response, 200)
# Should have 3 items: one from recipe_a, one from recipe_b, and one from recipe_c
assert len(shopping_list_data["listItems"]) == 3
# Verify each ingredient is present with the correct quantity
found_ingredients = {
food_a.name: False,
food_b.name: False,
food_c.name: False,
}
for item in shopping_list_data["listItems"]:
if food_a.name in item["note"]:
assert item["quantity"] == 3
found_ingredients[food_a.name] = True
elif food_b.name in item["note"]:
assert item["quantity"] == 2
found_ingredients[food_b.name] = True
elif food_c.name in item["note"]:
assert item["quantity"] == 1
found_ingredients[food_c.name] = True
# Ensure all ingredients were found
assert all(found_ingredients.values()), f"Missing ingredients: {found_ingredients}"
# Verify recipe reference
refs = shopping_list_data["recipeReferences"]
assert len(refs) == 1
assert refs[0]["recipeId"] == str(recipe_a.id)
assert refs[0]["recipeQuantity"] == 1
@pytest.mark.parametrize("is_private_household", [True, False]) @pytest.mark.parametrize("is_private_household", [True, False])
@pytest.mark.parametrize("household_lock_recipe_edits", [True, False]) @pytest.mark.parametrize("household_lock_recipe_edits", [True, False])
def test_shopping_lists_add_cross_household_recipe( def test_shopping_lists_add_cross_household_recipe(

View File

@@ -23,6 +23,7 @@ from mealie.pkgs.safehttp.transport import AsyncSafeTransport
from mealie.schema.cookbook.cookbook import SaveCookBook from mealie.schema.cookbook.cookbook import SaveCookBook
from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary, RecipeTag from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary, RecipeTag
from mealie.schema.recipe.recipe_category import CategorySave, TagSave from mealie.schema.recipe.recipe_category import CategorySave, TagSave
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
from mealie.schema.recipe.recipe_notes import RecipeNote from mealie.schema.recipe.recipe_notes import RecipeNote
from mealie.schema.recipe.recipe_tool import RecipeToolSave from mealie.schema.recipe.recipe_tool import RecipeToolSave
from mealie.services.recipe.recipe_data_service import RecipeDataService from mealie.services.recipe.recipe_data_service import RecipeDataService
@@ -512,6 +513,251 @@ def test_update_many(api_client: TestClient, unique_user: TestUser, use_patch: b
assert get_response.json()["slug"] == updated_recipe_data["slug"] assert get_response.json()["slug"] == updated_recipe_data["slug"]
def test_recipe_recursion_valid_linear_chain(api_client: TestClient, unique_user: TestUser):
"""Test that valid deep nesting without cycles is allowed (a -> b -> c)."""
database = unique_user.repos
food = database.ingredient_foods.create(
SaveIngredientFood(
name=random_string(10),
group_id=unique_user.group_id,
)
)
# Create recipe_c with just a food ingredient (base recipe)
recipe_c: Recipe = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note="", food=food),
],
)
)
# Create recipe_b that references recipe_c (c -> b)
recipe_b = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note="", referenced_recipe=recipe_c),
],
)
)
# Update recipe_a to reference recipe_b (b -> a, creating chain c -> b -> a)
recipe_a: Recipe = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note="", food=food),
],
)
)
recipe_url = api_routes.recipes_slug(recipe_a.slug)
response = api_client.get(recipe_url, headers=unique_user.token)
assert response.status_code == 200
recipe_data = json.loads(response.text)
recipe_data["recipeIngredient"].append(
{
"note": "",
"referencedRecipe": {"id": str(recipe_b.id)},
}
)
response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token)
assert response.status_code == 200
def test_recipe_recursion_cycle_two_level(api_client: TestClient, unique_user: TestUser):
"""Test that two-level cycles (a -> b -> a) are detected and rejected."""
database = unique_user.repos
food = database.ingredient_foods.create(
SaveIngredientFood(
name=random_string(10),
group_id=unique_user.group_id,
)
)
# Create recipe_a
recipe_a: Recipe = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note="", food=food),
],
)
)
# Create recipe_b that references recipe_a (a -> b)
recipe_b = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note="", referenced_recipe=recipe_a),
],
)
)
# Try to update recipe_a to reference recipe_b, creating a cycle (b -> a)
recipe_url = api_routes.recipes_slug(recipe_a.slug)
response = api_client.get(recipe_url, headers=unique_user.token)
assert response.status_code == 200
recipe_data = json.loads(response.text)
recipe_data["recipeIngredient"].append(
{
"note": "",
"referencedRecipe": {"id": str(recipe_b.id)},
}
)
response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token)
assert response.status_code == 400
assert "cannot reference itself" in response.text.lower()
def test_recipe_recursion_cycle_three_level(api_client: TestClient, unique_user: TestUser):
"""Test that three-level cycles (a -> b -> c -> a) are detected and rejected."""
database = unique_user.repos
food = database.ingredient_foods.create(
SaveIngredientFood(
name=random_string(10),
group_id=unique_user.group_id,
)
)
# Create recipe_a
recipe_a: Recipe = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note="", food=food),
],
)
)
# Create recipe_b that references recipe_a (a -> b)
recipe_b = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note="", referenced_recipe=recipe_a),
],
)
)
# Create recipe_c that references recipe_b (b -> c, creating chain a -> b -> c)
recipe_c = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note="", referenced_recipe=recipe_b),
],
)
)
# Try to update recipe_a to reference recipe_c, creating a cycle (c -> a)
recipe_url = api_routes.recipes_slug(recipe_a.slug)
response = api_client.get(recipe_url, headers=unique_user.token)
assert response.status_code == 200
recipe_data = json.loads(response.text)
recipe_data["recipeIngredient"].append(
{
"note": "",
"referencedRecipe": {"id": str(recipe_c.id)},
}
)
response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token)
assert response.status_code == 400
assert "cannot reference itself" in response.text.lower()
def test_recipe_reference_deleted(api_client: TestClient, unique_user: TestUser):
"""Test that when a referenced recipe is deleted, the parent recipe remains intact."""
database = unique_user.repos
food = database.ingredient_foods.create(
SaveIngredientFood(
name=random_string(10),
group_id=unique_user.group_id,
)
)
# Create recipe_b
recipe_b: Recipe = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note="", food=food),
],
)
)
# Create recipe_a that references recipe_b
recipe_a = database.recipes.create(
Recipe(
name=random_string(10),
user_id=unique_user.user_id,
group_id=unique_user.group_id,
recipe_ingredient=[
RecipeIngredient(note="ingredient 1", referenced_recipe=recipe_b),
RecipeIngredient(note="ingredient 2", food=food),
],
)
)
# Verify recipe_a has the reference to recipe_b
recipe_a_url = api_routes.recipes_slug(recipe_a.slug)
response = api_client.get(recipe_a_url, headers=unique_user.token)
assert response.status_code == 200
recipe_a_data = json.loads(response.text)
assert len(recipe_a_data["recipeIngredient"]) == 2
assert recipe_a_data["recipeIngredient"][0]["referencedRecipe"] is not None
assert recipe_a_data["recipeIngredient"][0]["referencedRecipe"]["id"] == str(recipe_b.id)
# Delete recipe_b
recipe_b_url = api_routes.recipes_slug(recipe_b.slug)
response = api_client.delete(recipe_b_url, headers=unique_user.token)
assert response.status_code == 200
# Verify recipe_b is deleted
response = api_client.get(recipe_b_url, headers=unique_user.token)
assert response.status_code == 404
# Verify recipe_a still exists and can be retrieved
response = api_client.get(recipe_a_url, headers=unique_user.token)
assert response.status_code == 200
recipe_a_data = json.loads(response.text)
# The ingredient with the deleted reference should still exist but with no valid reference
assert len(recipe_a_data["recipeIngredient"]) == 2
assert recipe_a_data["recipeIngredient"][0]["note"] == "ingredient 1"
assert recipe_a_data["recipeIngredient"][1]["note"] == "ingredient 2"
# The referenced recipe should be None or not present since it was deleted
assert recipe_a_data["recipeIngredient"][0]["referencedRecipe"] is None
def test_duplicate(api_client: TestClient, unique_user: TestUser): def test_duplicate(api_client: TestClient, unique_user: TestUser):
recipe_data = recipe_test_data[0] recipe_data = recipe_test_data[0]