mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2026-01-10 08:38:34 -05:00
998 lines
51 KiB
Vue
998 lines
51 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('Type')" value="type" icon=" "></v-stepper-item>
|
|
<v-divider></v-divider>
|
|
|
|
<template v-if="['url','ai', 'source'].includes(importType)">
|
|
<v-stepper-item :title="$t('Import')" value="url" icon=" "></v-stepper-item>
|
|
<v-divider></v-divider>
|
|
<template v-if="importResponse.duplicates && importResponse.duplicates.length > 0">
|
|
<v-stepper-item :title="$t('Duplicate')" value="duplicates" icon=" "></v-stepper-item>
|
|
<v-divider></v-divider>
|
|
</template>
|
|
<v-stepper-item :title="$t('Image')" value="image_chooser" icon=" "></v-stepper-item>
|
|
<v-divider></v-divider>
|
|
<v-stepper-item :title="$t('Keywords')" value="keywords_chooser" icon=" "></v-stepper-item>
|
|
<v-divider></v-divider>
|
|
<v-stepper-item :title="$t('Steps')" value="step_editor" icon=" "></v-stepper-item>
|
|
<v-divider></v-divider>
|
|
<v-stepper-item :title="$t('Save')" value="confirm" icon=" "></v-stepper-item>
|
|
</template>
|
|
<template v-if="importType == 'app'">
|
|
<v-stepper-item :title="$t('App')" value="app" icon=" "></v-stepper-item>
|
|
<v-divider></v-divider>
|
|
<v-stepper-item :title="$t('File')" value="file" icon=" "></v-stepper-item>
|
|
<v-divider></v-divider>
|
|
<v-stepper-item :title="$t('Import')" value="import_log" icon=" "></v-stepper-item>
|
|
</template>
|
|
|
|
<template v-if="importType == 'bookmarklet'">
|
|
<v-stepper-item :title="$t('Bookmarklet')" value="bookmarklet" icon=" "></v-stepper-item>
|
|
</template>
|
|
|
|
<template v-if="importType == 'url-list'">
|
|
<v-stepper-item :title="$t('UrlList')" value="url_list_input" icon=" "></v-stepper-item>
|
|
<v-divider></v-divider>
|
|
<v-stepper-item :title="$t('Import')" value="url_list_import" icon=" "></v-stepper-item>
|
|
</template>
|
|
|
|
</v-stepper-header>
|
|
|
|
<v-stepper-window>
|
|
<v-stepper-window-item value="type">
|
|
<v-row>
|
|
<v-col cols="12" md="6">
|
|
<v-card
|
|
:title="$t('Url_Import')"
|
|
:subtitle="$t('UrlImportSubtitle')"
|
|
prepend-icon="$import"
|
|
variant="outlined"
|
|
:color="(importType == 'url') ? 'primary' : ''"
|
|
elevation="1"
|
|
@click="importType = 'url'">
|
|
</v-card>
|
|
</v-col>
|
|
|
|
<v-col cols="12" md="6">
|
|
<v-card
|
|
:title="$t('AI')"
|
|
:subtitle="$t('AIImportSubtitle')"
|
|
prepend-icon="$ai"
|
|
variant="outlined"
|
|
:color="(importType == 'ai') ? 'primary' : ''"
|
|
elevation="1"
|
|
@click="importType = 'ai'"
|
|
:disabled="!useUserPreferenceStore().serverSettings.enableAiImport">
|
|
</v-card>
|
|
<!-- TODO temporary until AI backend system is improved -->
|
|
<v-label class="font-italic" v-if="!useUserPreferenceStore().serverSettings.enableAiImport">Set AI_API_KEY on server to use AI</v-label>
|
|
</v-col>
|
|
|
|
<v-col cols="12" md="6">
|
|
<v-card
|
|
:title="$t('App')"
|
|
:subtitle="$t('AppImportSubtitle')"
|
|
prepend-icon="fa-solid fa-folder-open"
|
|
variant="outlined"
|
|
:color="(importType == 'app') ? 'primary' : ''"
|
|
elevation="1"
|
|
@click="importType = 'app'">
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-card
|
|
:title="$t('Bookmarklet')"
|
|
:subtitle="$t('BookmarkletImportSubtitle')"
|
|
prepend-icon="fa-solid fa-bookmark"
|
|
variant="outlined"
|
|
:color="(importType == 'bookmarklet') ? 'primary' : ''"
|
|
elevation="1"
|
|
@click="importType = 'bookmarklet'">
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-card
|
|
title="JSON/HTML"
|
|
:subtitle="$t('SourceImportSubtitle')"
|
|
prepend-icon="fa-solid fa-code"
|
|
variant="outlined"
|
|
:color="(importType == 'source') ? 'primary' : ''"
|
|
elevation="1"
|
|
@click="importType = 'source'">
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-card
|
|
:title="$t('UrlList')"
|
|
:subtitle="$t('UrlListSubtitle')"
|
|
prepend-icon="fa-solid fa-list"
|
|
variant="outlined"
|
|
:color="(importType == 'url-list') ? 'primary' : ''"
|
|
elevation="1"
|
|
@click="importType = 'url-list'">
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
<v-stepper-actions>
|
|
<template #prev>
|
|
<v-spacer></v-spacer>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="stepper = 'url'" v-if="['url','ai', 'source'].includes(importType)" color="success">{{ $t('Next') }}</v-btn>
|
|
<v-btn @click="stepper = 'app'" v-if="importType == 'app'" color="success">{{ $t('Next') }}</v-btn>
|
|
<v-btn @click="stepper = 'bookmarklet'" v-if="importType == 'bookmarklet'" color="success">{{ $t('Next') }}</v-btn>
|
|
<v-btn @click="stepper = 'url_list_input'" v-if="importType == 'url-list'" color="success">{{ $t('Next') }}</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
|
|
<!-- ------------ -->
|
|
<!-- ULR/AI Items -->
|
|
<!-- ------------ -->
|
|
|
|
<v-stepper-window-item value="url">
|
|
<v-text-field :label="$t('Website') + ' (https://...)'" v-model="importUrl" v-if="importType == 'url'" :loading="loading" autofocus
|
|
@keydown.enter="loadRecipeFromUrl({url: importUrl})"></v-text-field>
|
|
|
|
<div v-if="importType == 'ai'">
|
|
<v-btn-toggle v-model="aiMode">
|
|
<v-btn value="file">{{ $t('File') }}</v-btn>
|
|
<v-btn value="text">{{ $t('Text') }}</v-btn>
|
|
</v-btn-toggle>
|
|
|
|
<v-file-upload v-model="image" v-if="aiMode == 'file'" :loading="loading" clearable>
|
|
<template #icon>
|
|
<v-icon icon="fa-solid fa-file-pdf"></v-icon>
|
|
{{ $t('or') }}
|
|
<v-icon icon="fa-solid fa-file-image"></v-icon>
|
|
</template>
|
|
</v-file-upload>
|
|
|
|
<v-textarea v-model="sourceImportText" :loading="loading" autofocus v-if="aiMode == 'text'"
|
|
@keydown.enter="loadRecipeFromAiImport()"></v-textarea>
|
|
</div>
|
|
|
|
<v-textarea v-model="sourceImportText" label="JSON/HTML" :loading="loading" v-if="importType == 'source'" :hint="$t('SourceImportHelp')"
|
|
persistent-hint autofocus @keydown.enter="loadRecipeFromUrl({data: sourceImportText})"></v-textarea>
|
|
|
|
<v-alert v-if="importResponse.error" :title="$t('Error')" :text="importResponse.msg" color="warning">
|
|
|
|
</v-alert>
|
|
|
|
<v-stepper-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'type'; importResponse = {}">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="loadRecipeFromUrl({url: importUrl})" v-if="importType == 'url'" :disabled="importUrl == ''" :loading="loading">{{
|
|
$t('Load')
|
|
}}
|
|
</v-btn>
|
|
<v-btn @click="loadRecipeFromUrl({data: sourceImportText})" v-if="importType == 'source'" :disabled="sourceImportText == ''"
|
|
:loading="loading">{{ $t('Load') }}
|
|
</v-btn>
|
|
<v-btn @click="loadRecipeFromAiImport()" v-if="importType == 'ai'"
|
|
:disabled="(aiMode == 'file' && image == null) || (aiMode == 'text' && sourceImportText == '')" :loading="loading">{{ $t('Load') }}
|
|
</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
<v-stepper-window-item value="duplicates">
|
|
<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: 'RecipeViewPage', 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-stepper-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'url'">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="stepper = 'image_chooser'">{{ $t('Next') }}</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
<v-stepper-window-item value="image_chooser">
|
|
<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-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'duplicates'" v-if="importResponse.duplicates && importResponse.duplicates.length > 0">{{ $t('Back') }}</v-btn>
|
|
<v-btn @click="stepper = 'url'" v-else>{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="stepper = 'keywords_chooser'">{{ $t('Next') }}</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
<v-stepper-window-item value="keywords_chooser">
|
|
<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-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'image_chooser'">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="stepper = 'step_editor'">{{ $t('Next') }}</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
<v-stepper-window-item value="step_editor">
|
|
<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, stepIndex) in importResponse.recipe.steps" :key="stepIndex">
|
|
<v-col cols="12">
|
|
<v-chip color="primary">#{{ stepIndex + 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, ingredientIndex) in s.ingredients" border>
|
|
<v-icon size="small" class="drag-handle cursor-grab mr-2" icon="$dragHandle"></v-icon>
|
|
<v-chip density="compact" label class="mr-1">{{ i.amount }}</v-chip>
|
|
<v-chip density="compact" label class="mr-1" v-if="i.unit">{{ i.unit.name }}</v-chip>
|
|
<v-chip density="compact" label class="mr-1" v-if="i.food">{{ i.food.name }}</v-chip>
|
|
<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-item prepend-icon="fa-solid fa-sort"
|
|
@click="editingIngredientIndex = ingredientIndex; editingStepIndex = stepIndex; editingStep = s; dialogIngredientSorter = true">
|
|
{{ $t('Move') }}
|
|
</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" auto-grow></v-textarea>
|
|
</v-col>
|
|
<v-divider></v-divider>
|
|
</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" readonly></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" :rules="['required']" v-if="editingIngredient.unit">
|
|
<template #append-inner>
|
|
<v-btn icon="$delete" color="delete" @click="editingIngredient.unit = null"></v-btn>
|
|
</template>
|
|
</v-text-field>
|
|
<v-btn prepend-icon="$create" color="create" class="mb-4" @click="editingIngredient.unit = {name: ''}" 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-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'keywords_chooser'">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="stepper = 'confirm'">{{ $t('Next') }}</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
<v-stepper-window-item value="confirm">
|
|
<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" :rules="[['maxLength',128]]"></v-text-field>
|
|
<v-number-input :label="$t('Servings')" v-model="importResponse.recipe.servings" :precision="2"></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" :rules="[['maxLength',512]]" counter
|
|
clearable></v-textarea>
|
|
|
|
<v-checkbox v-model="editAfterImport" :label="$t('Edit_Recipe')" hide-details></v-checkbox>
|
|
</v-col>
|
|
|
|
</v-row>
|
|
|
|
</v-card>
|
|
<v-stepper-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'step_editor'">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="createRecipeFromImport()" :disabled="false" color="success">{{ $t('Import') }}</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
|
|
<!-- ---------------- -->
|
|
<!-- App Import Items -->
|
|
<!-- ---------------- -->
|
|
|
|
<v-stepper-window-item value="app">
|
|
|
|
<v-row>
|
|
<v-col cols="12" md="3" v-for="i in INTEGRATIONS">
|
|
<v-card prepend-icon="fa-solid fa-carrot" :title="i.name" @click="importApp = i.id" variant="outlined" elevation="1"
|
|
:color="(importApp == i.id) ? 'primary' : ''">
|
|
<template #append>
|
|
<v-btn icon="$help" variant="plain" :href="i.helpUrl" target="_blank"></v-btn>
|
|
</template>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-stepper-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'type'">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="stepper = 'file'">{{ $t('Next') }}</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
<v-stepper-window-item value="file">
|
|
<v-file-upload v-model="appImportFiles" multiple></v-file-upload>
|
|
|
|
<v-alert variant="outlined" elevation="1" density="compact" :title="$t('Duplicate')" :text="$t('import_duplicates')" class="mt-2">
|
|
<template #prepend>
|
|
<v-checkbox v-model="appImportDuplicates"></v-checkbox>
|
|
</template>
|
|
</v-alert>
|
|
|
|
<v-stepper-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'app'">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="appImport()" :disabled="appImportFiles.length == 0" :loading="fileApiLoading">{{ $t('Import') }}</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
<v-stepper-window-item value="import_log">
|
|
|
|
<import-log-viewer :import-log="appImportLog" v-if="appImportLog"></import-log-viewer>
|
|
|
|
<v-stepper-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'file'">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn :to="{name: 'SearchPage', query: {keywords: appImportLog.keyword.id}}" v-if="appImportLog && !appImportLog.running"
|
|
:disabled="false">{{ $t('View_Recipes') }}
|
|
</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
|
|
<!-- ------------ -->
|
|
<!-- Bookmarklet -->
|
|
<!-- ------------ -->
|
|
<v-stepper-window-item value="bookmarklet">
|
|
{{ $t('BookmarkletImportSubtitle') }}
|
|
|
|
<ol>
|
|
<li>1. {{ $t('BookmarkletHelp1') }}</li>
|
|
<li>
|
|
<v-btn :href="bookmarkletContent" color="primary">{{ $t('ImportIntoTandoor') }}</v-btn>
|
|
</li>
|
|
<li>2. {{ $t('BookmarkletHelp2') }}</li>
|
|
<li>3. {{ $t('BookmarkletHelp3') }}</li>
|
|
</ol>
|
|
|
|
<v-stepper-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'type'">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
|
|
<!-- ---------------- -->
|
|
<!-- URL List -->
|
|
<!-- ---------------- -->
|
|
|
|
<v-stepper-window-item value="url_list_input">
|
|
|
|
<v-textarea :hint="$t('one_url_per_line')" auto-grow max-rows="20" persistent-hint v-model="urlListImportInput">
|
|
|
|
</v-textarea>
|
|
|
|
<v-stepper-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'type'">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="stepper = 'url_list_import'; doListImport()" :disabled="urlListImportInput.length == 0">{{ $t('Import') }}</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
|
|
<v-stepper-window-item value="url_list_import">
|
|
|
|
<v-progress-linear :height="16" :model-value="urlListImportedRecipes.length / urlListImportInput.split('\n').length * 100">
|
|
{{ urlListImportedRecipes.length }} / {{ urlListImportInput.split('\n').length }}
|
|
</v-progress-linear>
|
|
|
|
<v-list>
|
|
<v-list-item border v-for="r in urlListImportedRecipes" :title="r.name" :subtitle="r.sourceUrl" :key="r.id"
|
|
:to="{name: 'RecipeViewPage', params: {id: r.id}}" target="_blank">
|
|
|
|
</v-list-item>
|
|
</v-list>
|
|
|
|
<v-stepper-actions>
|
|
<template #prev>
|
|
<v-btn @click="stepper = 'url_list_input'">{{ $t('Back') }}</v-btn>
|
|
</template>
|
|
<template #next>
|
|
<v-btn @click="resetImporter()" :disabled="loading">{{ $t('Reset') }}</v-btn>
|
|
</template>
|
|
</v-stepper-actions>
|
|
</v-stepper-window-item>
|
|
|
|
|
|
</v-stepper-window>
|
|
</template>
|
|
</v-stepper>
|
|
</v-col>
|
|
</v-row>
|
|
<v-row dense>
|
|
<v-col class="text-center">
|
|
<v-btn size="small" prepend-icon="fa-solid fa-arrow-rotate-left" variant="tonal" color="warning" @click="resetImporter()">{{ $t('Reset') }}</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
|
|
<step-ingredient-sorter-dialog :step-index="editingStepIndex" :step="editingStep" :recipe="importResponse.recipe" v-model="dialogIngredientSorter"
|
|
:ingredient-index="editingIngredientIndex"></step-ingredient-sorter-dialog>
|
|
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
|
|
import {useI18n} from "vue-i18n";
|
|
import {computed, onMounted, ref} from "vue";
|
|
import {
|
|
AccessToken,
|
|
ApiApi,
|
|
ImportLog,
|
|
Recipe,
|
|
type RecipeFromSource,
|
|
RecipeFromSourceResponse,
|
|
type SourceImportIngredient,
|
|
SourceImportKeyword,
|
|
SourceImportStep,
|
|
Step
|
|
} from "@/openapi";
|
|
import {ErrorMessageType, MessageType, PreparedMessage, 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 {useFileApi} from "@/composables/useFileApi";
|
|
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
|
import {useDisplay} from "vuetify";
|
|
import {useUrlSearchParams} from "@vueuse/core";
|
|
import {INTEGRATIONS} from "@/utils/integration_utils";
|
|
import {VFileUpload} from 'vuetify/labs/VFileUpload'
|
|
import ImportLogViewer from "@/components/display/ImportLogViewer.vue";
|
|
import {DateTime} from "luxon";
|
|
import {useDjangoUrls} from "@/composables/useDjangoUrls";
|
|
import bookmarkletJs from '@/assets/bookmarklet_v3?url'
|
|
import StepIngredientSorterDialog from "@/components/dialogs/StepIngredientSorterDialog.vue";
|
|
|
|
function doListImport() {
|
|
urlList.value = urlListImportInput.value.split('\n')
|
|
loading.value = true
|
|
importFromUrlList()
|
|
}
|
|
|
|
function importFromUrlList() {
|
|
let api = new ApiApi()
|
|
let url = urlList.value.pop()
|
|
|
|
if (url != undefined && url.trim() != '') {
|
|
|
|
api.apiRecipeFromSourceCreate({recipeFromSource: {url: url}}).then(sourceResponse => {
|
|
if (sourceResponse.recipe) {
|
|
api.apiRecipeCreate({recipe: sourceResponse.recipe}).then(recipe => {
|
|
urlListImportedRecipes.value.push(recipe)
|
|
updateRecipeImage(recipe.id!, null, sourceResponse.recipe?.imageUrl).then(imageResponse => {
|
|
setTimeout(importFromUrlList, 500)
|
|
})
|
|
}).catch(err => {
|
|
|
|
}).finally(() => {
|
|
loading.value = false
|
|
})
|
|
}
|
|
}).catch(err => {
|
|
if (err.response.status == 429) {
|
|
useMessageStore().addPreparedMessage(PreparedMessage.RATE_LIMIT, err)
|
|
} else {
|
|
useMessageStore().addMessage(MessageType.WARNING, t('ErrorUrlListImport'), 8000, url)
|
|
}
|
|
urlListImportInput.value = url + '\n' + urlList.value.join('\n')
|
|
stepper.value = 'url_list_input'
|
|
}).finally(() => {
|
|
|
|
})
|
|
} else {
|
|
useMessageStore().addPreparedMessage(PreparedMessage.CREATE_SUCCESS)
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const params = useUrlSearchParams('history', {})
|
|
const {mobile} = useDisplay()
|
|
const router = useRouter()
|
|
const {t} = useI18n()
|
|
const {updateRecipeImage, doAiImport, doAppImport, fileApiLoading} = useFileApi()
|
|
const {getDjangoUrl} = useDjangoUrls()
|
|
|
|
const bookmarkletContent = computed(() => {
|
|
return 'javascript:(function(){' +
|
|
'if(window.bookmarkletTandoor!==undefined){' +
|
|
'bookmarkletTandoor();' +
|
|
'} else {' +
|
|
`localStorage.setItem("importURL", "${getDjangoUrl('/api/bookmarklet-import/')}");` +
|
|
`localStorage.setItem("redirectURL", "${getDjangoUrl('/recipe/import/')}");` +
|
|
`localStorage.setItem("token", "${bookmarkletToken.value}");` +
|
|
`document.body.appendChild(document.createElement("script")).src="${bookmarkletJs}"}` +
|
|
`})()`
|
|
})
|
|
|
|
const importType = ref<'url' | 'ai' | 'app' | 'bookmarklet' | 'source' | 'url-list'>("url")
|
|
const importApp = ref('DEFAULT')
|
|
const stepper = ref("type")
|
|
const dialog = ref(false)
|
|
|
|
const loading = ref(false)
|
|
|
|
const importUrl = ref("")
|
|
|
|
const urlListImportInput = ref("")
|
|
const urlList = ref([] as string[])
|
|
const urlListImportedRecipes = ref([] as Recipe[])
|
|
|
|
const sourceImportText = ref("")
|
|
const appImportFiles = ref<File[]>([])
|
|
const appImportDuplicates = ref(false)
|
|
const appImportLog = ref<null | ImportLog>(null)
|
|
const image = ref<null | File>(null)
|
|
const aiMode = ref<'file' | 'text'>('file')
|
|
const editAfterImport = ref(false)
|
|
|
|
const bookmarkletToken = ref("")
|
|
|
|
const importResponse = ref({} as RecipeFromSourceResponse)
|
|
const keywordSelect = ref<null | SourceImportKeyword>(null)
|
|
const editingIngredient = ref({} as SourceImportIngredient)
|
|
|
|
// stuff for ingredient mover, find some better solution at some point (finally merge importer/editor?)
|
|
const editingIngredientIndex = ref(0)
|
|
const dialogIngredientSorter = ref(false)
|
|
const editingStep = ref<Step | SourceImportStep>({} as Step)
|
|
const editingStepIndex = ref(0)
|
|
|
|
onMounted(() => {
|
|
loadOrCreateBookmarkletToken()
|
|
|
|
// handle manifest share intend passing url to import page
|
|
if (params.url && typeof params.url === "string") {
|
|
importUrl.value = params.url
|
|
loadRecipeFromUrl({url: importUrl.value})
|
|
}
|
|
if (params.text && typeof params.text === "string") {
|
|
importUrl.value = params.text
|
|
loadRecipeFromUrl({url: importUrl.value})
|
|
}
|
|
|
|
if (params.bookmarklet_import && typeof params.bookmarklet_import === "string" && !isNaN(parseInt(params.bookmarklet_import))) {
|
|
importType.value = 'url'
|
|
loadRecipeFromUrl({bookmarklet: parseInt(params.bookmarklet_import)})
|
|
}
|
|
})
|
|
|
|
/**
|
|
* call server to load recipe from a given URl
|
|
*/
|
|
function loadRecipeFromUrl(recipeFromSourceRequest: RecipeFromSource) {
|
|
let api = new ApiApi()
|
|
loading.value = true
|
|
importResponse.value = {} as RecipeFromSourceResponse
|
|
|
|
api.apiRecipeFromSourceCreate({recipeFromSource: recipeFromSourceRequest}).then(r => {
|
|
if (r.recipeId != null) {
|
|
router.push({name: 'RecipeViewPage', params: {id: r.recipeId}})
|
|
return
|
|
}
|
|
|
|
importResponse.value = r
|
|
|
|
if (importResponse.value.duplicates && importResponse.value.duplicates.length > 0) {
|
|
stepper.value = 'duplicates'
|
|
} else {
|
|
if (importResponse.value.images && importResponse.value.images.length > 0) {
|
|
stepper.value = 'image_chooser'
|
|
} else {
|
|
stepper.value = 'keywords_chooser'
|
|
}
|
|
}
|
|
}).catch(err => {
|
|
err.response.json().then(r => {
|
|
if (r.error) {
|
|
importResponse.value = r
|
|
} else {
|
|
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, r)
|
|
}
|
|
})
|
|
}).finally(() => {
|
|
loading.value = false
|
|
})
|
|
}
|
|
|
|
/**
|
|
* upload file to conversion endpoint
|
|
*/
|
|
function loadRecipeFromAiImport() {
|
|
let request = null
|
|
if (image.value != null && aiMode.value == 'file') {
|
|
console.log('file import')
|
|
request = doAiImport(image.value)
|
|
} else if (sourceImportText.value != '' && aiMode.value == 'text') {
|
|
console.log('text import')
|
|
request = doAiImport(null, sourceImportText.value)
|
|
}
|
|
|
|
if (request != null) {
|
|
loading.value = true
|
|
request.then(r => {
|
|
loading.value = false
|
|
importResponse.value = r
|
|
|
|
if (!importResponse.value.error) {
|
|
if (importResponse.value.images && importResponse.value.images.length > 0) {
|
|
stepper.value = 'image_chooser'
|
|
} else {
|
|
stepper.value = 'keywords_chooser'
|
|
}
|
|
}
|
|
}).catch(err => {
|
|
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
function appImport() {
|
|
doAppImport(appImportFiles.value, importApp.value, appImportDuplicates.value).then(r => {
|
|
stepper.value = 'import_log'
|
|
recLoadImportLog(r)
|
|
})
|
|
}
|
|
|
|
function recLoadImportLog(importLogId: number) {
|
|
let api = new ApiApi()
|
|
|
|
api.apiImportLogRetrieve({id: importLogId}).then(r => {
|
|
appImportLog.value = r
|
|
if (r.running) {
|
|
setTimeout(() => {
|
|
recLoadImportLog(importLogId)
|
|
}, 1000)
|
|
}
|
|
}).catch(err => {
|
|
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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 => {
|
|
if (editAfterImport.value) {
|
|
router.push({name: 'ModelEditPage', params: {id: recipe.id, model: 'recipe'}})
|
|
} else {
|
|
router.push({name: 'RecipeViewPage', 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.toLowerCase().includes(i.food.name.trim().toLowerCase()) && !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)
|
|
}
|
|
|
|
|
|
/**
|
|
* load or create an AccessToken with the bookmarklet scope for use in the bookmarklet code
|
|
*/
|
|
function loadOrCreateBookmarkletToken() {
|
|
let api = new ApiApi()
|
|
api.apiAccessTokenList().then(r => {
|
|
r.forEach(token => {
|
|
if (token.scope == 'bookmarklet') {
|
|
bookmarkletToken.value = token.token
|
|
}
|
|
})
|
|
|
|
if (bookmarkletToken.value == '') {
|
|
api.apiAccessTokenCreate({accessToken: {scope: 'bookmarklet', expires: DateTime.now().plus({year: 100}).toJSDate()} as AccessToken}).then(r => {
|
|
bookmarkletToken.value = r.token
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* reset the importer at any point
|
|
*/
|
|
function resetImporter() {
|
|
location.reload()
|
|
}
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
|
|
</style>
|