mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-10-28 00:34:47 -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