super very basics of manual mapping page working

This commit is contained in:
vabene1111
2022-03-05 16:02:42 +01:00
parent 2565ab30a4
commit 22ca482458
5 changed files with 443 additions and 98 deletions

View File

@ -1084,7 +1084,6 @@ def recipe_from_source(request):
:param request: standard request with additional post parameters :param request: standard request with additional post parameters
- url: url to use for importing recipe - url: url to use for importing recipe
- data: if no url is given recipe is imported from provided source data - data: if no url is given recipe is imported from provided source data
- auto: true to return just the recipe as json, false to return source json, html and images as well
- (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes
:return: JsonResponse containing the parsed json, original html,json and images :return: JsonResponse containing the parsed json, original html,json and images
""" """
@ -1092,7 +1091,6 @@ def recipe_from_source(request):
url = request_payload.get('url', None) url = request_payload.get('url', None)
data = request_payload.get('data', None) data = request_payload.get('data', None)
bookmarklet = request_payload.get('bookmarklet', None) bookmarklet = request_payload.get('bookmarklet', None)
auto = True if request_payload.get('auto', 'true') == 'true' else False
if bookmarklet := BookmarkletImport.objects.filter(pk=bookmarklet).first(): if bookmarklet := BookmarkletImport.objects.filter(pk=bookmarklet).first():
url = bookmarklet.url url = bookmarklet.url
@ -1108,69 +1106,28 @@ def recipe_from_source(request):
'msg': _('Nothing to do.') 'msg': _('Nothing to do.')
}, status=400) }, status=400)
# in auto mode scrape url directly with recipe scrapers library # in manual mode request complete page to return it later
if url and auto: if url:
try: try:
scrape = scrape_me(url) data = requests.get(url, headers=external_request_headers).content
except (WebsiteNotImplementedError, AttributeError): except requests.exceptions.ConnectionError:
try:
scrape = scrape_me(url, wild_mode=True)
except NoSchemaFoundInWildMode:
return JsonResponse({
'error': True,
'msg': _('The requested site provided malformed data and cannot be read.')
}, status=400)
except ConnectionError:
return JsonResponse({ return JsonResponse({
'error': True, 'error': True,
'msg': _('The requested page could not be found.') 'msg': _('Connection Refused.')
}, status=400) }, status=400)
recipe_json, recipe_tree, recipe_html, recipe_images = get_recipe_from_source(data, url, request)
try: if len(recipe_tree) == 0 and len(recipe_json) == 0:
instructions = scrape.instructions()
except Exception:
instructions = ""
try:
ingredients = scrape.ingredients()
except Exception:
ingredients = []
if len(ingredients) + len(instructions) == 0:
return JsonResponse({
'error': True,
'msg': _('The requested site does not provide any recognized data format to import the recipe from.')
}, status=400)
else:
return JsonResponse({"recipe_json": get_from_scraper(scrape, request)})
elif data or (url and not auto):
# in manual mode request complete page to return it later
if not data or data == 'undefined':
try:
data = requests.get(url, headers=external_request_headers).content
except requests.exceptions.ConnectionError:
return JsonResponse({
'error': True,
'msg': _('Connection Refused.')
}, status=400)
recipe_json, recipe_tree, recipe_html, recipe_images = get_recipe_from_source(data, url, request)
if len(recipe_tree) == 0 and len(recipe_json) == 0:
return JsonResponse({
'error': True,
'msg': _('No usable data could be found.')
}, status=400)
else:
return JsonResponse({
'recipe_json': recipe_json,
'recipe_tree': recipe_tree,
'recipe_html': recipe_html,
'recipe_images': recipe_images,
})
else:
return JsonResponse({ return JsonResponse({
'error': True, 'error': True,
'msg': _('I couldn\'t find anything to do.') 'msg': _('No usable data could be found.')
}, status=400) }, status=400)
else:
return JsonResponse({
'recipe_json': recipe_json,
'recipe_tree': recipe_tree,
'recipe_html': recipe_html,
'recipe_images': recipe_images,
})
@group_required('admin') @group_required('admin')

View File

@ -27,6 +27,7 @@
"vue-cookies": "^1.7.4", "vue-cookies": "^1.7.4",
"vue-i18n": "^8.26.8", "vue-i18n": "^8.26.8",
"vue-infinite-loading": "^2.4.5", "vue-infinite-loading": "^2.4.5",
"vue-jstree": "^2.1.6",
"vue-multiselect": "^2.1.6", "vue-multiselect": "^2.1.6",
"vue-property-decorator": "^9.1.2", "vue-property-decorator": "^9.1.2",
"vue-simple-calendar": "^5.0.1", "vue-simple-calendar": "^5.0.1",

View File

