mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	feat: Upgraded Ingredient Parsing Workflow (#6151)
This commit is contained in:
		| @@ -12,12 +12,10 @@ var url = document.URL.endsWith('/') ? | ||||
|     document.URL; | ||||
| var mealie = "http://localhost:8080"; | ||||
| var group_slug = "home" // Change this to your group slug. You can obtain this from your URL after logging-in to Mealie | ||||
| var use_keywords= "&use_keywords=1" // Optional - use keywords from recipe - update to "" if you don't want that | ||||
| var edity = "&edit=1" // Optional - keep in edit mode - update to "" if you don't want that | ||||
|  | ||||
| if (mealie.slice(-1) === "/") { | ||||
|     mealie = mealie.slice(0, -1) | ||||
| } | ||||
| var dest = mealie + "/g/" + group_slug + "/r/create/url?recipe_import_url=" + url + use_keywords + edity; | ||||
| var dest = mealie + "/g/" + group_slug + "/r/create/url?recipe_import_url=" + url; | ||||
| window.open(dest, "_blank"); | ||||
| ``` | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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; | ||||
|     }), | ||||
|   }; | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										85
									
								
								frontend/composables/use-new-recipe-options.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								frontend/composables/use-new-recipe-options.ts
									
									
									
									
									
										Normal 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, | ||||
|   }; | ||||
| } | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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), | ||||
|   | ||||
		Reference in New Issue
	
	Block a user