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"
|
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 || [];
|
||||||
checked: !householdsWithFood.includes(userHousehold.value),
|
if (ing.referencedRecipe) {
|
||||||
ingredient: ing,
|
// 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,
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
setMode(PageMode.VIEW);
|
if (!error) {
|
||||||
|
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>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
@click="addIngredient"
|
<v-btn
|
||||||
>
|
color="success"
|
||||||
{{ $t("general.add") }}
|
class="split-main ml-2"
|
||||||
</BaseButton>
|
@click="addIngredient"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 (sections.length === 0) {
|
if (preferences.value.expandChildRecipes && ingredient.referencedRecipe?.recipeIngredient?.length) {
|
||||||
sections.push({
|
// Recursively add to the section for this referenced recipe
|
||||||
sectionName: "",
|
addIngredientsToSections(
|
||||||
ingredients: [ingredient],
|
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],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
sections[sections.length - 1].ingredients.push(ingredient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return sections;
|
const sections: IngredientSection[] = [];
|
||||||
}
|
addIngredientsToSections(props.recipe.recipeIngredient, sections, null);
|
||||||
|
return sections;
|
||||||
// otherwise add ingredient to last section in the array
|
|
||||||
sections[sections.length - 1].ingredients.push(ingredient);
|
|
||||||
return sections;
|
|
||||||
}, [] as IngredientSection[]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group instructions by section so we can style them independently
|
// Group instructions by section so we can style them independently
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user