mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-10-27 08:14:30 -04:00
feat: Ingredient Parser Enhancements (#6228)
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
:placeholder="$t('recipe.quantity')"
|
||||
@keypress="quantityFilter"
|
||||
>
|
||||
<template #prepend>
|
||||
<template v-if="enableDragHandle" #prepend>
|
||||
<v-icon
|
||||
class="mr-n1 handle"
|
||||
>
|
||||
@@ -178,6 +178,7 @@
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<slot name="before-divider" />
|
||||
<v-divider
|
||||
v-if="!mdAndUp"
|
||||
class="my-4"
|
||||
@@ -196,7 +197,7 @@ import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
// defineModel replaces modelValue prop
|
||||
const model = defineModel<RecipeIngredient>({ required: true });
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
unitError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -217,6 +218,14 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableDragHandle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
deleteDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
@@ -270,8 +279,8 @@ const btns = computed(() => {
|
||||
text: i18n.t("general.delete"),
|
||||
event: "delete",
|
||||
children: undefined,
|
||||
disabled: props.deleteDisabled,
|
||||
});
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ import { useUserApi } from "~/composables/api";
|
||||
import { uuid4, deepCopy } from "~/composables/use-utils";
|
||||
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
@@ -200,6 +201,7 @@ const display = useDisplay();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
@@ -258,11 +260,11 @@ const paramsEdit = useRouteQuery<BooleanString>("edit", "");
|
||||
const paramsParse = useRouteQuery<BooleanString>("parse", "");
|
||||
|
||||
onMounted(() => {
|
||||
if (paramsEdit.value === "true") {
|
||||
if (paramsEdit.value === "true" && isOwnGroup.value) {
|
||||
setMode(PageMode.EDIT);
|
||||
}
|
||||
|
||||
if (paramsParse.value === "true") {
|
||||
if (paramsParse.value === "true" && isOwnGroup.value) {
|
||||
toggleIsParsing(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||
:key="ingredient.referenceId"
|
||||
v-model="recipe.recipeIngredient[index]"
|
||||
enable-drag-handle
|
||||
enable-context-menu
|
||||
class="list-group-item"
|
||||
@delete="recipe.recipeIngredient.splice(index, 1)"
|
||||
|
||||
@@ -6,106 +6,107 @@
|
||||
@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 v-if="state.loading.parser" class="my-6">
|
||||
<AppLoader waiting-text="" class="my-6" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<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 align-center">
|
||||
<BaseOverflowButton
|
||||
v-model="parser"
|
||||
:disabled="state.loading.parser"
|
||||
btn-class="mx-2"
|
||||
:items="availableParsers"
|
||||
<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>
|
||||
<v-card v-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-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>
|
||||
<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>
|
||||
<div v-else>
|
||||
<v-card-title class="text-center pt-0 pb-8">
|
||||
{{ $t("recipe.parser.review-parsed-ingredients") }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
</v-card-title>
|
||||
<v-card-text style="max-height: 60vh; overflow-y: auto;">
|
||||
<VueDraggable
|
||||
v-model="parsedIngs"
|
||||
handle=".handle"
|
||||
@@ -117,48 +118,66 @@
|
||||
disabled: false,
|
||||
ghostClass: 'ghost',
|
||||
}"
|
||||
class="px-6"
|
||||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
>
|
||||
<TransitionGroup
|
||||
type="transition"
|
||||
>
|
||||
<div v-for="(ingredient, index) in parsedIngs" :key="index">
|
||||
<v-lazy v-for="(ingredient, index) in parsedIngs" :key="index">
|
||||
<RecipeIngredientEditor
|
||||
v-model="ingredient.ingredient"
|
||||
enable-drag-handle
|
||||
enable-context-menu
|
||||
class="list-group-item"
|
||||
class="list-group-item pb-8"
|
||||
:delete-disabled="parsedIngs.length <= 1"
|
||||
@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>
|
||||
>
|
||||
<template #before-divider>
|
||||
<p v-if="ingredient.input" class="py-0 my-0 text-caption">
|
||||
{{ $t("recipe.original-text-with-value", { originalText: ingredient.input }) }}
|
||||
</p>
|
||||
</template>
|
||||
</RecipeIngredientEditor>
|
||||
</v-lazy>
|
||||
</TransitionGroup>
|
||||
</VueDraggable>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
/>
|
||||
<!-- Parse -->
|
||||
<div v-if="!state.allReviewed" class="d-flex justify-space-between align-center">
|
||||
<v-checkbox
|
||||
v-model="currentIngShouldDelete"
|
||||
color="error"
|
||||
hide-details
|
||||
density="compact"
|
||||
:label="i18n.t('recipe.parser.delete-item')"
|
||||
class="mr-4"
|
||||
/>
|
||||
<BaseButton
|
||||
:color="currentIngShouldDelete ? 'error' : 'info'"
|
||||
:icon="currentIngShouldDelete ? $globals.icons.delete : $globals.icons.arrowRightBold"
|
||||
:icon-right="!currentIngShouldDelete"
|
||||
:text="$t(currentIngShouldDelete ? 'recipe.parser.delete-item' : 'general.next')"
|
||||
@click="nextIngredient"
|
||||
/>
|
||||
</div>
|
||||
<!-- Review -->
|
||||
<div v-else>
|
||||
<BaseButton
|
||||
create
|
||||
:text="$t('general.save')"
|
||||
:icon="$globals.icons.save"
|
||||
:loading="state.loading.save"
|
||||
@click="saveIngs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
@@ -226,6 +245,7 @@ const currentIng = ref<ParsedIngredient | null>(null);
|
||||
const currentMissingUnit = ref("");
|
||||
const currentMissingFood = ref("");
|
||||
const currentIngHasError = computed(() => currentMissingUnit.value || currentMissingFood.value);
|
||||
const currentIngShouldDelete = ref(false);
|
||||
|
||||
const state = reactive({
|
||||
currentParsedIndex: -1,
|
||||
@@ -297,6 +317,11 @@ function checkFood(ing: ParsedIngredient) {
|
||||
}
|
||||
|
||||
function nextIngredient() {
|
||||
if (currentIngShouldDelete.value) {
|
||||
parsedIngs.value.splice(state.currentParsedIndex, 1);
|
||||
currentIngShouldDelete.value = false;
|
||||
}
|
||||
|
||||
let nextIndex = state.currentParsedIndex + 1;
|
||||
|
||||
while (nextIndex < parsedIngs.value.length) {
|
||||
@@ -304,6 +329,7 @@ function nextIngredient() {
|
||||
if (shouldReview(current)) {
|
||||
state.currentParsedIndex = nextIndex;
|
||||
currentIng.value = current;
|
||||
currentIngShouldDelete.value = false;
|
||||
checkUnit(current);
|
||||
checkFood(current);
|
||||
return;
|
||||
@@ -462,6 +488,16 @@ watch(parser, () => {
|
||||
parseIngredients();
|
||||
});
|
||||
|
||||
watch([parsedIngs, () => state.allReviewed], () => {
|
||||
if (!state.allReviewed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsedIngs.value.length) {
|
||||
insertNewIngredient(0);
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
function asPercentage(num: number | undefined): string {
|
||||
if (!num) {
|
||||
return "0%";
|
||||
|
||||
Reference in New Issue
Block a user