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')" :placeholder="$t('recipe.quantity')"
@keypress="quantityFilter" @keypress="quantityFilter"
> >
<template #prepend> <template v-if="enableDragHandle" #prepend>
<v-icon <v-icon
class="mr-n1 handle" class="mr-n1 handle"
> >
@@ -178,6 +178,7 @@
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
<slot name="before-divider" />
<v-divider <v-divider
v-if="!mdAndUp" v-if="!mdAndUp"
class="my-4" class="my-4"
@@ -196,7 +197,7 @@ import type { RecipeIngredient } from "~/lib/api/types/recipe";
// defineModel replaces modelValue prop // defineModel replaces modelValue prop
const model = defineModel<RecipeIngredient>({ required: true }); const model = defineModel<RecipeIngredient>({ required: true });
defineProps({ const props = defineProps({
unitError: { unitError: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -217,6 +218,14 @@ defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
enableDragHandle: {
type: Boolean,
default: false,
},
deleteDisabled: {
type: Boolean,
default: false,
},
}); });
defineEmits([ defineEmits([
@@ -270,8 +279,8 @@ const btns = computed(() => {
text: i18n.t("general.delete"), text: i18n.t("general.delete"),
event: "delete", event: "delete",
children: undefined, children: undefined,
disabled: props.deleteDisabled,
}); });
return out; return out;
}); });

View File

@@ -192,6 +192,7 @@ import { useUserApi } from "~/composables/api";
import { uuid4, deepCopy } from "~/composables/use-utils"; import { uuid4, deepCopy } from "~/composables/use-utils";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue"; import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue"; import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useNavigationWarning } from "~/composables/use-navigation-warning"; import { useNavigationWarning } from "~/composables/use-navigation-warning";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true }); const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
@@ -200,6 +201,7 @@ const display = useDisplay();
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const route = useRoute(); const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || ""); 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", ""); const paramsParse = useRouteQuery<BooleanString>("parse", "");
onMounted(() => { onMounted(() => {
if (paramsEdit.value === "true") { if (paramsEdit.value === "true" && isOwnGroup.value) {
setMode(PageMode.EDIT); setMode(PageMode.EDIT);
} }
if (paramsParse.value === "true") { if (paramsParse.value === "true" && isOwnGroup.value) {
toggleIsParsing(true); toggleIsParsing(true);
} }
}); });

View File

@@ -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-drag-handle
enable-context-menu enable-context-menu
class="list-group-item" class="list-group-item"
@delete="recipe.recipeIngredient.splice(index, 1)" @delete="recipe.recipeIngredient.splice(index, 1)"

View File

