Files
recipes/vue3/src/pages/RecipeImportPage.vue
2025-01-20 09:40:22 +01:00

454 lines
23 KiB
Vue

<template>
<v-container>
<v-row >
<v-col>
<v-stepper v-model="stepper" >
<template v-slot:default="{ prev, next }">
<v-stepper-header>
<v-stepper-item :title="$t('Import')" value="1"></v-stepper-item>
<v-divider></v-divider>
<v-stepper-item :title="$t('Image')" value="2"></v-stepper-item>
<v-divider></v-divider>
<v-stepper-item :title="$t('Keywords')" value="3"></v-stepper-item>
<v-divider></v-divider>
<v-stepper-item :title="$t('Steps')" value="4"></v-stepper-item>
<v-divider></v-divider>
<v-stepper-item :title="$t('Save')" value="5"></v-stepper-item>
</v-stepper-header>
<v-stepper-window>
<v-stepper-window-item value="1" >
<v-card :loading="loading">
<v-card-text>
<v-text-field :label="$t('Website') + ' (https://...)'" v-model="importUrl">
<template #append>
<v-btn color="primary" icon="fa-solid fa-cloud-arrow-down fa-fw" @click="loadRecipeFromUrl()"></v-btn>
</template>
</v-text-field>
<v-file-input v-model="image" :label="$t('Image')" @click="uploadAndConvertImage()">
<template #append>
<v-btn>AI Import</v-btn>
</template>
</v-file-input>
<!-- <v-textarea :placeholder="$t('paste_json')"></v-textarea> -->
<v-alert variant="tonal" v-if="importResponse.duplicates && importResponse.duplicates.length > 0">
<v-alert-title>{{ $t('Duplicate') }}</v-alert-title>
{{ $t('DuplicateFoundInfo') }}
<v-list>
<v-list-item :to="{name: 'view_recipe', params: {id: r.id}}" v-for="r in importResponse.duplicates" :key="r.id"> {{ r.name }} (#{{r.id}})</v-list-item>
</v-list>
</v-alert>
</v-card-text>
</v-card>
</v-stepper-window-item>
<v-stepper-window-item value="2">
<v-row>
<v-col cols="12" md="6">
<h2 class="text-h5">{{ $t('Selected') }}</h2>
<v-img max-height="30vh" :src="importResponse.recipe.imageUrl"></v-img>
</v-col>
<v-col cols="12" md="6">
<h2 class="text-h5">{{ $t('Available') }}</h2>
<v-row dense>
<v-col cols="4" v-for="i in importResponse.images">
<v-img max-height="10vh" cover aspect-ratio="1" :src="i" @click="importResponse.recipe.imageUrl = i"></v-img>
</v-col>
</v-row>
</v-col>
</v-row>
</v-stepper-window-item>
<v-stepper-window-item value="3">
<v-row>
<v-col class="text-center">
<v-btn-group border divided>
<v-btn prepend-icon="fa-solid fa-square-check" @click="setAllKeywordsImportStatus(true)">{{ $t('SelectAll') }}</v-btn>
<v-btn prepend-icon="fa-solid fa-square-minus" @click="setAllKeywordsImportStatus(false)">{{ $t('SelectNone') }}</v-btn>
</v-btn-group>
</v-col>
</v-row>
<v-row>
<v-col>
<model-select model="Keyword" v-model="keywordSelect">
<template #append>
<v-btn icon="$add" color="success"
@click="keywordSelect.importKeyword = true; importResponse.recipe.keywords.push(keywordSelect); keywordSelect= null"
:disabled="keywordSelect == null"></v-btn>
</template>
</model-select>
</v-col>
</v-row>
<v-list>
<v-list-item border v-for="k in importResponse.recipe.keywords" :key="k" :class="{'bg-success': k.importKeyword}"
@click="k.importKeyword = !k.importKeyword">
{{ k.label }}
<template #append>
<v-checkbox-btn :model-value="k.importKeyword"></v-checkbox-btn>
</template>
</v-list-item>
</v-list>
</v-stepper-window-item>
<v-stepper-window-item value="4">
<v-row>
<v-col class="text-center">
<v-btn-group border divided>
<v-btn prepend-icon="fa-solid fa-shuffle" @click="autoSortIngredients()"><span v-if="!mobile">{{ $t('Auto_Sort') }}</span></v-btn>
<v-btn prepend-icon="fa-solid fa-maximize" @click="splitAllSteps('\n')"><span v-if="!mobile">{{ $t('Split') }}</span></v-btn>
<v-btn prepend-icon="fa-solid fa-minimize" @click="mergeAllSteps()"><span v-if="!mobile">{{ $t('Merge') }}</span></v-btn>
</v-btn-group>
</v-col>
</v-row>
<v-row v-for="(s,i) in importResponse.recipe.steps" :key="i">
<v-col cols="12">
<v-chip color="primary">#{{ i + 1 }}</v-chip>
<v-btn variant="plain" size="small" icon class="float-right">
<v-icon icon="$menu"></v-icon>
<v-menu activator="parent">
<v-list>
<v-list-item prepend-icon="$delete" @click="deleteStep(s)">{{ $t('Delete') }}</v-list-item>
<v-list-item prepend-icon="fa-solid fa-maximize" @click="splitStep(s, '\n')">{{ $t('Split') }}</v-list-item>
</v-list>
</v-menu>
</v-btn>
</v-col>
<v-col cols="12" md="6">
<v-list>
<vue-draggable v-model="s.ingredients" group="ingredients" handle=".drag-handle" empty-insert-threshold="25">
<v-list-item v-for="i in s.ingredients" border>
<v-icon size="small" class="drag-handle cursor-grab" icon="$dragHandle"></v-icon>
{{ i.amount }} <span v-if="i.unit">{{ i.unit.name }}</span> <span v-if="i.food">{{ i.food.name }}</span>
<template #append>
<v-btn variant="plain" size="small" icon class="float-right">
<v-icon icon="$menu"></v-icon>
<v-menu activator="parent">
<v-list>
<v-list-item prepend-icon="$edit" @click="editingIngredient = i; dialog=true">{{ $t('Edit') }}</v-list-item>
<v-list-item prepend-icon="$delete" @click="deleteIngredient(s,i)">{{ $t('Delete') }}</v-list-item>
</v-list>
</v-menu>
</v-btn>
</template>
</v-list-item>
</vue-draggable>
</v-list>
</v-col>
<v-col cols="12" md="6">
<v-textarea class="mt-2" v-model="s.instruction"></v-textarea>
</v-col>
</v-row>
<v-row>
<v-col class="text-center">
<v-btn icon="$add" color="create" @click="addStep()"></v-btn>
</v-col>
</v-row>
<v-dialog max-width="450px" v-model="dialog">
<v-card>
<v-closable-card-title v-model="dialog" :title="$t('Ingredient Editor')"></v-closable-card-title>
<v-card-text>
<v-text-field :label="$t('Original_Text')" v-model="editingIngredient.originalText" disabled></v-text-field>
<v-text-field :label="$t('Amount')" v-model="editingIngredient.amount"></v-text-field>
<v-text-field :label="$t('Unit')" v-model="editingIngredient.unit.name" v-if="editingIngredient.unit"></v-text-field>
<v-btn prepend-icon="$create" color="create" v-else>{{ $t('Unit') }}</v-btn>
<v-text-field :label="$t('Food')" v-model="editingIngredient.food.name"></v-text-field>
<v-text-field :label="$t('Note')" v-model="editingIngredient.note"></v-text-field>
</v-card-text>
<v-card-actions>
<v-btn class="float-right" color="save" @click="dialog = false">{{ $t('Save') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-stepper-window-item>
<v-stepper-window-item value="5">
<v-card :loading="loading || fileApiLoading">
<v-card-title>{{ importResponse.recipe.name }}</v-card-title>
<v-row>
<v-col cols="12" md="6">
<v-img v-if="importResponse.recipe.imageUrl" :src="importResponse.recipe.imageUrl"></v-img>
</v-col>
<v-col cols="12" md="6">
<v-text-field :label="$t('Name')" v-model="importResponse.recipe.name"></v-text-field>
<v-number-input :label="$t('Servings')" v-model="importResponse.recipe.servings"></v-number-input>
<v-text-field :label="$t('ServingsText')" v-model="importResponse.recipe.servingsText"></v-text-field>
<v-textarea :label="$t('Description')" v-model="importResponse.recipe.description" clearable></v-textarea>
</v-col>
</v-row>
</v-card>
</v-stepper-window-item>
</v-stepper-window>
<v-stepper-actions >
<template #prev>
<v-btn @click="stepper = (parseInt(stepper) - 1).toString()">Zurück</v-btn>
</template>
<template #next>
<v-btn @click="createRecipeFromImport()" color="success" :disabled="Object.keys(importResponse).length == 0" v-if="stepper == '1'">{{$t('Import')}}</v-btn>
<v-btn @click="stepper = (parseInt(stepper) + 1).toString()" :disabled="Object.keys(importResponse).length == 0" v-if="stepper != '5'">
{{stepper == '1' ? $t('Edit') : $t('Next')}}
</v-btn>
<v-btn @click="createRecipeFromImport()" color="success" :disabled="false" v-if="stepper == '5'">{{$t('Import')}}</v-btn>
</template>
</v-stepper-actions>
</template>
</v-stepper>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts" setup>
import {nextTick, ref} from "vue";
import {ApiApi, Keyword, RecipeFromSourceResponse, type SourceImportIngredient, SourceImportKeyword, SourceImportStep} from "@/openapi";
import {ErrorMessageType, MessageType, useMessageStore} from "@/stores/MessageStore";
import {useRouter} from "vue-router";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import {VueDraggable} from "vue-draggable-plus";
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
import KeywordsBar from "@/components/display/KeywordsBar.vue";
import {VNumberInput} from 'vuetify/labs/VNumberInput'
import {useFileApi} from "@/composables/useFileApi";
import ModelSelect from "@/components/inputs/ModelSelect.vue";
import {useDisplay} from "vuetify";
const {mobile} = useDisplay()
const router = useRouter()
const {updateRecipeImage, convertImageToRecipe, fileApiLoading} = useFileApi()
const stepper = ref("1")
const dialog = ref(false)
const loading = ref(false)
const importUrl = ref("")
const image = ref<null|File>(null)
const importResponse = ref({} as RecipeFromSourceResponse)
const keywordSelect = ref<null | SourceImportKeyword>(null)
const editingIngredient = ref({} as SourceImportIngredient)
/**
* call server to load recipe from a given URl
*/
function loadRecipeFromUrl() {
let api = new ApiApi()
loading.value = true
api.apiRecipeFromSourceCreate({recipeFromSource: {url: importUrl.value}}).then(r => {
importResponse.value = r
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
}).finally(() => {
loading.value = false
})
}
function uploadAndConvertImage(){
if(image.value != null){
convertImageToRecipe(image.value).then(r => {
importResponse.value = r
})
}
}
/**
* create recipe in database
*/
function createRecipeFromImport() {
let api = new ApiApi()
if (importResponse.value.recipe) {
loading.value = true
importResponse.value.recipe.keywords = importResponse.value.recipe.keywords.filter(k => k.importKeyword)
api.apiRecipeCreate({recipe: importResponse.value.recipe}).then(recipe => {
updateRecipeImage(recipe.id!, null, importResponse.value.recipe?.imageUrl).then(r => {
router.push({name: 'view_recipe', params: {id: recipe.id}})
})
}).catch(err => {
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err)
}).finally(() => {
loading.value = false
})
}
}
/**
* deletes the given step from the local recipe
* @param step step to delete
*/
function deleteStep(step: SourceImportStep) {
if (importResponse.value.recipe) {
importResponse.value.recipe.steps.splice(importResponse.value.recipe.steps.findIndex(x => x === step), 1)
}
}
/**
* utility function used by splitAllSteps and splitStep to split a single step object into multiple step objects
* @param step step to split
* @param split_character character to use as a delimiter between steps
*/
function splitStepObject(step: SourceImportStep, split_character: string) {
let steps: SourceImportStep[] = []
step.instruction.split(split_character).forEach(part => {
if (part.trim() !== '') {
steps.push({instruction: part, ingredients: [], showIngredientsTable: useUserPreferenceStore().userSettings.showStepIngredients!})
}
})
steps[0].ingredients = step.ingredients // put all ingredients from the original step in the ingredients of the first step of the split step list
return steps
}
/**
* Splits all steps of a given recipe_json at the split character (e.g. \n or \n\n)
* @param split_character character to split steps at
*/
function splitAllSteps(split_character: string) {
let steps: SourceImportStep[] = []
if (importResponse.value.recipe) {
importResponse.value.recipe.steps.forEach(step => {
steps = steps.concat(splitStepObject(step, split_character))
})
importResponse.value.recipe.steps = steps
} else {
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
}
}
/**
* Splits the given step at the split character (e.g. \n or \n\n)
* @param step step to split
* @param split_character character to use as a delimiter between steps
*/
function splitStep(step: SourceImportStep, split_character: string) {
if (importResponse.value.recipe) {
let old_index = importResponse.value.recipe.steps.findIndex(x => x === step)
let new_steps = splitStepObject(step, split_character)
importResponse.value.recipe.steps.splice(old_index, 1, ...new_steps)
} else {
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
}
}
/**
* Merge all steps of a given recipe_json into one
*/
function mergeAllSteps() {
let step = {instruction: '', ingredients: [], showIngredientsTable: useUserPreferenceStore().userSettings.showStepIngredients!} as SourceImportStep
if (importResponse.value.recipe) {
importResponse.value.recipe.steps.forEach(s => {
step.instruction += s.instruction + '\n'
step.ingredients = step.ingredients.concat(s.ingredients)
})
importResponse.value.recipe.steps = [step]
} else {
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
}
}
/**
* Merge two steps (the given and next one)
*/
function mergeStep(step: SourceImportStep) {
if (importResponse.value.recipe) {
let step_index = importResponse.value.recipe.steps.findIndex(x => x === step)
let removed_steps = importResponse.value.recipe.steps.splice(step_index, 2)
importResponse.value.recipe.steps.splice(step_index, 0, {
instruction: removed_steps.flatMap(x => x.instruction).join('\n'),
ingredients: removed_steps.flatMap(x => x.ingredients),
showIngredientsTable: useUserPreferenceStore().userSettings.showStepIngredients!
})
} else {
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
}
}
/**
* deletes the given ingredient from the given step
* @param step step to delete ingredient from
* @param ingredient ingredient to delete
*/
function deleteIngredient(step: SourceImportStep, ingredient: SourceImportIngredient) {
step.ingredients = step.ingredients.filter(i => i != ingredient)
}
/**
* automatically assign ingredients to steps based on text matching
*/
function autoSortIngredients() {
if (importResponse.value.recipe) {
let ingredients = importResponse.value.recipe.steps.flatMap(s => s.ingredients)
importResponse.value.recipe.steps.forEach(s => s.ingredients = [])
ingredients.forEach(i => {
let found = false
importResponse.value.recipe!.steps.forEach(s => {
if (s.instruction.includes(i.food.name.trim()) && !found) {
found = true
s.ingredients.push(i)
}
})
if (!found) {
importResponse.value.recipe!.steps[0].ingredients.push(i)
}
// TODO implement a new "second try" algorithm if no exact match was found
/*
if (!found) {
let best_match = {rating: 0, step: importResponse.value.recipe.steps[0]}
importResponse.value.recipe.steps.forEach(s => {
let match = stringSimilarity.findBestMatch(i.food.name.trim(), s.instruction.split(' '))
if (match.bestMatch.rating > best_match.rating) {
best_match = {rating: match.bestMatch.rating, step: s}
}
})
best_match.step.ingredients.push(i)
found = true
}
*/
})
} else {
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
}
}
/**
* set the import status for all keywords to the given status
* @param status if keyword should be imported or not
*/
function setAllKeywordsImportStatus(status: boolean) {
importResponse.value.recipe?.keywords.forEach(keyword => {
keyword.importKeyword = status
})
}
/**
* add a new (empty) step at the end of the step list
*/
function addStep() {
importResponse.value.recipe?.steps.push({} as SourceImportStep)
}
</script>
<style scoped>
</style>