@ -121,8 +121,10 @@
</b-card-header> </b-card-header>
<b-collapse id="id_accordion_advanced_options" visible accordion="url_import_accordion" <b-collapse id="id_accordion_advanced_options" visible accordion="url_import_accordion"
role="tabpanel" v-model="collapse_visible.advanced_options"> role="tabpanel" v-model="collapse_visible.advanced_options">
<b-card-body> <b-card-body v-if="recipe_json !== undefined">
<import-view-advanced-mapping :recipe="recipe_json" :recipe_tree="recipe_tree" :recipe_images="recipe_images" :recipe_html="recipe_html"
@change="recipe_json = $event"></import-view-advanced-mapping>
</b-card-body> </b-card-body>
</b-collapse> </b-collapse>
@ -224,6 +226,7 @@ import {ApiApiFactory} from "@/utils/openapi/api";
import draggable from "vuedraggable"; import draggable from "vuedraggable";
import {INTEGRATIONS} from "@/utils/integration"; import {INTEGRATIONS} from "@/utils/integration";
import ImportViewStepEditor from "@/apps/ImportView/ImportViewStepEditor"; import ImportViewStepEditor from "@/apps/ImportView/ImportViewStepEditor";
import ImportViewAdvancedMapping from "@/apps/ImportView/ImportViewAdvancedMapping";
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
@ -234,8 +237,9 @@ export default {
ToastMixin, ToastMixin,
], ],
components: { components: {
ImportViewAdvancedMapping,
ImportViewStepEditor, ImportViewStepEditor,
draggable draggable,
}, },
data() { data() {
return { return {
@ -252,7 +256,7 @@ export default {
recent_urls: [], recent_urls: [],
source_data: '', source_data: '',
recipe_json: undefined, recipe_json: undefined,
recipe_data: undefined, recipe_html: undefined,
recipe_tree: undefined, recipe_tree: undefined,
recipe_images: [], recipe_images: [],
// App Import // App Import
@ -269,7 +273,7 @@ export default {
this.recent_urls = local_storage_recent !== null ? local_storage_recent : [] this.recent_urls = local_storage_recent !== null ? local_storage_recent : []
this.tab_index = 0 //TODO add ability to pass open tab via get parameter this.tab_index = 0 //TODO add ability to pass open tab via get parameter
if (window.BOOKMARKLET_IMPORT_ID !== -1){ if (window.BOOKMARKLET_IMPORT_ID !== -1) {
this.loadRecipe(window.BOOKMARKLET_IMPORT_ID) this.loadRecipe(window.BOOKMARKLET_IMPORT_ID)
} }
}, },
@ -332,7 +336,7 @@ export default {
} }
// reset all variables // reset all variables
this.recipe_data = undefined this.recipe_html = undefined
this.recipe_json = undefined this.recipe_json = undefined
this.recipe_tree = undefined this.recipe_tree = undefined
this.recipe_images = [] this.recipe_images = []
@ -341,11 +345,9 @@ export default {
let payload = { let payload = {
'url': this.website_url, 'url': this.website_url,
'data': this.source_data, 'data': this.source_data,
'auto': this.automatic,
'mode': this.mode
} }
if (bookmarklet !== undefined){ if (bookmarklet !== undefined) {
payload['bookmarklet'] = bookmarklet payload['bookmarklet'] = bookmarklet
} }
@ -365,38 +367,6 @@ export default {
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH, err.response.data.msg) StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH, err.response.data.msg)
}) })
}, },
/**
* 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
*/
loadBookmarkletRecipe: function () {
this.recipe_data = undefined
this.recipe_json = undefined
this.recipe_tree = undefined
this.recipe_images = []
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.$set(this.recipe_json, 'unused_keywords', this.recipe_json.keywords.filter(k => k.id === undefined))
this.$set(this.recipe_json, 'keywords', this.recipe_json.keywords.filter(k => k.id !== undefined))
this.recipe_tree = response.data['recipe_tree'];
this.recipe_html = response.data['recipe_html'];
this.recipe_images = response.data['recipe_images'] !== undefined ? response.data['recipe_images'] : [];
this.tab_index = 0 // open tab 0 with import wizard
this.collapse_visible.options = true // open options collapse
}).catch((err) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH, err.response.data.msg)
})
},
/** /**
* Import recipes with uploaded files and app integration * Import recipes with uploaded files and app integration
*/ */
@ -421,6 +391,10 @@ export default {
window.localStorage.setItem(this.LS_IMPORT_RECENT, JSON.stringify([])) window.localStorage.setItem(this.LS_IMPORT_RECENT, JSON.stringify([]))
this.recent_urls = [] this.recent_urls = []
}, },
/**
* Create the code required for the bookmarklet
* @returns {string} javascript:// protocol code to be loaded into href attribute of link that can be bookmarked
*/
makeBookmarklet: function () { makeBookmarklet: function () {
return 'javascript:(function(){' + return 'javascript:(function(){' +
'if(window.bookmarkletTandoor!==undefined){' + 'if(window.bookmarkletTandoor!==undefined){' +

View File

@ -0,0 +1,408 @@
<template>
<div>
<!-- recipe preview before Import -->
<div class="container-fluid" v-if="recipe_json" id="manage_tree">
<div class="row">
<div class="col" style="max-width:50%">
<!-- start of preview card -->
<div class="card card-border-primary">
<div class="card-header">
<h3>Preview Recipe Data</h3>
<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="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;">
Description
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.description=''" title="Clear Contents"></i>
</div>
<div class="small text-muted">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;">
Keywords
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.keywords=[]" title="Clear Contents"></i>
</div>
<div class="small text-muted">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.name">
<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;">
Image
<i class="fas fa-eraser" style="cursor:pointer;" @click="recipe_json.image=''"
title="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;">
Servings
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.servings=''"
title="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;">
Prep Time
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.working_time=''"
title="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.working_time }}</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-header p-1"
style="display:flex; justify-content:space-between;">
Cook Time
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.waiting_time=''"
title="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.waiting_time }}</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;">
Ingredients
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.recipeIngredient=[]"
title="Clear Contents"></i>
</div>
<div class="small text-muted">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.note">
<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;">
Instructions
<i class="fas fa-eraser" style="cursor:pointer;"
@click="recipe_json.recipeInstructions=''"
title="Clear Contents"></i>
</div>
<div class="small text-muted">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 class="btn btn-primary shadow-none" type="button"
style="margin-bottom: 2vh"
id="id_btn_json"><i class="fas fa-code"></i> Import
</button>
</div>
<!-- start of source data -->
<div class="col" style="max-width:50%;">
<div class="card card-border-primary sticky-top" style="z-index: 100;">
<div class="card-header">
<h3>Discovered Attributes</h3>
<div class='small text-muted'>
Drag recipe attributes from below into the appropriate box on the left. Click
any node to display its full properties.
</div>
</div>
<div class="btn-group btn-group-toggle" data-toggle="buttons">
<label class="btn btn-outline-info btn-sm active" @click="preview_type='json'">
<input type="radio" autocomplete="off" checked> json
</label>
<label class="btn btn-outline-info btn-sm" @click="preview_type='html'">
<input type="radio" autocomplete="off"> html
</label>
</div>
<i :class="[show_blank ? 'fa-chevron-up' : 'fa-chevron-down', 'fas']"
style="cursor:pointer;"
@click="show_blank=!show_blank"
title="Show Blank Field"></i>
<div class="card-body p-1">
<div class="card card-border-primary" v-if="show_blank">
<div class="card-header">
<div class="row px-3" style="justify-content:space-between;">
Blank Field
<i class="fas fa-eraser justify-content-end" style="cursor:pointer;"
@click="blank_field=''" title="Clear Contents"></i>
</div>
<div class="small text-muted">Items dragged to Blank Field will be
appended.
</div>
</div>
<div class="card-body drop-zone"
@drop="replacePreview('blank', $event)"
@dragover.prevent
@dragenter.prevent
draggable
@dragstart="htmlDragStart($event)">
{{ blank_field }}
</div>
</div>
<!-- start of json data -->
<!-- eslint-disable vue/no-deprecated-scope-attribute -->
<v-jstree v-if="preview_type=='json'" :data="recipe_tree"
text-field-name="name"
collapse:true
draggable
@item-drag-start="itemDragStart"
@item-click="itemClick">
<template scope="_">
<div class="col" @click.ctrl="customItemClickWithCtrl">
<div class="row clearfix" style="width:100%">
<div class="col-es" style="align-right">
<button
style="border: 0px; background-color: transparent; cursor: pointer;"
@click="deleteNode(_.vm, _.model, $event)"><i
class="fas fa-minus-square" style="color:red"></i></button>
</div>
<div class="col overflow-hidden">
<i :class="_.vm.themeIconClasses" role="presentation"
v-if="!_.model.loading"></i>
{{ _.model.name }}
</div>
</div>
</div>
</template>
</v-jstree>
<!-- eslint-disable vue/no-deprecated-scope-attribute -->
<!-- start of html data -->
<div v-if="preview_type=='html'">
<ul class="list-group list-group-flush" v-for="(txt, key) in recipe_html"
v-bind:key="key">
<div class="list-group-item bg-light m-0 small"
draggable
@dragstart="htmlDragStart($event)"
style="display:flex; justify-content:space-between;">
{{ txt }}
<i class="fas fa-minus-square" style="cursor:pointer; color:red"
@click="$delete(recipe_html, key)" title="Delete Text"></i>
</div>
</ul>
</div>
</div>
</div>
</div>
<!-- end of json tree -->
</div>
</div>
<!-- end of recipe preview before Import -->
</div>
</template>
<script>
import {StandardToasts} from "@/utils/utils";
import VJstree from 'vue-jstree'
export default {
name: "ImportViewAdvancedMapping",
components: {
VJstree,
},
props: {
recipe: undefined,
recipe_html: undefined,
recipe_tree: undefined,
recipe_images: undefined,
},
data() {
return {
recipe_json: undefined,
preview_type: 'json',
show_blank: false,
blank_field: '',
}
},
watch: {
recipe_json: function () {
this.$emit('change', this.recipe_json)
},
},
mounted() {
this.recipe_json = this.recipe //TODO check if changed not only if mounted, same for step editor
},
methods: {
deleteNode: function (node, item, e) {
e.stopPropagation()
var index = node.parentItem.indexOf(item)
node.parentItem.splice(index, 1)
},
itemClick: function (node, item, e) {
this.makeToast('Details', node.model.value, 'info')
},
itemDragStart(node, item, e) {
if (node.model.children.length > 0) {
e.dataTransfer.setData('hasChildren', true)
}
e.dataTransfer.setData('value', node.model.value)
},
htmlDragStart: function (e) {
e.dataTransfer.setData('value', e.target.innerText)
},
imageDragStart: function (e) {
e.dataTransfer.setData('value', e.target.src)
},
replacePreview: function (field, e) {
let v = e.dataTransfer.getData('value')
if (e.dataTransfer.getData('hasChildren')) {
this.makeToast('Error', 'Items with children cannot be dropped here!', 'danger')
return
}
switch (field) {
case 'name':
this.recipe_json.name = [this.recipe_json.name, v].filter(Boolean).join(" ");
break;
case 'description':
this.recipe_json.description = [this.recipe_json.description, v].filter(Boolean).join(" ");
break;
case 'image':
this.recipe_json.image = v
break;
case 'keywords':
this.recipe_json.keywords.push({'text': v, 'id': null})
break;
case 'servings':
this.recipe_json.servings = parseInt(v.match(/\b\d+\b/)[0])
break;
case 'prepTime':
this.recipe_json.prepTime = v
break;
case 'cookTime':
this.recipe_json.cookTime = v
break;
case 'ingredients':
this.$http.post('string_from_ingredients', {text: v}, {emulateJSON: true}
).then((response) => {
let new_ingredient = {
unit: {id: Math.random() * 1000, text: response.body.unit},
amount: String(response.body.amount),
ingredient: {id: Math.random() * 1000, text: response.body.food},
note: response.body.note,
original_text: v
}
this.recipe_json.recipeIngredient.push(new_ingredient)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
break;
case 'instructions':
this.recipe_json.recipeInstructions = [this.recipe_json.recipeInstructions, v].filter(Boolean).join("\n\n");
break;
case 'blank':
this.blank_field = [this.blank_field, v].filter(Boolean).join(" ");
break;
}
},
}
}
</script>
<style scoped>
</style>

View File

@ -10612,6 +10612,11 @@ vue-infinite-loading@^2.4.5:
resolved "https://registry.yarnpkg.com/vue-infinite-loading/-/vue-infinite-loading-2.4.5.tgz#cc20fd40af7f20188006443c99b60470cf1de1b3" resolved "https://registry.yarnpkg.com/vue-infinite-loading/-/vue-infinite-loading-2.4.5.tgz#cc20fd40af7f20188006443c99b60470cf1de1b3"
integrity sha512-xhq95Mxun060bRnsOoLE2Be6BR7jYwuC89kDe18+GmCLVrRA/dU0jrGb12Xu6NjmKs+iTW0AA6saSEmEW4cR7g== integrity sha512-xhq95Mxun060bRnsOoLE2Be6BR7jYwuC89kDe18+GmCLVrRA/dU0jrGb12Xu6NjmKs+iTW0AA6saSEmEW4cR7g==
vue-jstree@^2.1.6:
version "2.1.6"
resolved "https://registry.yarnpkg.com/vue-jstree/-/vue-jstree-2.1.6.tgz#44827ad72953ed77da6590ce4e8f37f7787f8653"
integrity sha512-vtUmhLbfE2JvcnYNRXauJPkNJSRO/f9BTsbxV+ESXP/mMQPVUIYI4EkSHKSEOxVDHTU7SfLp/AxplmaAl6ctcg==
"vue-loader-v16@npm:vue-loader@^16.1.0": "vue-loader-v16@npm:vue-loader@^16.1.0":
version "16.8.3" version "16.8.3"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-16.8.3.tgz#d43e675def5ba9345d6c7f05914c13d861997087" resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-16.8.3.tgz#d43e675def5ba9345d6c7f05914c13d861997087"