@@ -6,6 +6,10 @@
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<v-container class="pa-2 ma-0" style="background-color: rgb(var(--v-theme-background));"> <v-container class="pa-2 ma-0" style="background-color: rgb(var(--v-theme-background));">
<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')"> <BaseCardSectionTitle :title="$t('recipe.parser.ingredient-parser')">
<div v-if="!state.allReviewed" class="mb-4"> <div v-if="!state.allReviewed" class="mb-4">
<p>{{ $t("recipe.parser.ingredient-parser-description") }}</p> <p>{{ $t("recipe.parser.ingredient-parser-description") }}</p>
@@ -34,8 +38,7 @@
</div> </div>
</div> </div>
</BaseCardSectionTitle> </BaseCardSectionTitle>
<AppLoader v-if="state.loading.parser" waiting-text="" class="my-6" /> <v-card v-if="!state.allReviewed && currentIng">
<v-card v-else-if="!state.allReviewed && currentIng">
<v-card-text class="pb-0 mb-0"> <v-card-text class="pb-0 mb-0">
<div class="text-center px-8 py-4 mb-6"> <div class="text-center px-8 py-4 mb-6">
<p class="text-h5 font-italic"> <p class="text-h5 font-italic">
@@ -99,13 +102,11 @@
</v-card-actions> </v-card-actions>
</v-card-text> </v-card-text>
</v-card> </v-card>
<v-expansion-panels v-else> <div v-else>
<v-card-title>{{ $t("recipe.parser.parsing-completed") }}</v-card-title> <v-card-title class="text-center pt-0 pb-8">
<v-expansion-panel>
<v-expansion-panel-title>
{{ $t("recipe.parser.review-parsed-ingredients") }} {{ $t("recipe.parser.review-parsed-ingredients") }}
</v-expansion-panel-title> </v-card-title>
<v-expansion-panel-text> <v-card-text style="max-height: 60vh; overflow-y: auto;">
<VueDraggable <VueDraggable
v-model="parsedIngs" v-model="parsedIngs"
handle=".handle" handle=".handle"
@@ -117,48 +118,66 @@
disabled: false, disabled: false,
ghostClass: 'ghost', ghostClass: 'ghost',
}" }"
class="px-6"
@start="drag = true" @start="drag = true"
@end="drag = false" @end="drag = false"
> >
<TransitionGroup <TransitionGroup
type="transition" type="transition"
> >
<div v-for="(ingredient, index) in parsedIngs" :key="index"> <v-lazy v-for="(ingredient, index) in parsedIngs" :key="index">
<RecipeIngredientEditor <RecipeIngredientEditor
v-model="ingredient.ingredient" v-model="ingredient.ingredient"
enable-drag-handle
enable-context-menu enable-context-menu
class="list-group-item" class="list-group-item pb-8"
:delete-disabled="parsedIngs.length <= 1"
@delete="parsedIngs.splice(index, 1)" @delete="parsedIngs.splice(index, 1)"
@insert-above="insertNewIngredient(index)" @insert-above="insertNewIngredient(index)"
@insert-below="insertNewIngredient(index + 1)" @insert-below="insertNewIngredient(index + 1)"
/> >
<p class="pt-0 pb-4 my-0 text-caption"> <template #before-divider>
<p v-if="ingredient.input" class="py-0 my-0 text-caption">
{{ $t("recipe.original-text-with-value", { originalText: ingredient.input }) }} {{ $t("recipe.original-text-with-value", { originalText: ingredient.input }) }}
</p> </p>
</div> </template>
</RecipeIngredientEditor>
</v-lazy>
</TransitionGroup> </TransitionGroup>
</VueDraggable> </VueDraggable>
</v-expansion-panel-text> </v-card-text>
</v-expansion-panel> </div>
</v-expansion-panels> </div>
</v-container> </v-container>
<template v-if="!state.loading.parser" #custom-card-action> <template v-if="!state.loading.parser" #custom-card-action>
<BaseButton <!-- Parse -->
v-if="!state.allReviewed" <div v-if="!state.allReviewed" class="d-flex justify-space-between align-center">
color="info" <v-checkbox
:icon="$globals.icons.arrowRightBold" v-model="currentIngShouldDelete"
icon-right color="error"
:text="$t('general.next')" hide-details
@click="nextIngredient" density="compact"
:label="i18n.t('recipe.parser.delete-item')"
class="mr-4"
/> />
<BaseButton <BaseButton
v-else :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 create
:text="$t('general.save')" :text="$t('general.save')"
:icon="$globals.icons.save" :icon="$globals.icons.save"
:loading="state.loading.save" :loading="state.loading.save"
@click="saveIngs" @click="saveIngs"
/> />
</div>
</template> </template>
</BaseDialog> </BaseDialog>
</template> </template>
@@ -226,6 +245,7 @@ const currentIng = ref<ParsedIngredient | null>(null);
const currentMissingUnit = ref(""); const currentMissingUnit = ref("");
const currentMissingFood = ref(""); const currentMissingFood = ref("");
const currentIngHasError = computed(() => currentMissingUnit.value || currentMissingFood.value); const currentIngHasError = computed(() => currentMissingUnit.value || currentMissingFood.value);
const currentIngShouldDelete = ref(false);
const state = reactive({ const state = reactive({
currentParsedIndex: -1, currentParsedIndex: -1,
@@ -297,6 +317,11 @@ function checkFood(ing: ParsedIngredient) {
} }
function nextIngredient() { function nextIngredient() {
if (currentIngShouldDelete.value) {
parsedIngs.value.splice(state.currentParsedIndex, 1);
currentIngShouldDelete.value = false;
}
let nextIndex = state.currentParsedIndex + 1; let nextIndex = state.currentParsedIndex + 1;
while (nextIndex < parsedIngs.value.length) { while (nextIndex < parsedIngs.value.length) {
@@ -304,6 +329,7 @@ function nextIngredient() {
if (shouldReview(current)) { if (shouldReview(current)) {
state.currentParsedIndex = nextIndex; state.currentParsedIndex = nextIndex;
currentIng.value = current; currentIng.value = current;
currentIngShouldDelete.value = false;
checkUnit(current); checkUnit(current);
checkFood(current); checkFood(current);
return; return;
@@ -462,6 +488,16 @@ watch(parser, () => {
parseIngredients(); 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 { function asPercentage(num: number | undefined): string {
if (!num) { if (!num) {
return "0%"; return "0%";

View File

@@ -671,12 +671,12 @@
"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", "review-parsed-ingredients": "Review parsed ingredients",
"confidence-score": "Confidence Score", "confidence-score": "Confidence Score",
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.", "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.", "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", "reset-servings-count": "Reset Servings Count",
"not-linked-ingredients": "Additional Ingredients", "not-linked-ingredients": "Additional Ingredients",