feat: Ingredient Parser Enhancements (#6228)

This commit is contained in:
Michael Genson
2025-09-23 17:03:35 -05:00
committed by GitHub
parent 4dfc32a314
commit 679a42a7cc
5 changed files with 178 additions and 130 deletions

View File

@@ -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;
});

View File

@@ -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);
}
});

View File

@@ -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)"

View File

@@ -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%";

View File

@@ -671,12 +671,12 @@
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "No Food",
"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}"
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
"delete-item": "Delete Item"
},
"reset-servings-count": "Reset Servings Count",
"not-linked-ingredients": "Additional Ingredients",