mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: Upgraded Ingredient Parsing Workflow (#6151)
This commit is contained in:
		| @@ -12,12 +12,10 @@ var url = document.URL.endsWith('/') ? | |||||||
|     document.URL; |     document.URL; | ||||||
| var mealie = "http://localhost:8080"; | 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 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) === "/") { | if (mealie.slice(-1) === "/") { | ||||||
|     mealie = mealie.slice(0, -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"); | window.open(dest, "_blank"); | ||||||
| ``` | ``` | ||||||
|   | |||||||
| @@ -165,12 +165,12 @@ | |||||||
|             @click="$emit('clickIngredientField', 'note')" |             @click="$emit('clickIngredientField', 'note')" | ||||||
|           /> |           /> | ||||||
|           <BaseButtonGroup |           <BaseButtonGroup | ||||||
|  |             v-if="enableContextMenu" | ||||||
|             hover |             hover | ||||||
|             :large="false" |             :large="false" | ||||||
|             class="my-auto d-flex" |             class="my-auto d-flex" | ||||||
|             :buttons="btns" |             :buttons="btns" | ||||||
|             @toggle-section="toggleTitle" |             @toggle-section="toggleTitle" | ||||||
|             @toggle-original="toggleOriginalText" |  | ||||||
|             @insert-above="$emit('insert-above')" |             @insert-above="$emit('insert-above')" | ||||||
|             @insert-below="$emit('insert-below')" |             @insert-below="$emit('insert-below')" | ||||||
|             @delete="$emit('delete')" |             @delete="$emit('delete')" | ||||||
| @@ -178,13 +178,6 @@ | |||||||
|         </div> |         </div> | ||||||
|       </v-col> |       </v-col> | ||||||
|     </v-row> |     </v-row> | ||||||
|     <p |  | ||||||
|       v-if="showOriginalText" |  | ||||||
|       class="text-caption" |  | ||||||
|     > |  | ||||||
|       {{ $t("recipe.original-text-with-value", { originalText: model.originalText }) }} |  | ||||||
|     </p> |  | ||||||
|  |  | ||||||
|     <v-divider |     <v-divider | ||||||
|       v-if="!mdAndUp" |       v-if="!mdAndUp" | ||||||
|       class="my-4" |       class="my-4" | ||||||
| @@ -220,6 +213,10 @@ defineProps({ | |||||||
|     type: String, |     type: String, | ||||||
|     default: "", |     default: "", | ||||||
|   }, |   }, | ||||||
|  |   enableContextMenu: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| defineEmits([ | defineEmits([ | ||||||
| @@ -235,7 +232,6 @@ const { $globals } = useNuxtApp(); | |||||||
|  |  | ||||||
| const state = reactive({ | const state = reactive({ | ||||||
|   showTitle: false, |   showTitle: false, | ||||||
|   showOriginalText: false, |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const contextMenuOptions = computed(() => { | 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; |   return options; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @@ -319,10 +308,6 @@ function toggleTitle() { | |||||||
|   state.showTitle = !state.showTitle; |   state.showTitle = !state.showTitle; | ||||||
| } | } | ||||||
|  |  | ||||||
| function toggleOriginalText() { |  | ||||||
|   state.showOriginalText = !state.showOriginalText; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function handleUnitEnter() { | function handleUnitEnter() { | ||||||
|   if ( |   if ( | ||||||
|     model.value.unit === undefined |     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 foods = foodStore.store; | ||||||
| const units = unitStore.store; | const units = unitStore.store; | ||||||
|   | |||||||
| @@ -1,5 +1,12 @@ | |||||||
| <template> | <template> | ||||||
|   <div> |   <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-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"> |       <v-card :flat="$vuetify.display.smAndDown" class="d-print-none"> | ||||||
|         <RecipePageHeader |         <RecipePageHeader | ||||||
| @@ -168,6 +175,7 @@ import RecipePageIngredientEditor from "./RecipePageParts/RecipePageIngredientEd | |||||||
| import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredientToolsView.vue"; | import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredientToolsView.vue"; | ||||||
| import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue"; | import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue"; | ||||||
| import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue"; | import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue"; | ||||||
|  | import RecipePageParseDialog from "./RecipePageParts/RecipePageParseDialog.vue"; | ||||||
| import RecipePageScale from "./RecipePageParts/RecipePageScale.vue"; | import RecipePageScale from "./RecipePageParts/RecipePageScale.vue"; | ||||||
| import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue"; | import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue"; | ||||||
| import RecipePageComments from "./RecipePageParts/RecipePageComments.vue"; | import RecipePageComments from "./RecipePageParts/RecipePageComments.vue"; | ||||||
| @@ -178,7 +186,7 @@ import { | |||||||
|   usePageState, |   usePageState, | ||||||
| } from "~/composables/recipe-page/shared-state"; | } from "~/composables/recipe-page/shared-state"; | ||||||
| import type { NoUndefinedField } from "~/lib/api/types/non-generated"; | 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 { useRouteQuery } from "~/composables/use-router"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import { uuid4, deepCopy } from "~/composables/use-utils"; | 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 router = useRouter(); | ||||||
| const api = useUserApi(); | const api = useUserApi(); | ||||||
| const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } | const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, isParsing, toggleCookMode, toggleIsParsing } | ||||||
|   = usePageState(recipe.value.slug); |   = usePageState(recipe.value.slug); | ||||||
| const { deactivateNavigationWarning } = useNavigationWarning(); | const { deactivateNavigationWarning } = useNavigationWarning(); | ||||||
| const notLinkedIngredients = computed(() => { | const notLinkedIngredients = computed(() => { | ||||||
| @@ -246,12 +254,29 @@ const hasLinkedIngredients = computed(() => { | |||||||
|  |  | ||||||
| type BooleanString = "true" | "false" | ""; | type BooleanString = "true" | "false" | ""; | ||||||
|  |  | ||||||
| const edit = useRouteQuery<BooleanString>("edit", ""); | const paramsEdit = useRouteQuery<BooleanString>("edit", ""); | ||||||
|  | const paramsParse = useRouteQuery<BooleanString>("parse", ""); | ||||||
|  |  | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
|   if (edit.value === "true") { |   if (paramsEdit.value === "true") { | ||||||
|     setMode(PageMode.EDIT); |     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() { | async function deleteRecipe() { | ||||||
|   const { data } = await api.recipes.deleteOne(recipe.value.slug); |   const { data } = await api.recipes.deleteOne(recipe.value.slug); | ||||||
|   if (data?.slug) { |   if (data?.slug) { | ||||||
| @@ -302,7 +333,7 @@ function addStep(steps: Array<string> | null = null) { | |||||||
|  |  | ||||||
|   if (steps) { |   if (steps) { | ||||||
|     const cleanedSteps = steps.map((step) => { |     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); |     recipe.value.recipeInstructions.push(...cleanedSteps); | ||||||
|   | |||||||
| @@ -31,6 +31,7 @@ | |||||||
|           v-for="(ingredient, index) in recipe.recipeIngredient" |           v-for="(ingredient, index) in recipe.recipeIngredient" | ||||||
|           :key="ingredient.referenceId" |           :key="ingredient.referenceId" | ||||||
|           v-model="recipe.recipeIngredient[index]" |           v-model="recipe.recipeIngredient[index]" | ||||||
|  |           enable-context-menu | ||||||
|           class="list-group-item" |           class="list-group-item" | ||||||
|           @delete="recipe.recipeIngredient.splice(index, 1)" |           @delete="recipe.recipeIngredient.splice(index, 1)" | ||||||
|           @insert-above="insertNewIngredient(index)" |           @insert-above="insertNewIngredient(index)" | ||||||
| @@ -55,8 +56,8 @@ | |||||||
|               class="mb-1" |               class="mb-1" | ||||||
|               :disabled="hasFoodOrUnit" |               :disabled="hasFoodOrUnit" | ||||||
|               color="accent" |               color="accent" | ||||||
|               :to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`" |  | ||||||
|               v-bind="props" |               v-bind="props" | ||||||
|  |               @click="toggleIsParsing(true)" | ||||||
|             > |             > | ||||||
|               <template #icon> |               <template #icon> | ||||||
|                 {{ $globals.icons.foods }} |                 {{ $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 type { Recipe } from "~/lib/api/types/recipe"; | ||||||
| import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; | import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; | ||||||
| import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue"; | import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue"; | ||||||
|  | import { usePageState } from "~/composables/recipe-page/shared-state"; | ||||||
| import { uuid4 } from "~/composables/use-utils"; | import { uuid4 } from "~/composables/use-utils"; | ||||||
|  |  | ||||||
| const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true }); | const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true }); | ||||||
| const i18n = useI18n(); | const i18n = useI18n(); | ||||||
| const $auth = useMealieAuth(); |  | ||||||
|  |  | ||||||
| const drag = ref(false); | const drag = ref(false); | ||||||
|  | const { toggleIsParsing } = usePageState(recipe.value.slug); | ||||||
| const route = useRoute(); |  | ||||||
| const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || ""); |  | ||||||
|  |  | ||||||
| const hasFoodOrUnit = computed(() => { | const hasFoodOrUnit = computed(() => { | ||||||
|   if (!recipe.value) { |   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> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| const { size } = withDefaults(defineProps<{ size?: number }>(), { size: 75 }); | withDefaults(defineProps<{ size?: number }>(), { size: 75 }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
|   | |||||||
| @@ -44,11 +44,16 @@ interface PageState { | |||||||
| 	 * true is the page is in cook mode. | 	 * true is the page is in cook mode. | ||||||
| 	 */ | 	 */ | ||||||
|   isCookMode: ComputedRef<boolean>; |   isCookMode: ComputedRef<boolean>; | ||||||
|  |   /** | ||||||
|  |    * true if the recipe is currently being parsed. | ||||||
|  |    */ | ||||||
|  |   isParsing: ComputedRef<boolean>; | ||||||
|  |  | ||||||
|   setMode: (v: PageMode) => void; |   setMode: (v: PageMode) => void; | ||||||
|   setEditMode: (v: EditorMode) => void; |   setEditMode: (v: EditorMode) => void; | ||||||
|   toggleEditMode: () => void; |   toggleEditMode: () => void; | ||||||
|   toggleCookMode: () => void; |   toggleCookMode: () => void; | ||||||
|  |   toggleIsParsing: (v?: boolean) => void; | ||||||
| } | } | ||||||
|  |  | ||||||
| type PageRefs = ReturnType<typeof pageRefs>; | type PageRefs = ReturnType<typeof pageRefs>; | ||||||
| @@ -60,11 +65,12 @@ function pageRefs(slug: string) { | |||||||
|     slugRef: ref(slug), |     slugRef: ref(slug), | ||||||
|     pageModeRef: ref(PageMode.VIEW), |     pageModeRef: ref(PageMode.VIEW), | ||||||
|     editModeRef: ref(EditorMode.FORM), |     editModeRef: ref(EditorMode.FORM), | ||||||
|  |     isParsingRef: ref(false), | ||||||
|     imageKey: ref(1), |     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 { activateNavigationWarning, deactivateNavigationWarning } = useNavigationWarning(); | ||||||
|  |  | ||||||
|   const toggleEditMode = () => { |   const toggleEditMode = () => { | ||||||
| @@ -83,6 +89,14 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P | |||||||
|     pageModeRef.value = PageMode.COOK; |     pageModeRef.value = PageMode.COOK; | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   const toggleIsParsing = (v: boolean | null = null) => { | ||||||
|  |     if (v === null) { | ||||||
|  |       v = !isParsingRef.value; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     isParsingRef.value = v; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   const setEditMode = (v: EditorMode) => { |   const setEditMode = (v: EditorMode) => { | ||||||
|     editModeRef.value = v; |     editModeRef.value = v; | ||||||
|   }; |   }; | ||||||
| @@ -113,6 +127,7 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P | |||||||
|     setMode, |     setMode, | ||||||
|     setEditMode, |     setEditMode, | ||||||
|     toggleCookMode, |     toggleCookMode, | ||||||
|  |     toggleIsParsing, | ||||||
|  |  | ||||||
|     isEditForm: computed(() => { |     isEditForm: computed(() => { | ||||||
|       return pageModeRef.value === PageMode.EDIT && editModeRef.value === EditorMode.FORM; |       return pageModeRef.value === PageMode.EDIT && editModeRef.value === EditorMode.FORM; | ||||||
| @@ -126,6 +141,9 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P | |||||||
|     isCookMode: computed(() => { |     isCookMode: computed(() => { | ||||||
|       return pageModeRef.value === PageMode.COOK; |       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; |   includeToolsOnHand: boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export interface UserRecipeCreatePreferences { | ||||||
|  |   importKeywordsAsTags: boolean; | ||||||
|  |   stayInEditMode: boolean; | ||||||
|  |   parseRecipe: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
| export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> { | export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> { | ||||||
|   const fromStorage = useLocalStorage( |   const fromStorage = useLocalStorage( | ||||||
|     "meal-planner-preferences", |     "meal-planner-preferences", | ||||||
| @@ -200,3 +206,19 @@ export function useRecipeFinderPreferences(): Ref<UserRecipeFinderPreferences> { | |||||||
|  |  | ||||||
|   return fromStorage; |   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", |     "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", |     "import-original-keywords-as-tags": "Import original keywords as tags", | ||||||
|     "stay-in-edit-mode": "Stay in Edit mode", |     "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": "Import from Zip", | ||||||
|     "import-from-zip-description": "Import a single recipe that was exported from another Mealie instance.", |     "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", |     "import-from-html-or-json": "Import from HTML or JSON", | ||||||
| @@ -669,7 +670,13 @@ | |||||||
|       "missing-food": "Create missing food: {food}", |       "missing-food": "Create missing food: {food}", | ||||||
|       "this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically", |       "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", |       "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", |     "reset-servings-count": "Reset Servings Count", | ||||||
|     "not-linked-ingredients": "Additional Ingredients", |     "not-linked-ingredients": "Additional Ingredients", | ||||||
|   | |||||||
| @@ -153,6 +153,7 @@ import { | |||||||
|   mdiBellPlus, |   mdiBellPlus, | ||||||
|   mdiLinkVariantPlus, |   mdiLinkVariantPlus, | ||||||
|   mdiTableEdit, |   mdiTableEdit, | ||||||
|  |   mdiFileSign, | ||||||
| } from "@mdi/js"; | } from "@mdi/js"; | ||||||
|  |  | ||||||
| export const icons = { | export const icons = { | ||||||
| @@ -285,6 +286,7 @@ export const icons = { | |||||||
|   undo: mdiUndo, |   undo: mdiUndo, | ||||||
|   knfife: mdiKnife, |   knfife: mdiKnife, | ||||||
|   bread: mdiCookie, |   bread: mdiCookie, | ||||||
|  |   fileSign: mdiFileSign, | ||||||
|  |  | ||||||
|   // Crud |   // Crud | ||||||
|   backArrow: mdiArrowLeftBoldOutline, |   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> | <template> | ||||||
|   <v-form |   <v-form | ||||||
|     ref="domUrlForm" |     ref="domUrlForm" | ||||||
|     @submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags, stayInEditMode)" |     @submit.prevent="createFromHtmlOrJson(newRecipeData, importKeywordsAsTags)" | ||||||
|   > |   > | ||||||
|     <div> |     <div> | ||||||
|       <v-card-title class="headline"> |       <v-card-title class="headline"> | ||||||
| @@ -48,14 +48,22 @@ | |||||||
|         /> |         /> | ||||||
|         <v-checkbox |         <v-checkbox | ||||||
|           v-model="importKeywordsAsTags" |           v-model="importKeywordsAsTags" | ||||||
|  |           color="primary" | ||||||
|           hide-details |           hide-details | ||||||
|           :label="$t('recipe.import-original-keywords-as-tags')" |           :label="$t('recipe.import-original-keywords-as-tags')" | ||||||
|         /> |         /> | ||||||
|         <v-checkbox |         <v-checkbox | ||||||
|           v-model="stayInEditMode" |           v-model="stayInEditMode" | ||||||
|  |           color="primary" | ||||||
|           hide-details |           hide-details | ||||||
|           :label="$t('recipe.stay-in-edit-mode')" |           :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-text> | ||||||
|       <v-card-actions class="justify-center"> |       <v-card-actions class="justify-center"> | ||||||
|         <div style="width: 250px"> |         <div style="width: 250px"> | ||||||
| @@ -76,6 +84,7 @@ | |||||||
| import type { AxiosResponse } from "axios"; | import type { AxiosResponse } from "axios"; | ||||||
| import { useTagStore } from "~/composables/store/use-tag-store"; | import { useTagStore } from "~/composables/store/use-tag-store"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
|  | import { useNewRecipeOptions } from "~/composables/use-new-recipe-options"; | ||||||
| import { validators } from "~/composables/use-validators"; | import { validators } from "~/composables/use-validators"; | ||||||
| import type { VForm } from "~/types/auto-forms"; | import type { VForm } from "~/types/auto-forms"; | ||||||
|  |  | ||||||
| @@ -92,28 +101,16 @@ export default defineNuxtComponent({ | |||||||
|     const domUrlForm = ref<VForm | null>(null); |     const domUrlForm = ref<VForm | null>(null); | ||||||
|  |  | ||||||
|     const api = useUserApi(); |     const api = useUserApi(); | ||||||
|     const router = useRouter(); |  | ||||||
|     const tags = useTagStore(); |     const tags = useTagStore(); | ||||||
|  |  | ||||||
|     const importKeywordsAsTags = computed({ |     const { | ||||||
|       get() { |       importKeywordsAsTags, | ||||||
|         return route.query.use_keywords === "1"; |       stayInEditMode, | ||||||
|       }, |       parseRecipe, | ||||||
|       set(v: boolean) { |       navigateToRecipe, | ||||||
|         router.replace({ query: { ...route.query, use_keywords: v ? "1" : "0" } }); |     } = useNewRecipeOptions(); | ||||||
|       }, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     const stayInEditMode = computed({ |     function handleResponse(response: AxiosResponse<string> | null, refreshTags = false) { | ||||||
|       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) { |  | ||||||
|       if (response?.status !== 201) { |       if (response?.status !== 201) { | ||||||
|         state.error = true; |         state.error = true; | ||||||
|         state.loading = false; |         state.loading = false; | ||||||
| @@ -123,7 +120,7 @@ export default defineNuxtComponent({ | |||||||
|         tags.actions.refresh(); |         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); |     const newRecipeData = ref<string | object | null>(null); | ||||||
| @@ -151,7 +148,7 @@ export default defineNuxtComponent({ | |||||||
|     } |     } | ||||||
|     handleIsEditJson(); |     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()) { |       if (!htmlOrJsonData || !domUrlForm.value?.validate()) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
| @@ -166,13 +163,14 @@ export default defineNuxtComponent({ | |||||||
|  |  | ||||||
|       state.loading = true; |       state.loading = true; | ||||||
|       const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags); |       const { response } = await api.recipes.createOneByHtmlOrJson(dataString, importKeywordsAsTags); | ||||||
|       handleResponse(response, stayInEditMode, importKeywordsAsTags); |       handleResponse(response, importKeywordsAsTags); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
|       domUrlForm, |       domUrlForm, | ||||||
|       importKeywordsAsTags, |       importKeywordsAsTags, | ||||||
|       stayInEditMode, |       stayInEditMode, | ||||||
|  |       parseRecipe, | ||||||
|       newRecipeData, |       newRecipeData, | ||||||
|       handleIsEditJson, |       handleIsEditJson, | ||||||
|       createFromHtmlOrJson, |       createFromHtmlOrJson, | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ | |||||||
|               :multiple="true" |               :multiple="true" | ||||||
|               @uploaded="uploadImages" |               @uploaded="uploadImages" | ||||||
|             /> |             /> | ||||||
|             <div v-if="uploadedImages.length > 0" class="mt-3"> |             <div v-if="uploadedImages.length" class="mt-3"> | ||||||
|               <p class="my-2"> |               <p class="my-2"> | ||||||
|                 {{ $t("recipe.crop-and-rotate-the-image") }} |                 {{ $t("recipe.crop-and-rotate-the-image") }} | ||||||
|               </p> |               </p> | ||||||
| @@ -60,20 +60,28 @@ | |||||||
|               </v-row> |               </v-row> | ||||||
|             </div> |             </div> | ||||||
|           </v-container> |           </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-text> | ||||||
|         <v-card-actions v-if="uploadedImages.length"> |         <v-card-actions v-if="uploadedImages.length"> | ||||||
|           <div class="w-100 d-flex flex-column align-center"> |           <div class="w-100 d-flex flex-column align-center"> | ||||||
|             <p style="width: 250px"> |             <p style="width: 250px"> | ||||||
|               <BaseButton rounded block type="submit" :loading="loading" /> |               <BaseButton rounded block type="submit" :loading="loading" /> | ||||||
|             </p> |             </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"> |             <p v-if="loading" class="mb-0"> | ||||||
|               {{ |               {{ | ||||||
|                 uploadedImages.length > 1 |                 uploadedImages.length > 1 | ||||||
| @@ -91,6 +99,7 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import { alert } from "~/composables/use-toast"; | import { alert } from "~/composables/use-toast"; | ||||||
|  | import { useNewRecipeOptions } from "~/composables/use-new-recipe-options"; | ||||||
| import type { VForm } from "~/types/auto-forms"; | import type { VForm } from "~/types/auto-forms"; | ||||||
|  |  | ||||||
| export default defineNuxtComponent({ | export default defineNuxtComponent({ | ||||||
| @@ -102,7 +111,6 @@ export default defineNuxtComponent({ | |||||||
|     const i18n = useI18n(); |     const i18n = useI18n(); | ||||||
|     const api = useUserApi(); |     const api = useUserApi(); | ||||||
|     const route = useRoute(); |     const route = useRoute(); | ||||||
|     const router = useRouter(); |  | ||||||
|     const groupSlug = computed(() => route.params.groupSlug || ""); |     const groupSlug = computed(() => route.params.groupSlug || ""); | ||||||
|  |  | ||||||
|     const domUrlForm = ref<VForm | null>(null); |     const domUrlForm = ref<VForm | null>(null); | ||||||
| @@ -111,6 +119,8 @@ export default defineNuxtComponent({ | |||||||
|     const uploadedImagesPreviewUrls = ref<string[]>([]); |     const uploadedImagesPreviewUrls = ref<string[]>([]); | ||||||
|     const shouldTranslate = ref(true); |     const shouldTranslate = ref(true); | ||||||
|  |  | ||||||
|  |     const { parseRecipe, navigateToRecipe } = useNewRecipeOptions(); | ||||||
|  |  | ||||||
|     function uploadImages(files: File[]) { |     function uploadImages(files: File[]) { | ||||||
|       uploadedImages.value = [...uploadedImages.value, ...files]; |       uploadedImages.value = [...uploadedImages.value, ...files]; | ||||||
|       uploadedImageNames.value = [...uploadedImageNames.value, ...files.map(file => file.name)]; |       uploadedImageNames.value = [...uploadedImageNames.value, ...files.map(file => file.name)]; | ||||||
| @@ -143,7 +153,7 @@ export default defineNuxtComponent({ | |||||||
|         state.loading = false; |         state.loading = false; | ||||||
|       } |       } | ||||||
|       else { |       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, |       uploadedImages, | ||||||
|       uploadedImagesPreviewUrls, |       uploadedImagesPreviewUrls, | ||||||
|       shouldTranslate, |       shouldTranslate, | ||||||
|  |       parseRecipe, | ||||||
|       uploadImages, |       uploadImages, | ||||||
|       clearImage, |       clearImage, | ||||||
|       createRecipe, |       createRecipe, | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ | |||||||
|   <div> |   <div> | ||||||
|     <v-form |     <v-form | ||||||
|       ref="domUrlForm" |       ref="domUrlForm" | ||||||
|       @submit.prevent="createByUrl(recipeUrl, importKeywordsAsTags, stayInEditMode)" |       @submit.prevent="createByUrl(recipeUrl, importKeywordsAsTags)" | ||||||
|     > |     > | ||||||
|       <div> |       <div> | ||||||
|         <v-card-title class="headline"> |         <v-card-title class="headline"> | ||||||
| @@ -44,6 +44,12 @@ | |||||||
|           hide-details |           hide-details | ||||||
|           :label="$t('recipe.stay-in-edit-mode')" |           :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"> |         <v-card-actions class="justify-center"> | ||||||
|           <div style="width: 250px"> |           <div style="width: 250px"> | ||||||
|             <BaseButton |             <BaseButton | ||||||
| @@ -111,6 +117,7 @@ | |||||||
| import type { AxiosResponse } from "axios"; | import type { AxiosResponse } from "axios"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import { useTagStore } from "~/composables/store/use-tag-store"; | import { useTagStore } from "~/composables/store/use-tag-store"; | ||||||
|  | import { useNewRecipeOptions } from "~/composables/use-new-recipe-options"; | ||||||
| import { validators } from "~/composables/use-validators"; | import { validators } from "~/composables/use-validators"; | ||||||
| import type { VForm } from "~/types/auto-forms"; | import type { VForm } from "~/types/auto-forms"; | ||||||
|  |  | ||||||
| @@ -132,10 +139,17 @@ export default defineNuxtComponent({ | |||||||
|     const router = useRouter(); |     const router = useRouter(); | ||||||
|     const tags = useTagStore(); |     const tags = useTagStore(); | ||||||
|  |  | ||||||
|  |     const { | ||||||
|  |       importKeywordsAsTags, | ||||||
|  |       stayInEditMode, | ||||||
|  |       parseRecipe, | ||||||
|  |       navigateToRecipe, | ||||||
|  |     } = useNewRecipeOptions(); | ||||||
|  |  | ||||||
|     const bulkImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/bulk`); |     const bulkImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/bulk`); | ||||||
|     const htmlOrJsonImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/html`); |     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) { |       if (response?.status !== 201) { | ||||||
|         state.error = true; |         state.error = true; | ||||||
|         state.loading = false; |         state.loading = false; | ||||||
| @@ -145,10 +159,7 @@ export default defineNuxtComponent({ | |||||||
|         tags.actions.refresh(); |         tags.actions.refresh(); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       // we clear the query params first so if the user hits back, they don't re-import the recipe |       navigateToRecipe(response.data, groupSlug.value, `/g/${groupSlug.value}/r/create/url`); | ||||||
|       router.replace({ query: {} }).then( |  | ||||||
|         () => router.push(`/g/${groupSlug.value}/r/${response.data}?edit=${edit.toString()}`), |  | ||||||
|       ); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const recipeUrl = computed({ |     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(() => { |     onMounted(() => { | ||||||
|       if (!recipeUrl.value) { |       if (recipeUrl.value && recipeUrl.value.includes("https")) { | ||||||
|         return; |         // 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")) { |         const stayInEditModeParam = route.query.edit; | ||||||
|         createByUrl(recipeUrl.value, importKeywordsAsTags.value, stayInEditMode.value); |         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); |     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) { |       if (url === null) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
| @@ -204,7 +213,7 @@ export default defineNuxtComponent({ | |||||||
|       } |       } | ||||||
|       state.loading = true; |       state.loading = true; | ||||||
|       const { response } = await api.recipes.createOneByUrl(url, importKeywordsAsTags); |       const { response } = await api.recipes.createOneByUrl(url, importKeywordsAsTags); | ||||||
|       handleResponse(response, stayInEditMode, importKeywordsAsTags); |       handleResponse(response, importKeywordsAsTags); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
| @@ -213,6 +222,7 @@ export default defineNuxtComponent({ | |||||||
|       recipeUrl, |       recipeUrl, | ||||||
|       importKeywordsAsTags, |       importKeywordsAsTags, | ||||||
|       stayInEditMode, |       stayInEditMode, | ||||||
|  |       parseRecipe, | ||||||
|       domUrlForm, |       domUrlForm, | ||||||
|       createByUrl, |       createByUrl, | ||||||
|       ...toRefs(state), |       ...toRefs(state), | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user