mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-11-13 23:42:52 -05:00
feat: Add recipe as ingredient (#4800)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
ff42964537
commit
60d9294861
File diff suppressed because one or more lines are too long
@@ -139,7 +139,7 @@
|
||||
color="secondary"
|
||||
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
|
||||
:ingredient="ingredientData.ingredient"
|
||||
:scale="recipeSection.recipeScale"
|
||||
@@ -287,12 +287,35 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
|
||||
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
|
||||
return {
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = [];
|
||||
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
|
||||
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),
|
||||
ingredient: ing,
|
||||
};
|
||||
ingredient: {
|
||||
...ing,
|
||||
title: ing.title || parentTitle,
|
||||
},
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
recipe.recipeIngredient.forEach((ing) => {
|
||||
const flattened = flattenRecipeIngredients(ing, "");
|
||||
shoppingListIngredients.push(...flattened);
|
||||
});
|
||||
|
||||
let currentTitle = "";
|
||||
@@ -301,6 +324,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
if (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 (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
|
||||
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
|
||||
const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
|
||||
if (householdsWithFood.includes(userHousehold.value)) {
|
||||
onHandIngs.push(ing);
|
||||
return sections;
|
||||
|
||||
@@ -141,6 +141,13 @@ function save() {
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
function open() {
|
||||
dialog.value = true;
|
||||
}
|
||||
function close() {
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const utilities = [
|
||||
@@ -160,4 +167,10 @@ const utilities = [
|
||||
action: splitByNumberedLine,
|
||||
},
|
||||
];
|
||||
|
||||
// Expose functions to parent components
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -69,7 +69,14 @@
|
||||
:label="$t('recipe.nutrition')"
|
||||
/>
|
||||
</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-row>
|
||||
</v-container>
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="!state.isRecipe"
|
||||
sm="12"
|
||||
md="3"
|
||||
cols="12"
|
||||
@@ -97,6 +98,7 @@
|
||||
|
||||
<!-- Foods Input -->
|
||||
<v-col
|
||||
v-if="!state.isRecipe"
|
||||
m="12"
|
||||
md="3"
|
||||
cols="12"
|
||||
@@ -151,6 +153,33 @@
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</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
|
||||
sm="12"
|
||||
md=""
|
||||
@@ -173,6 +202,7 @@
|
||||
class="my-auto d-flex"
|
||||
:buttons="btns"
|
||||
@toggle-section="toggleTitle"
|
||||
@toggle-subrecipe="toggleIsRecipe"
|
||||
@insert-above="$emit('insert-above')"
|
||||
@insert-below="$emit('insert-below')"
|
||||
@delete="$emit('delete')"
|
||||
@@ -195,6 +225,8 @@ import { useI18n } from "vue-i18n";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
import { useNuxtApp } from "#app";
|
||||
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
|
||||
const model = defineModel<RecipeIngredient>({ required: true });
|
||||
@@ -204,6 +236,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: "body",
|
||||
},
|
||||
isRecipe: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
unitError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -247,6 +283,7 @@ const { $globals } = useNuxtApp();
|
||||
|
||||
const state = reactive({
|
||||
showTitle: false,
|
||||
isRecipe: props.isRecipe,
|
||||
});
|
||||
|
||||
const contextMenuOptions = computed(() => {
|
||||
@@ -255,6 +292,10 @@ const contextMenuOptions = computed(() => {
|
||||
text: i18n.t("recipe.toggle-section"),
|
||||
event: "toggle-section",
|
||||
},
|
||||
{
|
||||
text: i18n.t("recipe.toggle-recipe"),
|
||||
event: "toggle-subrecipe",
|
||||
},
|
||||
{
|
||||
text: i18n.t("recipe.insert-above"),
|
||||
event: "insert-above",
|
||||
@@ -303,6 +344,25 @@ async function createAssignFood() {
|
||||
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
|
||||
const unitStore = useUnitStore();
|
||||
const unitsData = useUnitData();
|
||||
@@ -323,6 +383,17 @@ function toggleTitle() {
|
||||
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() {
|
||||
if (
|
||||
model.value.unit === undefined
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
class="text-bold d-inline"
|
||||
: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>
|
||||
<SafeMarkdown
|
||||
v-if="parsedIng.name"
|
||||
@@ -39,9 +43,12 @@ interface Props {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
});
|
||||
const route = useRoute();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
const parsedIng = computed(() => {
|
||||
return useParsedIngredientText(props.ingredient, props.scale);
|
||||
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -20,6 +20,29 @@
|
||||
persistent-hint
|
||||
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-row>
|
||||
<v-col cols="6">
|
||||
@@ -166,6 +189,21 @@ onMounted(async () => {
|
||||
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(
|
||||
() => 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
|
||||
let imageError = false;
|
||||
if (newTimelineEventImage.value) {
|
||||
@@ -268,7 +337,6 @@ async function createTimelineEvent() {
|
||||
console.error("Failed to upload image for timeline event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageError) {
|
||||
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
|
||||
}
|
||||
|
||||
@@ -290,10 +290,13 @@ watch(isParsing, () => {
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||
recipe.value = data as NoUndefinedField<Recipe>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||
:key="ingredient.referenceId"
|
||||
v-model="recipe.recipeIngredient[index]"
|
||||
:is-recipe="ingredientIsRecipe(ingredient)"
|
||||
enable-drag-handle
|
||||
enable-context-menu
|
||||
class="list-group-item"
|
||||
@@ -69,15 +70,59 @@
|
||||
<span>{{ parserToolTip }}</span>
|
||||
</v-tooltip>
|
||||
<RecipeDialogBulkAdd
|
||||
ref="domBulkAddDialog"
|
||||
class="mx-1 mb-1"
|
||||
style="display: none"
|
||||
@bulk-data="addIngredient"
|
||||
/>
|
||||
<BaseButton
|
||||
class="mb-1"
|
||||
<div class="d-inline-flex split-button">
|
||||
<!-- Main button: Add Food -->
|
||||
<v-btn
|
||||
color="success"
|
||||
class="split-main ml-2"
|
||||
@click="addIngredient"
|
||||
>
|
||||
{{ $t("general.add") }}
|
||||
</BaseButton>
|
||||
<v-icon start>
|
||||
{{ $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>
|
||||
</template>
|
||||
@@ -85,16 +130,18 @@
|
||||
<script setup lang="ts">
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
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 RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
const ingredientsWithRecipe = new Map<string, boolean>();
|
||||
const i18n = useI18n();
|
||||
|
||||
const drag = ref(false);
|
||||
const domBulkAddDialog = ref<InstanceType<typeof RecipeDialogBulkAdd> | null>(null);
|
||||
const { toggleIsParsing } = usePageState(recipe.value.slug);
|
||||
|
||||
const hasFoodOrUnit = computed(() => {
|
||||
@@ -118,6 +165,22 @@ const parserToolTip = computed(() => {
|
||||
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) {
|
||||
if (ingredients?.length) {
|
||||
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) {
|
||||
recipe.value.recipeIngredient.splice(dest, 0, {
|
||||
referenceId: uuid4(),
|
||||
@@ -163,3 +261,22 @@ function insertNewIngredient(dest: number) {
|
||||
});
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -268,6 +268,11 @@ const state = reactive({
|
||||
function shouldReview(ing: ParsedIngredient): boolean {
|
||||
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) {
|
||||
console.debug("Needs review due to low confidence:", ing.confidence?.average);
|
||||
return true;
|
||||
@@ -364,12 +369,21 @@ async function parseIngredients() {
|
||||
}
|
||||
state.loading.parser = true;
|
||||
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);
|
||||
if (error || !data) {
|
||||
throw new Error("Failed to parse ingredients");
|
||||
}
|
||||
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.allReviewed = false;
|
||||
createdUnits.clear();
|
||||
|
||||
@@ -262,32 +262,55 @@ const ingredientSections = computed<IngredientSection[]>(() => {
|
||||
if (!props.recipe.recipeIngredient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
|
||||
// if title append new section to the end of the array
|
||||
if (ingredient.title) {
|
||||
sections.push({
|
||||
sectionName: ingredient.title,
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
return sections;
|
||||
const addIngredientsToSections = (ingredients: RecipeIngredient[], sections: IngredientSection[], title: string | null) => {
|
||||
// If title is set, ensure the section exists before adding ingredients
|
||||
let section: IngredientSection | undefined;
|
||||
if (title) {
|
||||
section = sections.find(sec => sec.sectionName === title);
|
||||
if (!section) {
|
||||
section = { sectionName: title, ingredients: [] };
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add ingredient to last section in the array
|
||||
else {
|
||||
sections[sections.length - 1].ingredients.push(ingredient);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const sections: IngredientSection[] = [];
|
||||
addIngredientsToSections(props.recipe.recipeIngredient, sections, null);
|
||||
return sections;
|
||||
}, [] as IngredientSection[]);
|
||||
});
|
||||
|
||||
// Group instructions by section so we can style them independently
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
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();
|
||||
|
||||
@@ -36,8 +36,28 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
|
||||
const { quantity, food, unit, note, title } = ingredient;
|
||||
function useRecipeLink(recipe: Recipe | undefined, groupSlug: string | undefined): string | undefined {
|
||||
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 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 foodName = useFoodName(food || undefined, usePluralFood);
|
||||
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
|
||||
|
||||
return {
|
||||
title: title ? sanitizeIngredientHTML(title) : undefined,
|
||||
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
||||
name: foodName ? sanitizeIngredientHTML(foodName) : undefined,
|
||||
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
|
||||
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface UserPrintPreferences {
|
||||
showDescription: boolean;
|
||||
showNotes: boolean;
|
||||
showNutrition: boolean;
|
||||
expandChildRecipes: boolean;
|
||||
}
|
||||
|
||||
export interface UserSearchQuery {
|
||||
@@ -91,6 +92,7 @@ export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
|
||||
imagePosition: "left",
|
||||
showDescription: true,
|
||||
showNotes: true,
|
||||
expandChildRecipes: false,
|
||||
},
|
||||
{ mergeDefaults: true },
|
||||
// we cast to a Ref because by default it will return an optional type ref
|
||||
|
||||
@@ -448,7 +448,9 @@
|
||||
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
|
||||
"import-by-url": "Import a recipe by URL",
|
||||
"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": {
|
||||
"404-page-not-found": "404 Page not found",
|
||||
@@ -590,6 +592,7 @@
|
||||
"made-this": "I Made This",
|
||||
"how-did-it-turn-out": "How did it turn out?",
|
||||
"user-made-this": "{user} made this",
|
||||
"made-for-recipe": "Made for {recipe}",
|
||||
"added-to-timeline": "Added to timeline",
|
||||
"failed-to-add-to-timeline": "Failed to add to timeline",
|
||||
"failed-to-update-recipe": "Failed to update recipe",
|
||||
@@ -691,7 +694,10 @@
|
||||
"upload-images": "Upload images",
|
||||
"upload-more-images": "Upload more images",
|
||||
"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",
|
||||
|
||||
@@ -309,6 +309,7 @@ export interface RecipeIngredient {
|
||||
quantity?: number | null;
|
||||
unit?: IngredientUnit | CreateIngredientUnit | null;
|
||||
food?: IngredientFood | CreateIngredientFood | null;
|
||||
referencedRecipe?: Recipe | null;
|
||||
note?: string | null;
|
||||
display?: string;
|
||||
title?: string | null;
|
||||
@@ -396,6 +397,129 @@ export interface CreateIngredientFoodAlias {
|
||||
name: string;
|
||||
[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 {
|
||||
recipeIncrementQuantity?: number;
|
||||
recipeIngredients?: RecipeIngredient[] | null;
|
||||
@@ -413,6 +537,7 @@ export interface ShoppingListItemBase {
|
||||
quantity?: number;
|
||||
unit?: IngredientUnit | CreateIngredientUnit | null;
|
||||
food?: IngredientFood | CreateIngredientFood | null;
|
||||
referencedRecipe?: Recipe | null;
|
||||
note?: string | null;
|
||||
display?: string;
|
||||
shoppingListId: string;
|
||||
@@ -429,6 +554,7 @@ export interface ShoppingListItemCreate {
|
||||
quantity?: number;
|
||||
unit?: IngredientUnit | CreateIngredientUnit | null;
|
||||
food?: IngredientFood | CreateIngredientFood | null;
|
||||
referencedRecipe?: Recipe | null;
|
||||
note?: string | null;
|
||||
display?: string;
|
||||
shoppingListId: string;
|
||||
@@ -453,6 +579,7 @@ export interface ShoppingListItemOut {
|
||||
quantity?: number;
|
||||
unit?: IngredientUnit | null;
|
||||
food?: IngredientFood | null;
|
||||
referencedRecipe?: Recipe | null;
|
||||
note?: string | null;
|
||||
display?: string;
|
||||
shoppingListId: string;
|
||||
@@ -492,6 +619,7 @@ export interface ShoppingListItemUpdate {
|
||||
quantity?: number;
|
||||
unit?: IngredientUnit | CreateIngredientUnit | null;
|
||||
food?: IngredientFood | CreateIngredientFood | null;
|
||||
referencedRecipe?: Recipe | null;
|
||||
note?: string | null;
|
||||
display?: string;
|
||||
shoppingListId: string;
|
||||
@@ -509,6 +637,7 @@ export interface ShoppingListItemUpdateBulk {
|
||||
quantity?: number;
|
||||
unit?: IngredientUnit | CreateIngredientUnit | null;
|
||||
food?: IngredientFood | CreateIngredientFood | null;
|
||||
referencedRecipe?: Recipe | null;
|
||||
note?: string | null;
|
||||
display?: string;
|
||||
shoppingListId: string;
|
||||
@@ -595,28 +724,6 @@ export interface RecipeSummary {
|
||||
updatedAt?: 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 {
|
||||
recipeDecrementQuantity?: number;
|
||||
}
|
||||
@@ -682,6 +789,7 @@ export interface RecipeIngredientBase {
|
||||
quantity?: number | null;
|
||||
unit?: IngredientUnit | CreateIngredientUnit | null;
|
||||
food?: IngredientFood | CreateIngredientFood | null;
|
||||
referencedRecipe?: Recipe | null;
|
||||
note?: string | null;
|
||||
display?: string;
|
||||
}
|
||||
|
||||
@@ -214,6 +214,7 @@ export interface RecipeIngredient {
|
||||
quantity?: number | null;
|
||||
unit?: IngredientUnit | CreateIngredientUnit | null;
|
||||
food?: IngredientFood | CreateIngredientFood | null;
|
||||
referencedRecipe?: Recipe | null;
|
||||
note?: string | null;
|
||||
display?: string;
|
||||
title?: string | null;
|
||||
@@ -349,6 +350,7 @@ export interface RecipeIngredientBase {
|
||||
quantity?: number | null;
|
||||
unit?: IngredientUnit | CreateIngredientUnit | null;
|
||||
food?: IngredientFood | CreateIngredientFood | null;
|
||||
referencedRecipe?: Recipe | null;
|
||||
note?: string | null;
|
||||
display?: string;
|
||||
}
|
||||
|
||||
@@ -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 ###
|
||||
@@ -22,6 +22,14 @@ class PermissionDenied(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RecursiveRecipe(Exception):
|
||||
"""
|
||||
This exception is raised when a recipe references itself, either directly or indirectly.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SlugError(Exception):
|
||||
"""
|
||||
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"),
|
||||
NoEntryFound: t.t("exceptions.no-entry-found"),
|
||||
IntegrityError: t.t("exceptions.integrity-error"),
|
||||
RecursiveRecipe: t.t("exceptions.recursive-recipe-link"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from .._model_utils.guid import GUID
|
||||
if TYPE_CHECKING:
|
||||
from ..group import Group
|
||||
from ..household import Household
|
||||
|
||||
from .recipe import RecipeModel
|
||||
|
||||
households_to_ingredient_foods = sa.Table(
|
||||
"households_to_ingredient_foods",
|
||||
@@ -358,6 +358,12 @@ class RecipeIngredientModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
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
|
||||
note_normalized: Mapped[str | None] = mapped_column(String, index=True)
|
||||
original_text_normalized: Mapped[str | None] = mapped_column(String, index=True)
|
||||
|
||||
@@ -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.datetime import NaiveDateTime, get_utc_today
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from ..household.household_to_recipe import HouseholdToRecipe
|
||||
@@ -22,7 +23,6 @@ from .api_extras import ApiExtras, api_extras
|
||||
from .assets import RecipeAsset
|
||||
from .category import recipes_to_categories
|
||||
from .comment import RecipeComment
|
||||
from .ingredient import RecipeIngredientModel
|
||||
from .instruction import RecipeInstruction
|
||||
from .note import Note
|
||||
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")
|
||||
|
||||
recipe_ingredient: Mapped[list[RecipeIngredientModel]] = orm.relationship(
|
||||
recipe_ingredient: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
|
||||
"RecipeIngredientModel",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="RecipeIngredientModel.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(
|
||||
"RecipeInstruction",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"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",
|
||||
"integrity-error": "Database integrity error",
|
||||
"username-conflict-error": "This username is already taken",
|
||||
|
||||
@@ -94,6 +94,12 @@ class RecipeController(BaseRecipeController):
|
||||
raise HTTPException(
|
||||
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:
|
||||
self.logger.error("Failed to generate a valid slug from recipe name")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -14,6 +14,7 @@ from mealie.db.models.recipe import IngredientFoodModel
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema._mealie.mealie_model import UpdatedAtField
|
||||
from mealie.schema._mealie.types import NoneFloat
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
|
||||
INGREDIENT_QTY_PRECISION = 3
|
||||
@@ -155,8 +156,9 @@ class RecipeIngredientBase(MealieModel):
|
||||
quantity: NoneFloat = 0
|
||||
unit: IngredientUnit | CreateIngredientUnit | None = None
|
||||
food: IngredientFood | CreateIngredientFood | None = None
|
||||
note: str | None = ""
|
||||
referenced_recipe: Recipe | None = None
|
||||
|
||||
note: str | None = ""
|
||||
display: str = ""
|
||||
"""
|
||||
How the ingredient should be displayed
|
||||
|
||||
@@ -20,6 +20,7 @@ from mealie.schema.household.group_shopping_list import (
|
||||
ShoppingListOut,
|
||||
ShoppingListSave,
|
||||
)
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_ingredient import (
|
||||
IngredientFood,
|
||||
IngredientUnit,
|
||||
@@ -315,10 +316,22 @@ class ShoppingListService:
|
||||
|
||||
list_items: list[ShoppingListItemCreate] = []
|
||||
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):
|
||||
food_id = ingredient.food.id
|
||||
label_id = ingredient.food.label_id
|
||||
|
||||
else:
|
||||
food_id = None
|
||||
label_id = None
|
||||
|
||||
@@ -369,6 +369,27 @@ class RecipeService(RecipeServiceBase):
|
||||
|
||||
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:
|
||||
"""
|
||||
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):
|
||||
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
|
||||
|
||||
def update_one(self, slug_or_id: str | UUID, update_data: Recipe) -> Recipe:
|
||||
|
||||
@@ -11,6 +11,7 @@ from mealie.schema.household.group_shopping_list import (
|
||||
ShoppingListOut,
|
||||
)
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
|
||||
from tests import utils
|
||||
from tests.utils import api_routes
|
||||
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
|
||||
|
||||
|
||||
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("household_lock_recipe_edits", [True, False])
|
||||
def test_shopping_lists_add_cross_household_recipe(
|
||||
|
||||
@@ -23,6 +23,7 @@ from mealie.pkgs.safehttp.transport import AsyncSafeTransport
|
||||
from mealie.schema.cookbook.cookbook import SaveCookBook
|
||||
from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary, RecipeTag
|
||||
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_tool import RecipeToolSave
|
||||
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"]
|
||||
|
||||
|
||||
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):
|
||||
recipe_data = recipe_test_data[0]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user