feat: Upgraded Ingredient Parsing Workflow (#6151)

This commit is contained in:
Michael Genson
2025-09-20 23:37:14 -05:00
committed by GitHub
parent 9e5a54477f
commit c929a03b57
15 changed files with 758 additions and 547 deletions

View File

@@ -165,12 +165,12 @@
@click="$emit('clickIngredientField', 'note')"
/>
<BaseButtonGroup
v-if="enableContextMenu"
hover
:large="false"
class="my-auto d-flex"
:buttons="btns"
@toggle-section="toggleTitle"
@toggle-original="toggleOriginalText"
@insert-above="$emit('insert-above')"
@insert-below="$emit('insert-below')"
@delete="$emit('delete')"
@@ -178,13 +178,6 @@
</div>
</v-col>
</v-row>
<p
v-if="showOriginalText"
class="text-caption"
>
{{ $t("recipe.original-text-with-value", { originalText: model.originalText }) }}
</p>
<v-divider
v-if="!mdAndUp"
class="my-4"
@@ -220,6 +213,10 @@ defineProps({
type: String,
default: "",
},
enableContextMenu: {
type: Boolean,
default: false,
},
});
defineEmits([
@@ -235,7 +232,6 @@ const { $globals } = useNuxtApp();
const state = reactive({
showTitle: false,
showOriginalText: false,
});
const contextMenuOptions = computed(() => {
@@ -254,13 +250,6 @@ const contextMenuOptions = computed(() => {
},
];
if (model.value.originalText) {
options.push({
text: i18n.t("recipe.see-original-text"),
event: "toggle-original",
});
}
return options;
});
@@ -319,10 +308,6 @@ function toggleTitle() {
state.showTitle = !state.showTitle;
}
function toggleOriginalText() {
state.showOriginalText = !state.showOriginalText;
}
function handleUnitEnter() {
if (
model.value.unit === undefined
@@ -349,7 +334,7 @@ function quantityFilter(e: KeyboardEvent) {
}
}
const { showTitle, showOriginalText } = toRefs(state);
const { showTitle } = toRefs(state);
const foods = foodStore.store;
const units = unitStore.store;

View File

@@ -1,5 +1,12 @@
<template>
<div>
<RecipePageParseDialog
:model-value="isParsing"
:ingredients="recipe.recipeIngredient"
:width="$vuetify.display.smAndDown ? '100%' : '80%'"
@update:model-value="toggleIsParsing"
@save="saveParsedIngredients"
/>
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown }">
<v-card :flat="$vuetify.display.smAndDown" class="d-print-none">
<RecipePageHeader
@@ -168,6 +175,7 @@ import RecipePageIngredientEditor from "./RecipePageParts/RecipePageIngredientEd
import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredientToolsView.vue";
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
import RecipePageParseDialog from "./RecipePageParts/RecipePageParseDialog.vue";
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
@@ -178,7 +186,7 @@ import {
usePageState,
} from "~/composables/recipe-page/shared-state";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import type { Recipe, RecipeCategory, RecipeIngredient, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { useRouteQuery } from "~/composables/use-router";
import { useUserApi } from "~/composables/api";
import { uuid4, deepCopy } from "~/composables/use-utils";
@@ -197,7 +205,7 @@ const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.use
const router = useRouter();
const api = useUserApi();
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode }
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, isParsing, toggleCookMode, toggleIsParsing }
= usePageState(recipe.value.slug);
const { deactivateNavigationWarning } = useNavigationWarning();
const notLinkedIngredients = computed(() => {
@@ -246,12 +254,29 @@ const hasLinkedIngredients = computed(() => {
type BooleanString = "true" | "false" | "";
const edit = useRouteQuery<BooleanString>("edit", "");
const paramsEdit = useRouteQuery<BooleanString>("edit", "");
const paramsParse = useRouteQuery<BooleanString>("parse", "");
onMounted(() => {
if (edit.value === "true") {
if (paramsEdit.value === "true") {
setMode(PageMode.EDIT);
}
if (paramsParse.value === "true") {
toggleIsParsing(true);
}
});
watch(isEditMode, (newVal) => {
if (!newVal) {
paramsEdit.value = undefined;
}
});
watch(isParsing, () => {
if (!isParsing.value) {
paramsParse.value = undefined;
}
});
/** =============================================================
@@ -266,6 +291,12 @@ async function saveRecipe() {
}
}
async function saveParsedIngredients(ingredients: NoUndefinedField<RecipeIngredient[]>) {
recipe.value.recipeIngredient = ingredients;
await saveRecipe();
toggleIsParsing(false);
}
async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(recipe.value.slug);
if (data?.slug) {
@@ -302,7 +333,7 @@ function addStep(steps: Array<string> | null = null) {
if (steps) {
const cleanedSteps = steps.map((step) => {
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
return { id: uuid4(), text: step, title: "", summary: "", ingredientReferences: [] };
});
recipe.value.recipeInstructions.push(...cleanedSteps);

View File

@@ -31,6 +31,7 @@
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
enable-context-menu
class="list-group-item"
@delete="recipe.recipeIngredient.splice(index, 1)"
@insert-above="insertNewIngredient(index)"
@@ -55,8 +56,8 @@
class="mb-1"
:disabled="hasFoodOrUnit"
color="accent"
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
v-bind="props"
@click="toggleIsParsing(true)"
>
<template #icon>
{{ $globals.icons.foods }}
@@ -87,16 +88,14 @@ import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } 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 i18n = useI18n();
const $auth = useMealieAuth();
const drag = ref(false);
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { toggleIsParsing } = usePageState(recipe.value.slug);
const hasFoodOrUnit = computed(() => {
if (!recipe.value) {

View File

@@ -0,0 +1,490 @@
<template>
<BaseDialog
:model-value="modelValue"
:title="$t('recipe.parse-ingredients')"
:icon="$globals.icons.fileSign"
@update:model-value="emit('update:modelValue', $event)"
>
<v-container class="pa-2 ma-0" style="background-color: rgb(var(--v-theme-background));">
<BaseCardSectionTitle :title="$t('recipe.parser.ingredient-parser')">
<div v-if="!state.allReviewed" class="mb-4">
<p>{{ $t("recipe.parser.ingredient-parser-description") }}</p>
<p>{{ $t("recipe.parser.ingredient-parser-final-review-description") }}</p>
</div>
<div class="d-flex flex-wrap align-center">
<div class="text-body-2 mr-2">
{{ $t("recipe.parser.select-parser") }}
</div>
<div class="d-flex align-center">
<BaseOverflowButton
v-model="parser"
:disabled="state.loading.parser"
btn-class="mx-2"
:items="availableParsers"
/>
<v-btn
icon
size="40"
color="info"
:disabled="state.loading.parser"
@click="parseIngredients"
>
<v-icon>{{ $globals.icons.refresh }}</v-icon>
</v-btn>
</div>
</div>
</BaseCardSectionTitle>
<AppLoader v-if="state.loading.parser" waiting-text="" class="my-6" />
<v-card v-else-if="!state.allReviewed && currentIng">
<v-card-text class="pb-0 mb-0">
<div class="text-center px-8 py-4 mb-6">
<p class="text-h5 font-italic">
{{ currentIng.input }}
</p>
</div>
<div class="d-flex align-center pa-0 ma-0">
<v-icon
:color="(currentIng.confidence?.average || 0) < confidenceThreshold ? 'error' : 'success'"
>
{{ (currentIng.confidence?.average || 0) < confidenceThreshold ? $globals.icons.alert : $globals.icons.check }}
</v-icon>
<span
class="ml-2"
:color="currentIngHasError ? 'error-text' : 'success-text'"
>
{{ $t("recipe.parser.confidence-score") }}: {{ currentIng.confidence ? asPercentage(currentIng.confidence?.average!) : "" }}
</span>
</div>
<RecipeIngredientEditor
v-model="currentIng.ingredient"
:unit-error="!!currentMissingUnit"
:unit-error-tooltip="$t('recipe.parser.this-unit-could-not-be-parsed-automatically')"
:food-error="!!currentMissingFood"
:food-error-tooltip="$t('recipe.parser.this-food-could-not-be-parsed-automatically')"
/>
<v-card-actions>
<v-spacer />
<BaseButton
v-if="currentMissingUnit && !currentIng.ingredient.unit?.id"
color="warning"
size="small"
@click="createMissingUnit"
>
{{ i18n.t("recipe.parser.missing-unit", { unit: currentMissingUnit }) }}
</BaseButton>
<BaseButton
v-if="currentMissingUnit && currentIng.ingredient.unit?.id"
color="warning"
size="small"
@click="addMissingUnitAsAlias"
>
{{ i18n.t("recipe.parser.add-text-as-alias-for-item", { text: currentMissingUnit, item: currentIng.ingredient.unit.name }) }}
</BaseButton>
<BaseButton
v-if="currentMissingFood && !currentIng.ingredient.food?.id"
color="warning"
size="small"
@click="createMissingFood"
>
{{ i18n.t("recipe.parser.missing-food", { food: currentMissingFood }) }}
</BaseButton>
<BaseButton
v-if="currentMissingFood && currentIng.ingredient.food?.id"
color="warning"
size="small"
@click="addMissingFoodAsAlias"
>
{{ i18n.t("recipe.parser.add-text-as-alias-for-item", { text: currentMissingFood, item: currentIng.ingredient.food.name }) }}
</BaseButton>
</v-card-actions>
</v-card-text>
</v-card>
<v-expansion-panels v-else>
<v-card-title>{{ $t("recipe.parser.parsing-completed") }}</v-card-title>
<v-expansion-panel>
<v-expansion-panel-title>
{{ $t("recipe.parser.review-parsed-ingredients") }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<VueDraggable
v-model="parsedIngs"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
group: 'recipe-ingredients',
disabled: false,
ghostClass: 'ghost',
}"
@start="drag = true"
@end="drag = false"
>
<TransitionGroup
type="transition"
>
<div v-for="(ingredient, index) in parsedIngs" :key="index">
<RecipeIngredientEditor
v-model="ingredient.ingredient"
enable-context-menu
class="list-group-item"
@delete="parsedIngs.splice(index, 1)"
@insert-above="insertNewIngredient(index)"
@insert-below="insertNewIngredient(index + 1)"
/>
<p class="pt-0 pb-4 my-0 text-caption">
{{ $t("recipe.original-text-with-value", { originalText: ingredient.input }) }}
</p>
</div>
</TransitionGroup>
</VueDraggable>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-container>
<template v-if="!state.loading.parser" #custom-card-action>
<BaseButton
v-if="!state.allReviewed"
color="info"
:icon="$globals.icons.arrowRightBold"
icon-right
:text="$t('general.next')"
@click="nextIngredient"
/>
<BaseButton
v-else
create
:text="$t('general.save')"
:icon="$globals.icons.save"
:loading="state.loading.save"
@click="saveIngs"
/>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe";
import type { Parser } from "~/lib/api/user/recipes/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useAppInfo, useUserApi } from "~/composables/api";
import { parseIngredientText } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useGlobalI18n } from "~/composables/use-global-i18n";
import { alert } from "~/composables/use-toast";
import { useParsingPreferences } from "~/composables/use-users/preferences";
const props = defineProps<{
modelValue: boolean;
ingredients: NoUndefinedField<RecipeIngredient[]>;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
}>();
const i18n = useGlobalI18n();
const api = useUserApi();
const appInfo = useAppInfo();
const drag = ref(false);
const unitStore = useUnitStore();
const unitData = useUnitData();
const foodStore = useFoodStore();
const foodData = useFoodData();
const parserPreferences = useParsingPreferences();
const parser = ref<Parser>(parserPreferences.value.parser || "nlp");
const availableParsers = computed(() => {
return [
{
text: i18n.t("recipe.parser.natural-language-processor"),
value: "nlp",
},
{
text: i18n.t("recipe.parser.brute-parser"),
value: "brute",
},
{
text: i18n.t("recipe.parser.openai-parser"),
value: "openai",
hide: !appInfo.value?.enableOpenai,
},
];
});
/**
* If confidence of parsing is below this threshold,
* we will prompt the user to review the parsed ingredient.
*/
const confidenceThreshold = 0.85;
const parsedIngs = ref<ParsedIngredient[]>([]);
const currentIng = ref<ParsedIngredient | null>(null);
const currentMissingUnit = ref("");
const currentMissingFood = ref("");
const currentIngHasError = computed(() => currentMissingUnit.value || currentMissingFood.value);
const state = reactive({
currentParsedIndex: -1,
allReviewed: false,
loading: {
parser: false,
save: false,
},
});
function shouldReview(ing: ParsedIngredient): boolean {
console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing);
if ((ing.confidence?.average || 0) < confidenceThreshold) {
console.debug("Needs review due to low confidence:", ing.confidence?.average);
return true;
}
const food = ing.ingredient.food;
if (food && !food.id) {
console.debug("Needs review due to missing food ID:", food);
return true;
}
const unit = ing.ingredient.unit;
if (unit && !unit.id) {
console.debug("Needs review due to missing unit ID:", unit);
return true;
}
console.debug("No review needed");
return false;
}
function checkUnit(ing: ParsedIngredient) {
const unit = ing.ingredient.unit?.name;
if (!unit || ing.ingredient.unit?.id) {
currentMissingUnit.value = "";
return;
}
const potentialMatch = createdUnits.get(unit.toLowerCase());
if (potentialMatch) {
ing.ingredient.unit = potentialMatch;
currentMissingUnit.value = "";
return;
}
currentMissingUnit.value = unit;
ing.ingredient.unit = undefined;
}
function checkFood(ing: ParsedIngredient) {
const food = ing.ingredient.food?.name;
if (!food || ing.ingredient.food?.id) {
currentMissingFood.value = "";
return;
}
const potentialMatch = createdFoods.get(food.toLowerCase());
if (potentialMatch) {
ing.ingredient.food = potentialMatch;
currentMissingFood.value = "";
return;
}
currentMissingFood.value = food;
ing.ingredient.food = undefined;
}
function nextIngredient() {
let nextIndex = state.currentParsedIndex + 1;
while (nextIndex < parsedIngs.value.length) {
const current = parsedIngs.value[nextIndex];
if (shouldReview(current)) {
state.currentParsedIndex = nextIndex;
currentIng.value = current;
checkUnit(current);
checkFood(current);
return;
}
nextIndex += 1;
}
// No more to review
state.allReviewed = true;
}
async function parseIngredients() {
if (state.loading.parser) {
return;
}
if (!props.ingredients || props.ingredients.length === 0) {
state.loading.parser = false;
return;
}
state.loading.parser = true;
try {
const ingsAsString = props.ingredients.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;
state.currentParsedIndex = -1;
state.allReviewed = false;
createdUnits.clear();
createdFoods.clear();
nextIngredient();
}
catch (error) {
console.error("Error parsing ingredients:", error);
alert.error(i18n.t("events.something-went-wrong"));
}
finally {
state.loading.parser = false;
}
}
/** Cache of lowercased created units to avoid duplicate creations */
const createdUnits = new Map<string, IngredientUnit>();
/** Cache of lowercased created foods to avoid duplicate creations */
const createdFoods = new Map<string, IngredientFood>();
async function createMissingUnit() {
if (!currentMissingUnit.value) {
return;
}
unitData.reset();
unitData.data.name = currentMissingUnit.value;
let newUnit: IngredientUnit | null = null;
if (createdUnits.has(unitData.data.name)) {
newUnit = createdUnits.get(unitData.data.name)!;
}
else {
newUnit = await unitStore.actions.createOne(unitData.data);
}
if (!newUnit) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.unit = newUnit;
createdUnits.set(newUnit.name.toLowerCase(), newUnit);
currentMissingUnit.value = "";
}
async function createMissingFood() {
if (!currentMissingFood.value) {
return;
}
foodData.reset();
foodData.data.name = currentMissingFood.value;
let newFood: IngredientFood | null = null;
if (createdFoods.has(foodData.data.name)) {
newFood = createdFoods.get(foodData.data.name)!;
}
else {
newFood = await foodStore.actions.createOne(foodData.data);
}
if (!newFood) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.food = newFood;
createdFoods.set(newFood.name.toLowerCase(), newFood);
currentMissingFood.value = "";
}
async function addMissingUnitAsAlias() {
const unit = currentIng.value?.ingredient.unit as IngredientUnit | undefined;
if (!currentMissingUnit.value || !unit?.id) {
return;
}
unit.aliases = unit.aliases || [];
if (unit.aliases.map(a => a.name).includes(currentMissingUnit.value)) {
return;
}
unit.aliases.push({ name: currentMissingUnit.value });
const updated = await unitStore.actions.updateOne(unit);
if (!updated) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.unit = updated;
currentMissingUnit.value = "";
}
async function addMissingFoodAsAlias() {
const food = currentIng.value?.ingredient.food as IngredientFood | undefined;
if (!currentMissingFood.value || !food?.id) {
return;
}
food.aliases = food.aliases || [];
if (food.aliases.map(a => a.name).includes(currentMissingFood.value)) {
return;
}
food.aliases.push({ name: currentMissingFood.value });
const updated = await foodStore.actions.updateOne(food);
if (!updated) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.food = updated;
currentMissingFood.value = "";
}
watch(() => props.modelValue, () => {
if (!props.modelValue) {
return;
}
parseIngredients();
});
watch(parser, () => {
parserPreferences.value.parser = parser.value;
parseIngredients();
});
function asPercentage(num: number | undefined): string {
if (!num) {
return "0%";
}
return Math.round(num * 100).toFixed(2) + "%";
}
function insertNewIngredient(index: number) {
const ing = {
input: "",
confidence: {},
ingredient: {
quantity: 1.0,
referenceId: uuid4(),
},
} as ParsedIngredient;
parsedIngs.value.splice(index, 0, ing);
}
function saveIngs() {
emit("save", parsedIngs.value.map(x => x.ingredient as NoUndefinedField<RecipeIngredient>));
state.loading.save = true;
}
</script>

View File

@@ -22,7 +22,7 @@
</template>
<script setup lang="ts">
const { size } = withDefaults(defineProps<{ size?: number }>(), { size: 75 });
withDefaults(defineProps<{ size?: number }>(), { size: 75 });
</script>
<style scoped>

View File

@@ -44,11 +44,16 @@ interface PageState {
* true is the page is in cook mode.
*/
isCookMode: ComputedRef<boolean>;
/**
* true if the recipe is currently being parsed.
*/
isParsing: ComputedRef<boolean>;
setMode: (v: PageMode) => void;
setEditMode: (v: EditorMode) => void;
toggleEditMode: () => void;
toggleCookMode: () => void;
toggleIsParsing: (v?: boolean) => void;
}
type PageRefs = ReturnType<typeof pageRefs>;
@@ -60,11 +65,12 @@ function pageRefs(slug: string) {
slugRef: ref(slug),
pageModeRef: ref(PageMode.VIEW),
editModeRef: ref(EditorMode.FORM),
isParsingRef: ref(false),
imageKey: ref(1),
};
}
function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): PageState {
function pageState({ slugRef, pageModeRef, editModeRef, isParsingRef, imageKey }: PageRefs): PageState {
const { activateNavigationWarning, deactivateNavigationWarning } = useNavigationWarning();
const toggleEditMode = () => {
@@ -83,6 +89,14 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
pageModeRef.value = PageMode.COOK;
};
const toggleIsParsing = (v: boolean | null = null) => {
if (v === null) {
v = !isParsingRef.value;
}
isParsingRef.value = v;
};
const setEditMode = (v: EditorMode) => {
editModeRef.value = v;
};
@@ -113,6 +127,7 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
setMode,
setEditMode,
toggleCookMode,
toggleIsParsing,
isEditForm: computed(() => {
return pageModeRef.value === PageMode.EDIT && editModeRef.value === EditorMode.FORM;
@@ -126,6 +141,9 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
isCookMode: computed(() => {
return pageModeRef.value === PageMode.COOK;
}),
isParsing: computed(() => {
return isParsingRef.value;
}),
};
}

View File

@@ -0,0 +1,85 @@
import { useRecipeCreatePreferences } from "~/composables/use-users/preferences";
export interface UseNewRecipeOptionsProps {
enableImportKeywords?: boolean;
enableStayInEditMode?: boolean;
enableParseRecipe?: boolean;
}
export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
const {
enableImportKeywords = true,
enableStayInEditMode = true,
enableParseRecipe = true,
} = props;
const router = useRouter();
const recipeCreatePreferences = useRecipeCreatePreferences();
const importKeywordsAsTags = computed({
get() {
if (!enableImportKeywords) return false;
return recipeCreatePreferences.value.importKeywordsAsTags;
},
set(v: boolean) {
if (!enableImportKeywords) return;
recipeCreatePreferences.value.importKeywordsAsTags = v;
},
});
const stayInEditMode = computed({
get() {
if (!enableStayInEditMode) return false;
return recipeCreatePreferences.value.stayInEditMode;
},
set(v: boolean) {
if (!enableStayInEditMode) return;
recipeCreatePreferences.value.stayInEditMode = v;
},
});
const parseRecipe = computed({
get() {
if (!enableParseRecipe) return false;
return recipeCreatePreferences.value.parseRecipe;
},
set(v: boolean) {
if (!enableParseRecipe) return;
recipeCreatePreferences.value.parseRecipe = v;
},
});
function navigateToRecipe(recipeSlug: string, groupSlug: string, createPagePath: string) {
const editParam = enableStayInEditMode ? stayInEditMode.value : false;
const parseParam = enableParseRecipe ? parseRecipe.value : false;
const queryParams = new URLSearchParams();
if (editParam) {
queryParams.set("edit", "true");
}
if (parseParam) {
queryParams.set("parse", "true");
}
const queryString = queryParams.toString();
const recipeUrl = `/g/${groupSlug}/r/${recipeSlug}${queryString ? `?${queryString}` : ""}`;
// Replace current entry to prevent re-import on back navigation
router.replace(createPagePath).then(() => router.push(recipeUrl));
}
return {
// Computed properties for the checkboxes
importKeywordsAsTags,
stayInEditMode,
parseRecipe,
// Helper functions
navigateToRecipe,
// Props for conditional rendering
enableImportKeywords,
enableStayInEditMode,
enableParseRecipe,
};
}

View File

@@ -59,6 +59,12 @@ export interface UserRecipeFinderPreferences {
includeToolsOnHand: boolean;
}
export interface UserRecipeCreatePreferences {
importKeywordsAsTags: boolean;
stayInEditMode: boolean;
parseRecipe: boolean;
}
export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> {
const fromStorage = useLocalStorage(
"meal-planner-preferences",
@@ -200,3 +206,19 @@ export function useRecipeFinderPreferences(): Ref<UserRecipeFinderPreferences> {
return fromStorage;
}
export function useRecipeCreatePreferences(): Ref<UserRecipeCreatePreferences> {
const fromStorage = useLocalStorage(
"recipe-create-preferences",
{
importKeywordsAsTags: false,
stayInEditMode: false,
parseRecipe: true,
},
{ mergeDefaults: true },
// we cast to a Ref because by default it will return an optional type ref
// but since we pass defaults we know all properties are set.
) as unknown as Ref<UserRecipeCreatePreferences>;
return fromStorage;
}

View File

@@ -624,6 +624,7 @@
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
"import-original-keywords-as-tags": "Import original keywords as tags",
"stay-in-edit-mode": "Stay in Edit mode",
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
"import-from-zip": "Import from Zip",
"import-from-zip-description": "Import a single recipe that was exported from another Mealie instance.",
"import-from-html-or-json": "Import from HTML or JSON",
@@ -669,7 +670,13 @@
"missing-food": "Create missing food: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "No Food"
"no-food": "No Food",
"parsing-completed": "Parsing Completed",
"review-parsed-ingredients": "Review parsed ingredients",
"confidence-score": "Confidence Score",
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}"
},
"reset-servings-count": "Reset Servings Count",
"not-linked-ingredients": "Additional Ingredients",

View File

@@ -153,6 +153,7 @@ import {
mdiBellPlus,
mdiLinkVariantPlus,
mdiTableEdit,
mdiFileSign,
} from "@mdi/js";
export const icons = {
@@ -285,6 +286,7 @@ export const icons = {
undo: mdiUndo,
knfife: mdiKnife,
bread: mdiCookie,
fileSign: mdiFileSign,
// Crud
backArrow: mdiArrowLeftBoldOutline,

View File

@@ -1,445 +0,0 @@
<template>
<v-container v-if="recipe">
<v-container>
<BaseCardSectionTitle :title="$t('recipe.parser.ingredient-parser')">
<div class="mt-4">
{{ $t("recipe.parser.explanation") }}
</div>
<div class="my-4">
{{ $t("recipe.parser.alerts-explainer") }}
</div>
<div class="d-flex align-center mb-n4">
<div class="mb-4">
{{ $t("recipe.parser.select-parser") }}
</div>
<BaseOverflowButton
v-model="parser"
btn-class="mx-2 mb-4"
:items="availableParsers"
/>
</div>
</BaseCardSectionTitle>
<div
class="d-flex mt-n3 mb-4 justify-end"
style="gap: 5px"
>
<BaseButton
cancel
class="mr-auto"
@click="$router.go(-1)"
/>
<BaseButton
color="info"
:disabled="parserLoading"
@click="fetchParsed"
>
<template #icon>
{{ $globals.icons.foods }}
</template>
{{ $t("recipe.parser.parse-all") }}
</BaseButton>
<BaseButton
save
:disabled="parserLoading"
@click="saveAll"
/>
</div>
<div v-if="parserLoading">
<AppLoader
v-if="parserLoading"
:loading="parserLoading"
waiting-text=""
/>
</div>
<div v-else>
<v-expansion-panels
v-model="panels"
multiple
>
<VueDraggable
v-if="parsedIng.length > 0"
v-model="parsedIng"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
:style="{ width: '100%' }"
ghost-class="ghost"
>
<v-expansion-panel
v-for="(ing, index) in parsedIng"
:key="index"
>
<v-expansion-panel-title
class="my-0 py-0"
disable-icon-rotate
>
<template #default="{ expanded }">
<v-fade-transition>
<span
v-if="!expanded"
key="0"
> {{ ing.input }} </span>
</v-fade-transition>
</template>
<template #actions>
<v-icon
start
:color="isError(ing) ? 'error' : 'success'"
>
{{ isError(ing) ? $globals.icons.alert : $globals.icons.check }}
</v-icon>
<div
class="my-auto"
:color="isError(ing) ? 'error-text' : 'success-text'"
>
{{ ing.confidence ? asPercentage(ing.confidence.average!) : "" }}
</div>
</template>
</v-expansion-panel-title>
<v-expansion-panel-text class="pb-0 mb-0">
<RecipeIngredientEditor
v-model="parsedIng[index].ingredient"
allow-insert-ingredient
:unit-error="errors[index].unitError && errors[index].unitErrorMessage !== ''"
:unit-error-tooltip="$t('recipe.parser.this-unit-could-not-be-parsed-automatically')"
:food-error="errors[index].foodError && errors[index].foodErrorMessage !== ''"
:food-error-tooltip="$t('recipe.parser.this-food-could-not-be-parsed-automatically')"
@insert-above="insertIngredient(index)"
@insert-below="insertIngredient(index + 1)"
@delete="deleteIngredient(index)"
/>
{{ ing.input }}
<v-card-actions>
<v-spacer />
<BaseButton
v-if="errors[index].unitError && errors[index].unitErrorMessage !== ''"
color="warning"
size="small"
@click="createUnit(errors[index].unitName, index)"
>
{{ errors[index].unitErrorMessage }}
</BaseButton>
<BaseButton
v-if="errors[index].foodError && errors[index].foodErrorMessage !== ''"
color="warning"
size="small"
@click="createFood(errors[index].foodName, index)"
>
{{ errors[index].foodErrorMessage }}
</BaseButton>
</v-card-actions>
</v-expansion-panel-text>
</v-expansion-panel>
</VueDraggable>
</v-expansion-panels>
</div>
</v-container>
</v-container>
</template>
<script lang="ts">
import { invoke, until } from "@vueuse/core";
import { VueDraggable } from "vue-draggable-plus";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import { alert } from "~/composables/use-toast";
import { useAppInfo, useUserApi } from "~/composables/api";
import { useRecipe } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useParsingPreferences } from "~/composables/use-users/preferences";
import { uuid4 } from "~/composables/use-utils";
import type {
CreateIngredientFood,
CreateIngredientUnit,
IngredientFood,
IngredientUnit,
ParsedIngredient,
RecipeIngredient,
} from "~/lib/api/types/recipe";
import type { Parser } from "~/lib/api/user/recipes/recipe";
interface Error {
ingredientIndex: number;
unitName: string;
unitError: boolean;
unitErrorMessage: string;
foodName: string;
foodError: boolean;
foodErrorMessage: string;
}
export default defineNuxtComponent({
components: {
RecipeIngredientEditor,
VueDraggable,
},
middleware: ["sidebase-auth", "group-only"],
setup() {
const i18n = useI18n();
const $auth = useMealieAuth();
const panels = ref<number[]>([]);
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const router = useRouter();
const slug = route.params.slug as string;
const api = useUserApi();
const appInfo = useAppInfo();
const { recipe, loading } = useRecipe(slug);
const parserLoading = ref(false);
invoke(async () => {
await until(recipe).not.toBeNull();
fetchParsed();
});
const ingredients = ref<any[]>([]);
const availableParsers = computed(() => {
return [
{
text: i18n.t("recipe.parser.natural-language-processor"),
value: "nlp",
},
{
text: i18n.t("recipe.parser.brute-parser"),
value: "brute",
},
{
text: i18n.t("recipe.parser.openai-parser"),
value: "openai",
hide: !appInfo.value?.enableOpenai,
},
];
});
// =========================================================
// Parser Logic
const parserPreferences = useParsingPreferences();
const parser = ref<Parser>(parserPreferences.value.parser || "nlp");
const parsedIng = ref<ParsedIngredient[]>([]);
watch(parser, (val) => {
parserPreferences.value.parser = val;
});
function processIngredientError(ing: ParsedIngredient, index: number): Error {
const unitError = !checkForUnit(ing.ingredient.unit!);
const foodError = !checkForFood(ing.ingredient.food!);
const unit = ing.ingredient.unit?.name || i18n.t("recipe.parser.no-unit");
const food = ing.ingredient.food?.name || i18n.t("recipe.parser.no-food");
let unitErrorMessage = "";
let foodErrorMessage = "";
if (unitError) {
if (ing?.ingredient?.unit?.name) {
ing.ingredient.unit = undefined;
unitErrorMessage = i18n.t("recipe.parser.missing-unit", { unit }).toString();
}
}
if (foodError) {
if (ing?.ingredient?.food?.name) {
ing.ingredient.food = undefined;
foodErrorMessage = i18n.t("recipe.parser.missing-food", { food }).toString();
}
}
panels.value.push(index);
return {
ingredientIndex: index,
unitName: unit,
unitError,
unitErrorMessage,
foodName: food,
foodError,
foodErrorMessage,
} as Error;
}
async function fetchParsed() {
if (!recipe.value || !recipe.value.recipeIngredient) {
return;
}
const raw = recipe.value.recipeIngredient.map(ing => ing.note ?? "");
parserLoading.value = true;
const { data } = await api.recipes.parseIngredients(parser.value, raw);
parserLoading.value = false;
if (data) {
// When we send the recipe ingredient text to be parsed, we lose the reference to the original unparsed ingredient.
// Generally this is fine, but if the unparsed ingredient had a title, we lose it; we add back the title for each ingredient here.
try {
for (let i = 0; i < recipe.value.recipeIngredient.length; i++) {
data[i].ingredient.title = recipe.value.recipeIngredient[i].title;
}
}
catch {
console.error("Index Mismatch Error during recipe ingredient parsing; did the number of ingredients change?");
}
parsedIng.value = data;
errors.value = data.map((ing, index: number) => {
return processIngredientError(ing, index);
});
}
else {
alert.error(i18n.t("events.something-went-wrong") as string);
parsedIng.value = [];
}
}
function isError(ing: ParsedIngredient) {
if (!ing?.confidence?.average) {
return true;
}
return !(ing.confidence.average >= 0.75);
}
function asPercentage(num: number | undefined): string {
if (!num) {
return "0%";
}
return Math.round(num * 100).toFixed(2) + "%";
}
// =========================================================
// Food and Ingredient Logic
const foodStore = useFoodStore();
const foodData = useFoodData();
const unitStore = useUnitStore();
const unitData = useUnitData();
const errors = ref<Error[]>([]);
function checkForUnit(unit?: IngredientUnit | CreateIngredientUnit) {
return !!unit?.id;
}
function checkForFood(food?: IngredientFood | CreateIngredientFood) {
return !!food?.id;
}
async function createFood(foodName: string, index: number) {
if (!foodName) {
return;
}
foodData.data.name = foodName;
parsedIng.value[index].ingredient.food = await foodStore.actions.createOne(foodData.data) || undefined;
errors.value[index].foodError = false;
foodData.reset();
}
async function createUnit(unitName: string | undefined, index: number) {
if (!unitName) {
return;
}
unitData.data.name = unitName;
parsedIng.value[index].ingredient.unit = await unitStore.actions.createOne(unitData.data) || undefined;
errors.value[index].unitError = false;
unitData.reset();
}
function insertIngredient(index: number) {
if (!recipe.value?.recipeIngredient) {
return;
}
const ing = {
input: "",
confidence: {},
ingredient: {
quantity: 1.0,
referenceId: uuid4(),
},
} as ParsedIngredient;
parsedIng.value.splice(index, 0, ing);
recipe.value.recipeIngredient.splice(index, 0, ing.ingredient);
errors.value = parsedIng.value.map((ing, index: number) => {
return processIngredientError(ing, index);
});
}
function deleteIngredient(index: number) {
parsedIng.value.splice(index, 1);
recipe.value?.recipeIngredient?.splice(index, 1);
errors.value = parsedIng.value.map((ing, index: number) => {
return processIngredientError(ing, index);
});
}
// =========================================================
// Save All Logic
async function saveAll() {
const ingredients = parsedIng.value.map((ing) => {
if (!checkForFood(ing.ingredient.food!)) {
ing.ingredient.food = undefined;
}
if (!checkForUnit(ing.ingredient.unit!)) {
ing.ingredient.unit = undefined;
}
return {
...ing.ingredient,
originalText: ing.input,
} as RecipeIngredient;
});
if (!recipe.value || !recipe.value.slug) {
return;
}
recipe.value.recipeIngredient = ingredients;
const { response } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
if (response?.status === 200) {
router.push(`/g/${groupSlug.value}/r/${recipe.value.slug}`);
}
}
useSeoMeta({
title: i18n.t("recipe.parser.ingredient-parser"),
});
return {
parser,
availableParsers,
saveAll,
createFood,
createUnit,
deleteIngredient,
insertIngredient,
errors,
actions: foodStore.actions,
workingFoodData: foodData,
isError,
panels,
asPercentage,
fetchParsed,
parsedIng,
recipe,
loading,
parserLoading,
ingredients,
};
},
});
</script>

View File

@@ -1,7 +1,7 @@
<template>
<v-form
ref="domUrlForm"
@submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags, stayInEditMode)"
@submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags)"
>
<div>
<v-card-title class="headline">
@@ -48,14 +48,22 @@
/>
<v-checkbox
v-model="importKeywordsAsTags"
color="primary"
hide-details
:label="$t('recipe.import-original-keywords-as-tags')"
/>
<v-checkbox
v-model="stayInEditMode"
color="primary"
hide-details
:label="$t('recipe.stay-in-edit-mode')"
/>
<v-checkbox
v-model="parseRecipe"
color="primary"
hide-details
:label="$t('recipe.parse-recipe-ingredients-after-import')"
/>
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
@@ -76,6 +84,7 @@
import type { AxiosResponse } from "axios";
import { useTagStore } from "~/composables/store/use-tag-store";
import { useUserApi } from "~/composables/api";
import { useNewRecipeOptions } from "~/composables/use-new-recipe-options";
import { validators } from "~/composables/use-validators";
import type { VForm } from "~/types/auto-forms";
@@ -92,28 +101,16 @@ export default defineNuxtComponent({
const domUrlForm = ref<VForm | null>(null);
const api = useUserApi();
const router = useRouter();
const tags = useTagStore();
const importKeywordsAsTags = computed({
get() {
return route.query.use_keywords === "1";
},
set(v: boolean) {
router.replace({ query: { ...route.query, use_keywords: v ? "1" : "0" } });
},
});
const {
importKeywordsAsTags,
stayInEditMode,
parseRecipe,
navigateToRecipe,
} = useNewRecipeOptions();
const stayInEditMode = computed({
get() {
return route.query.edit === "1";
},
set(v: boolean) {
router.replace({ query: { ...route.query, edit: v ? "1" : "0" } });
},
});
function handleResponse(response: AxiosResponse<string> | null, edit = false, refreshTags = false) {
function handleResponse(response: AxiosResponse<string> | null, refreshTags = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
@@ -123,7 +120,7 @@ export default defineNuxtComponent({
tags.actions.refresh();
}
router.push(`/g/${groupSlug.value}/r/${response.data}?edit=${edit.toString()}`);
navigateToRecipe(response.data, groupSlug.value, `/g/${groupSlug.value}/r/create/html`);
}
const newRecipeData = ref<string | object | null>(null);
@@ -151,7 +148,7 @@ export default defineNuxtComponent({
}
handleIsEditJson();
async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean, stayInEditMode: boolean) {
async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean) {
if (!htmlOrJsonData || !domUrlForm.value?.validate()) {
return;
}
@@ -166,13 +163,14 @@ export default defineNuxtComponent({
state.loading = true;
const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags);
handleResponse(response, stayInEditMode, importKeywordsAsTags);
handleResponse(response, importKeywordsAsTags);
}
return {
domUrlForm,
importKeywordsAsTags,
stayInEditMode,
parseRecipe,
newRecipeData,
handleIsEditJson,
createFromHtmlOrJson,

View File

@@ -19,7 +19,7 @@
:multiple="true"
@uploaded="uploadImages"
/>
<div v-if="uploadedImages.length > 0" class="mt-3">
<div v-if="uploadedImages.length" class="mt-3">
<p class="my-2">
{{ $t("recipe.crop-and-rotate-the-image") }}
</p>
@@ -60,20 +60,28 @@
</v-row>
</div>
</v-container>
<v-checkbox
v-if="uploadedImages.length"
v-model="shouldTranslate"
color="primary"
hide-details
:label="$t('recipe.should-translate-description')"
:disabled="loading"
/>
<v-checkbox
v-if="uploadedImages.length"
v-model="parseRecipe"
color="primary"
hide-details
:label="$t('recipe.parse-recipe-ingredients-after-import')"
:disabled="loading"
/>
</v-card-text>
<v-card-actions v-if="uploadedImages.length">
<div class="w-100 d-flex flex-column align-center">
<p style="width: 250px">
<BaseButton rounded block type="submit" :loading="loading" />
</p>
<p>
<v-checkbox
v-model="shouldTranslate"
hide-details
:label="$t('recipe.should-translate-description')"
:disabled="loading"
/>
</p>
<p v-if="loading" class="mb-0">
{{
uploadedImages.length > 1
@@ -91,6 +99,7 @@
<script lang="ts">
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { useNewRecipeOptions } from "~/composables/use-new-recipe-options";
import type { VForm } from "~/types/auto-forms";
export default defineNuxtComponent({
@@ -102,7 +111,6 @@ export default defineNuxtComponent({
const i18n = useI18n();
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const groupSlug = computed(() => route.params.groupSlug || "");
const domUrlForm = ref<VForm | null>(null);
@@ -111,6 +119,8 @@ export default defineNuxtComponent({
const uploadedImagesPreviewUrls = ref<string[]>([]);
const shouldTranslate = ref(true);
const { parseRecipe, navigateToRecipe } = useNewRecipeOptions();
function uploadImages(files: File[]) {
uploadedImages.value = [...uploadedImages.value, ...files];
uploadedImageNames.value = [...uploadedImageNames.value, ...files.map(file => file.name)];
@@ -143,7 +153,7 @@ export default defineNuxtComponent({
state.loading = false;
}
else {
router.push(`/g/${groupSlug.value}/r/${data}`);
navigateToRecipe(data, groupSlug.value, `/g/${groupSlug.value}/r/create/image`);
}
}
@@ -184,6 +194,7 @@ export default defineNuxtComponent({
uploadedImages,
uploadedImagesPreviewUrls,
shouldTranslate,
parseRecipe,
uploadImages,
clearImage,
createRecipe,

View File

@@ -2,7 +2,7 @@
<div>
<v-form
ref="domUrlForm"
@submit.prevent="createByUrl(recipeUrl, importKeywordsAsTags, stayInEditMode)"
@submit.prevent="createByUrl(recipeUrl, importKeywordsAsTags)"
>
<div>
<v-card-title class="headline">
@@ -44,6 +44,12 @@
hide-details
:label="$t('recipe.stay-in-edit-mode')"
/>
<v-checkbox
v-model="parseRecipe"
color="primary"
hide-details
:label="$t('recipe.parse-recipe-ingredients-after-import')"
/>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton
@@ -111,6 +117,7 @@
import type { AxiosResponse } from "axios";
import { useUserApi } from "~/composables/api";
import { useTagStore } from "~/composables/store/use-tag-store";
import { useNewRecipeOptions } from "~/composables/use-new-recipe-options";
import { validators } from "~/composables/use-validators";
import type { VForm } from "~/types/auto-forms";
@@ -132,10 +139,17 @@ export default defineNuxtComponent({
const router = useRouter();
const tags = useTagStore();
const {
importKeywordsAsTags,
stayInEditMode,
parseRecipe,
navigateToRecipe,
} = useNewRecipeOptions();
const bulkImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/bulk`);
const htmlOrJsonImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/html`);
function handleResponse(response: AxiosResponse<string> | null, edit = false, refreshTags = false) {
function handleResponse(response: AxiosResponse<string> | null, refreshTags = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
@@ -145,10 +159,7 @@ export default defineNuxtComponent({
tags.actions.refresh();
}
// we clear the query params first so if the user hits back, they don't re-import the recipe
router.replace({ query: {} }).then(
() => router.push(`/g/${groupSlug.value}/r/${response.data}?edit=${edit.toString()}`),
);
navigateToRecipe(response.data, groupSlug.value, `/g/${groupSlug.value}/r/create/url`);
}
const recipeUrl = computed({
@@ -163,37 +174,35 @@ export default defineNuxtComponent({
},
});
const importKeywordsAsTags = computed({
get() {
return route.query.use_keywords === "1";
},
set(v: boolean) {
router.replace({ query: { ...route.query, use_keywords: v ? "1" : "0" } });
},
});
const stayInEditMode = computed({
get() {
return route.query.edit === "1";
},
set(v: boolean) {
router.replace({ query: { ...route.query, edit: v ? "1" : "0" } });
},
});
onMounted(() => {
if (!recipeUrl.value) {
return;
}
if (recipeUrl.value && recipeUrl.value.includes("https")) {
// Check if we have a query params for using keywords as tags or staying in edit mode.
// We don't use these in the app anymore, but older automations such as Bookmarklet might still use them,
// and they're easy enough to support.
const importKeywordsAsTagsParam = route.query.use_keywords;
if (importKeywordsAsTagsParam === "1") {
importKeywordsAsTags.value = true;
}
else if (importKeywordsAsTagsParam === "0") {
importKeywordsAsTags.value = false;
}
if (recipeUrl.value.includes("https")) {
createByUrl(recipeUrl.value, importKeywordsAsTags.value, stayInEditMode.value);
const stayInEditModeParam = route.query.edit;
if (stayInEditModeParam === "1") {
stayInEditMode.value = true;
}
else if (stayInEditModeParam === "0") {
stayInEditMode.value = false;
}
createByUrl(recipeUrl.value, importKeywordsAsTags.value);
return;
}
});
const domUrlForm = ref<VForm | null>(null);
async function createByUrl(url: string | null, importKeywordsAsTags: boolean, stayInEditMode: boolean) {
async function createByUrl(url: string | null, importKeywordsAsTags: boolean) {
if (url === null) {
return;
}
@@ -204,7 +213,7 @@ export default defineNuxtComponent({
}
state.loading = true;
const { response } = await api.recipes.createOneByUrl(url, importKeywordsAsTags);
handleResponse(response, stayInEditMode, importKeywordsAsTags);
handleResponse(response, importKeywordsAsTags);
}
return {
@@ -213,6 +222,7 @@ export default defineNuxtComponent({
recipeUrl,
importKeywordsAsTags,
stayInEditMode,
parseRecipe,
domUrlForm,
createByUrl,
...toRefs(state),