basics of new import page

This commit is contained in:
vabene1111
2022-02-19 16:55:17 +01:00
parent 55b035eaaa
commit 89348f69f1
6 changed files with 462 additions and 115 deletions

View File

@@ -1,7 +1,270 @@
<template>
<div id="app">
Test
</div>
<div id="app">
<div>
<div class="row">
<div class="col col-md-12">
<h2>{{ $t('Import') }}</h2>
</div>
</div>
<div class="row">
<div class="col col-md-12">
<b-tabs content-class="mt-3">
<b-tab v-bind:title="$t('Website')" active>
<h6>Website</h6>
<b-input-group>
<b-input v-model="website_url" placeholder="Website URL"
@paste="loadRecipe()"></b-input>
<b-input-group-append>
<b-button variant="primary" @click="loadRecipe()"><i class="fas fa-search"></i>
</b-button>
</b-input-group-append>
</b-input-group>
<a href="#" @click="clearRecentImports()">Clear recent imports</a>
<ul>
<li v-for="x in recent_urls" v-bind:key="x">
<a href="#" @click="website_url=x; loadRecipe()">{{ x }}</a>
</li>
</ul>
<h6>Options</h6>
<!-- preview column -->
<div class="row">
<div class="col col-md-6" v-if="recipe_json !== undefined">
<div >
<!-- start of preview card -->
<div class="card card-border-primary">
<div class="card-header">
<h3>Recipe Preview</h3> <!-- TODO localize -->
<div class='small text-muted'>Drag recipe attributes from the
right into the appropriate box below.
</div>
</div>
<div class="card-body p-2">
<div class="card mb-2">
<div class="card-header" v-b-toggle.collapse-name>
<div class="row px-3" style="justify-content:space-between;">
Name
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.name=''"
title="{% trans 'Clear Contents' %}"></i>
</div>
<div class="small text-muted">Text dragged here will
be appended to the name.
</div>
</div>
<b-collapse id="collapse-name" visible class="mt-2">
<div class="card-body drop-zone"
@drop="replacePreview('name', $event)"
@dragover.prevent @dragenter.prevent>
<div class="card-text">{{recipe_json.name}}</div>
</div>
</b-collapse>
</div>
<div class="card mb-2">
<div class="card-header" v-b-toggle.collapse-description>
<div class="row px-3" style="justify-content:space-between;">
{% trans 'Description' %}
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.description=''"
title="{% trans 'Clear Contents' %}"></i>
</div>
<div class="small text-muted">{% trans 'Text dragged here will
be appended to the description.' %}
</div>
</div>
<b-collapse id="collapse-description" visible class="mt-2">
<div class="card-body drop-zone"
@drop="replacePreview('description', $event)"
@dragover.prevent @dragenter.prevent>
<div class="card-text">{{recipe_json.description}}</div>
</div>
</b-collapse>
</div>
<div class="card mb-2">
<div class="card-header" v-b-toggle.collapse-kw>
<div class="row px-3" style="justify-content:space-between;">
{% trans 'Keywords' %}
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.keywords=[]"
title="{% trans 'Clear Contents' %}"></i>
</div>
<div class="small text-muted">{% trans 'Keywords dragged here
will be appended to current list' %}
</div>
</div>
<b-collapse id="collapse-kw" visible class="mt-2">
<div class="card-body drop-zone"
@drop="replacePreview('keywords', $event)"
@dragover.prevent @dragenter.prevent>
<div v-for="kw in recipe_json.keywords" v-bind:key="kw.id">
<div class="card-text">{{ kw.text }}</div>
</div>
</div>
</b-collapse>
</div>
<div class="card mb-2">
<div class="card-header" v-b-toggle.collapse-image
style="display:flex; justify-content:space-between;">
{% trans 'Image' %}
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.image=''"
title="{% trans 'Clear Contents' %}"></i>
</div>
<b-collapse id="collapse-image" visible class="mt-2">
<div class="card-body m-0 p-0 drop-zone"
@drop="replacePreview('image', $event)"
@dragover.prevent @dragenter.prevent>
<img class="card-img" v-bind:src="recipe_json.image"
alt="Recipe Image">
</div>
</b-collapse>
</div>
<div class="row mb-2">
<div class="col">
<div class="card">
<div class="card-header p-1"
style="display:flex; justify-content:space-between;">
{% trans 'Servings' %}
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.servings=''"
title="{% trans 'Clear Contents' %}"></i>
</div>
<div class="card-body p-2 drop-zone"
@drop="replacePreview('servings', $event)"
@dragover.prevent @dragenter.prevent>
<div class="card-text">{{recipe_json.servings}}</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-header p-1"
style="display:flex; justify-content:space-between;">
{% trans 'Prep Time' %}
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.prepTime=''"
title="{% trans 'Clear Contents' %}"></i>
</div>
<div class="card-body p-2 drop-zone"
@drop="replacePreview('prepTime', $event)"
@dragover.prevent @dragenter.prevent>
<div class="card-text">{{recipe_json.prepTime}}</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-header p-1"
style="display:flex; justify-content:space-between;">
{% trans 'Cook Time' %}
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.cookTime=''"
title="{% trans 'Clear Contents' %}"></i>
</div>
<div class="card-body p-2 drop-zone"
@drop="replacePreview('cookTime', $event)"
@dragover.prevent @dragenter.prevent>
<div class="card-text">{{recipe_json.cookTime}}</div>
</div>
</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header" v-b-toggle.collapse-ing>
<div class="row px-3"
style="display:flex; justify-content:space-between;">
{% trans 'Ingredients' %}
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.recipeIngredient=[]"
title="{% trans 'Clear Contents' %}"></i>
</div>
<div class="small text-muted">{% trans 'Ingredients dragged here
will be appended to current list.' %}
</div>
</div>
<b-collapse id="collapse-ing" visible class="mt-2">
<div class="card-body drop-zone"
@drop="replacePreview('ingredients', $event)"
@dragover.prevent @dragenter.prevent>
<ul class="list-group list-group">
<div v-for="i in recipe_json.recipeIngredient" v-bind:key="i.id">
<li class="row border-light">
<div class="col-sm-1 border">{{i.amount}}</div>
<div class="col-sm border"> {{i.unit.text}}</div>
<div class="col-sm border">
{{i.ingredient.text}}
</div>
<div class="col-sm border">{{i.note}}</div>
</li>
</div>
</ul>
</div>
</b-collapse>
</div>
<div class="card mb-2">
<div class="card-header" v-b-toggle.collapse-instructions>
<div class="row px-3" style="justify-content:space-between;">
{% trans 'Instructions' %}
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.recipeInstructions=''"
title="{% trans 'Clear Contents' %}"></i>
</div>
<div class="small text-muted">{% trans 'Recipe instructions
dragged here will be appended to current instructions.' %}
</div>
</div>
<b-collapse id="collapse-instructions" visible class="mt-2">
<div class="card-body drop-zone"
@drop="replacePreview('instructions', $event)"
@dragover.prevent @dragenter.prevent>
<div class="card-text">{{recipe_json.recipeInstructions}}
</div>
</div>
</b-collapse>
</div>
</div>
</div>
<br/>
<!-- end of preview card -->
<button @click="showRecipe()" class="btn btn-primary shadow-none" type="button"
style="margin-bottom: 2vh"
id="id_btn_json"><i class="fas fa-code"></i> {% trans 'Import' %}
</button>
</div>
</div>
</div>
</b-tab>
<b-tab v-bind:title="$t('App')">
<!-- TODO implement app import -->
</b-tab>
<b-tab v-bind:title="$t('Source')">
<!-- TODO implement source import -->
</b-tab>
<b-tab v-bind:title="$t('Bookmarklet')">
<!-- TODO get code for bookmarklet here and provide some instructions -->
<a class="btn btn-outline-info btn-sm" href="#">
Bookmark Text </a>
</b-tab>
</b-tabs>
</div>
</div>
</div>
</div>
</template>
@@ -12,31 +275,94 @@ import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
import {resolveDjangoUrl, ResolveUrlMixin, StandardToasts, ToastMixin} from "@/utils/utils";
import axios from "axios";
Vue.use(BootstrapVue)
export default {
name: 'ImportView',
mixins: [
ResolveUrlMixin,
ToastMixin,
],
components: {
},
data() {
return {
name: 'ImportView',
mixins: [
ResolveUrlMixin,
ToastMixin,
],
components: {},
data() {
return {
LS_IMPORT_RECENT: 'import_recent_urls', //TODO use central helper to manage all local storage keys (and maybe even access)
website_url: '',
recent_urls: [],
source_data: '',
recipe_data: undefined,
recipe_json: undefined,
recipe_tree: undefined,
recipe_images: [],
automatic: true,
error: undefined,
loading: false,
preview: false,
}
},
mounted() {
let local_storage_recent = JSON.parse(window.localStorage.getItem(this.LS_IMPORT_RECENT))
this.recent_urls = local_storage_recent !== null ? local_storage_recent : []
},
methods: {
/**
* Requests the recipe to be loaded form the source (url/data) from the server
* Updates all variables to contain what they need to render either simple preview or manual mapping mode
*/
loadRecipe: function () {
console.log(this.website_url)
if (this.website_url !== '') {
if (this.recent_urls.length > 5) {
this.recent_urls.pop()
}
if (this.recent_urls.filter(x => x === this.website_url).length === 0) {
this.recent_urls.push(this.website_url)
}
window.localStorage.setItem(this.LS_IMPORT_RECENT, JSON.stringify(this.recent_urls))
}
this.recipe_data = undefined
this.recipe_json = undefined
this.recipe_tree = undefined
this.recipe_images = []
this.error = undefined
this.loading = true
this.preview = false
axios.post(resolveDjangoUrl('api_recipe_from_source'), {
'url': this.website_url,
'data': this.source_data,
'auto': this.automatic,
'mode': this.mode
},).then((response) => {
this.recipe_json = response.data['recipe_json'];
this.recipe_tree = response.data['recipe_tree'];
this.recipe_html = response.data['recipe_html'];
this.recipe_images = response.data['images']; //todo change on backend as well after old view is deprecated
if (this.automatic) {
this.recipe_data = this.recipe_json;
this.preview = false
} else {
this.preview = true
}
this.loading = false
}).catch((err) => {
this.error = err.data
this.loading = false
console.log(err.response)
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH, err.response.data.msg)
})
},
/**
* Clear list of recently imported recipe urls
*/
clearRecentImports: function () {
window.localStorage.setItem(this.LS_IMPORT_RECENT, JSON.stringify([]))
this.recent_urls = []
}
}
},
mounted() {
},
methods: {
}
}
</script>

View File

@@ -333,6 +333,9 @@
"ingredient_list": "Ingredient List",
"explain": "Explain",
"filter": "Filter",
"Website": "Website",
"App": "App",
"Bookmarklet": "Bookmarklet",
"search_no_recipes": "Could not find any recipes!",
"search_import_help_text": "Import a recipe from an external website or application.",
"search_create_help_text": "Create a new recipe directly in Tandoor.",

View File

@@ -0,0 +1,22 @@
// containing all data and functions regarding the different integrations
export const INTEGRATIONS = {
DEFAULT: {name: "Tandoor", import: true, export: true},
CHEFTAP: {name: "Cheftap", import: true, export: false},
CHOWDOWN: {name: "Chowdown", import: true, export: false},
COOKBOOKAPP: {name: "CookBookApp", import: true, export: false},
COPYMETHAT: {name: "CopyMeThat", import: true, export: false},
DOMESTICA: {name: "Domestica", import: true, export: false},
MEALIE: {name: "Mealie", import: true, export: false},
MEALMASTER: {name: "Mealmaster", import: true, export: false},
NEXTCLOUD: {name: "Nextcloud Cookbook", import: true, export: false},
OPENEATS: {name: "Openeats", import: true, export: false},
PAPRIKA: {name: "Paprika", import: true, export: false},
PEPPERPLATE: {name: "Pepperplate", import: true, export: false},
PLANTOEAT: {name: "Plantoeat", import: true, export: false},
RECETTETEK: {name: "RecetteTek", import: true, export: false},
RECIPEKEEPER: {name: "Recipekeeper", import: true, export: false},
RECIPESAGE: {name: "Recipesage", import: true, export: true},
REZKONV: {name: "Rezkonv", import: true, export: false},
SAFRON: {name: "Safron", import: true, export: true},
}

View File

@@ -2,18 +2,18 @@
* Utility functions to call bootstrap toasts
* */
import i18n from "@/i18n"
import { frac } from "@/utils/fractions"
import {frac} from "@/utils/fractions"
/*
* Utility functions to use OpenAPIs generically
* */
import { ApiApiFactory } from "@/utils/openapi/api.ts"
import {ApiApiFactory} from "@/utils/openapi/api.ts"
import axios from "axios"
import { BToast } from "bootstrap-vue"
import {BToast} from "bootstrap-vue"
// /*
// * Utility functions to use manipulate nested components
// * */
import Vue from "vue"
import { Actions, Models } from "./models"
import {Actions, Models} from "./models"
export const ToastMixin = {
name: "ToastMixin",
@@ -49,37 +49,37 @@ export class StandardToasts {
static FAIL_MOVE = "FAIL_MOVE"
static FAIL_MERGE = "FAIL_MERGE"
static makeStandardToast(toast, err_details = undefined) {
static makeStandardToast(toast, err_details = undefined) { //TODO err_details render very ugly, improve this maybe by using a custom toast component (in conjunction with error logging maybe)
switch (toast) {
case StandardToasts.SUCCESS_CREATE:
makeToast(i18n.tc("Success"), i18n.tc("success_creating_resource"), "success")
makeToast(i18n.tc("Success"), i18n.tc("success_creating_resource") + (err_details ? "\n" + err_details : ""), "success")
break
case StandardToasts.SUCCESS_FETCH:
makeToast(i18n.tc("Success"), i18n.tc("success_fetching_resource"), "success")
makeToast(i18n.tc("Success"), i18n.tc("success_fetching_resource") + (err_details ? "\n" + err_details : ""), "success")
break
case StandardToasts.SUCCESS_UPDATE:
makeToast(i18n.tc("Success"), i18n.tc("success_updating_resource"), "success")
makeToast(i18n.tc("Success"), i18n.tc("success_updating_resource") + (err_details ? "\n" + err_details : ""), "success")
break
case StandardToasts.SUCCESS_DELETE:
makeToast(i18n.tc("Success"), i18n.tc("success_deleting_resource"), "success")
makeToast(i18n.tc("Success"), i18n.tc("success_deleting_resource") + (err_details ? "\n" + err_details : ""), "success")
break
case StandardToasts.SUCCESS_MOVE:
makeToast(i18n.tc("Success"), i18n.tc("success_moving_resource"), "success")
makeToast(i18n.tc("Success"), i18n.tc("success_moving_resource") + (err_details ? "\n" + err_details : ""), "success")
break
case StandardToasts.SUCCESS_MERGE:
makeToast(i18n.tc("Success"), i18n.tc("success_merging_resource"), "success")
makeToast(i18n.tc("Success"), i18n.tc("success_merging_resource") + (err_details ? "\n" + err_details : ""), "success")
break
case StandardToasts.FAIL_CREATE:
makeToast(i18n.tc("Failure"), i18n.tc("err_creating_resource"), "danger")
makeToast(i18n.tc("Failure"), i18n.tc("err_creating_resource") + (err_details ? "\n" + err_details : ""), "danger")
break
case StandardToasts.FAIL_FETCH:
makeToast(i18n.tc("Failure"), i18n.tc("err_fetching_resource"), "danger")
makeToast(i18n.tc("Failure"), i18n.tc("err_fetching_resource") + (err_details ? "\n" + err_details : ""), "danger")
break
case StandardToasts.FAIL_UPDATE:
makeToast(i18n.tc("Failure"), i18n.tc("err_updating_resource"), "danger")
makeToast(i18n.tc("Failure"), i18n.tc("err_updating_resource") + (err_details ? "\n" + err_details : ""), "danger")
break
case StandardToasts.FAIL_DELETE:
makeToast(i18n.tc("Failure"), i18n.tc("err_deleting_resource"), "danger")
makeToast(i18n.tc("Failure"), i18n.tc("err_deleting_resource") + (err_details ? "\n" + err_details : ""), "danger")
break
case StandardToasts.FAIL_MOVE:
makeToast(i18n.tc("Failure"), i18n.tc("err_moving_resource") + (err_details ? "\n" + err_details : ""), "danger")
@@ -234,7 +234,7 @@ export const ApiMixin = {
return apiClient[func](...parameters)
},
genericGetAPI: function (url, options) {
return axios.get(resolveDjangoUrl(url), { params: options, emulateJSON: true })
return axios.get(resolveDjangoUrl(url), {params: options, emulateJSON: true})
},
genericPostAPI: function (url, form) {
let data = new FormData()
@@ -284,6 +284,7 @@ function formatParam(config, value, options) {
}
return value
}
function buildParams(options, setup) {
let config = setup?.config ?? {}
let params = setup?.params ?? []
@@ -311,6 +312,7 @@ function buildParams(options, setup) {
})
return parameters
}
function getDefault(config, options) {
let value = undefined
value = config?.default ?? undefined
@@ -338,11 +340,12 @@ function getDefault(config, options) {
}
return value
}
export function getConfig(model, action) {
let f = action.function
// if not defined partialUpdate will use params from create
if (f === "partialUpdate" && !model?.[f]?.params) {
model[f] = { params: [...["id"], ...model.create.params] }
model[f] = {params: [...["id"], ...model.create.params]}
}
let config = {
@@ -350,12 +353,12 @@ export function getConfig(model, action) {
apiName: model.apiName,
}
// spread operator merges dictionaries - last item in list takes precedence
config = { ...config, ...action, ...model.model_type?.[f], ...model?.[f] }
config = {...config, ...action, ...model.model_type?.[f], ...model?.[f]}
// nested dictionaries are not merged - so merge again on any nested keys
config.config = { ...action?.config, ...model.model_type?.[f]?.config, ...model?.[f]?.config }
config.config = {...action?.config, ...model.model_type?.[f]?.config, ...model?.[f]?.config}
// look in partialUpdate again if necessary
if (f === "partialUpdate" && Object.keys(config.config).length === 0) {
config.config = { ...model.model_type?.create?.config, ...model?.create?.config }
config.config = {...model.model_type?.create?.config, ...model?.create?.config}
}
config["function"] = f + config.apiName + (config?.suffix ?? "") // parens are required to force optional chaining to evaluate before concat
return config
@@ -366,17 +369,17 @@ export function getConfig(model, action) {
// * */
export function getForm(model, action, item1, item2) {
let f = action.function
let config = { ...action?.form, ...model.model_type?.[f]?.form, ...model?.[f]?.form }
let config = {...action?.form, ...model.model_type?.[f]?.form, ...model?.[f]?.form}
// if not defined partialUpdate will use form from create
if (f === "partialUpdate" && Object.keys(config).length == 0) {
config = { ...Actions.CREATE?.form, ...model.model_type?.["create"]?.form, ...model?.["create"]?.form }
config["title"] = { ...action?.form_title, ...model.model_type?.[f]?.form_title, ...model?.[f]?.form_title }
config = {...Actions.CREATE?.form, ...model.model_type?.["create"]?.form, ...model?.["create"]?.form}
config["title"] = {...action?.form_title, ...model.model_type?.[f]?.form_title, ...model?.[f]?.form_title}
// form functions should not be inherited
if (config?.["form_function"]?.includes("Create")) {
delete config["form_function"]
}
}
let form = { fields: [] }
let form = {fields: []}
let value = ""
for (const [k, v] of Object.entries(config)) {
if (v?.function) {
@@ -404,6 +407,7 @@ export function getForm(model, action, item1, item2) {
}
return form
}
function formTranslate(translate, model, item1, item2) {
if (typeof translate !== "object") {
return translate
@@ -495,7 +499,7 @@ const specialCases = {
let params = []
if (action.function === "partialUpdate") {
API = GenericAPI
params = [Models.SUPERMARKET, Actions.FETCH, { id: options.id }]
params = [Models.SUPERMARKET, Actions.FETCH, {id: options.id}]
} else if (action.function === "create") {
API = new ApiApiFactory()[setup.function]
params = buildParams(options, setup)
@@ -532,15 +536,15 @@ const specialCases = {
let order = Math.max(...existing_categories.map((x) => x?.order ?? 0), ...updated_categories.map((x) => x?.order ?? 0), 0) + 1
removed_categories.forEach((x) => {
promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.DELETE, { id: x.id }))
promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.DELETE, {id: x.id}))
})
let item = { supermarket: id }
let item = {supermarket: id}
added_categories.forEach((x) => {
item.order = x?.order ?? order
if (!x?.order) {
order = order + 1
}
item.category = { id: x.category.id, name: x.category.name }
item.category = {id: x.category.id, name: x.category.name}
promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.CREATE, item))
})
changed_categories.forEach((x) => {
@@ -549,13 +553,13 @@ const specialCases = {
if (!x?.order) {
order = order + 1
}
item.category = { id: x.category.id, name: x.category.name }
item.category = {id: x.category.id, name: x.category.name}
promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.UPDATE, item))
})
return Promise.all(promises).then(() => {
// finally get and return the Supermarket which everything downstream is expecting
return GenericAPI(Models.SUPERMARKET, Actions.FETCH, { id: id })
return GenericAPI(Models.SUPERMARKET, Actions.FETCH, {id: id})
})
})
},