From 89348f69f146915253cba2ee50fc08290c84ebbf Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Sat, 19 Feb 2022 16:55:17 +0100 Subject: [PATCH] basics of new import page --- cookbook/models.py | 8 +- cookbook/views/api.py | 110 ++++---- vue/src/apps/ImportView/ImportView.vue | 370 +++++++++++++++++++++++-- vue/src/locales/en.json | 3 + vue/src/utils/integration.js | 22 ++ vue/src/utils/utils.js | 64 +++-- 6 files changed, 462 insertions(+), 115 deletions(-) create mode 100644 vue/src/utils/integration.js diff --git a/cookbook/models.py b/cookbook/models.py index 67679a897..8f3041e9b 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -591,6 +591,8 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss no_amount = models.BooleanField(default=False) order = models.IntegerField(default=0) + original_text = models.CharField(max_length=512, null=True, blank=True, default=None) + space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space') @@ -673,9 +675,9 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel working_time = models.IntegerField(default=0) waiting_time = models.IntegerField(default=0) internal = models.BooleanField(default=False) - nutrition = models.ForeignKey( - NutritionInformation, blank=True, null=True, on_delete=models.CASCADE - ) + nutrition = models.ForeignKey( NutritionInformation, blank=True, null=True, on_delete=models.CASCADE ) + + source_url = models.CharField(max_length=1024, default=None, blank=True, null=True) created_by = models.ForeignKey(User, on_delete=models.PROTECT) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 872489787..55ba8f469 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -926,6 +926,7 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin): space=self.request.space).distinct() return super().get_queryset() + # -------------- non django rest api views -------------------- @@ -1063,45 +1064,45 @@ def get_plan_ical(request, from_date, to_date): @group_required('user') def recipe_from_source(request): - url = request.POST.get('url', None) - data = request.POST.get('data', None) - mode = request.POST.get('mode', None) - auto = request.POST.get('auto', 'true') + """ + function to retrieve a recipe from a given url or source string + :param request: standard request with additional post parameters + - url: url to use for importing recipe + - 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 + :return: + """ + request_payload = json.loads(request.body.decode('utf-8')) + url = request_payload.get('url', None) + data = request_payload.get('data', None) + auto = True if request_payload.get('auto', 'true') == 'true' else False - HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.0.7) Gecko/2009021910 Firefox/3.0.7" - } + # headers to use for request to external sites + external_request_headers = {"User-Agent": "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.0.7) Gecko/2009021910 Firefox/3.0.7"} - if (not url and not data) or (mode == 'url' and not url) or (mode == 'source' and not data): - return JsonResponse( - { - 'error': True, - 'msg': _('Nothing to do.') - }, - status=400 - ) + if not url and not data: + return JsonResponse({ + 'error': True, + 'msg': _('Nothing to do.') + }, status=400) - if mode == 'url' and auto == 'true': + # in auto mode scrape url directly with recipe scrapers library + if url and auto: try: scrape = scrape_me(url) except (WebsiteNotImplementedError, AttributeError): 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.') # noqa: E501 - }, - status=400) - except ConnectionError: - return JsonResponse( - { + return JsonResponse({ 'error': True, - 'msg': _('The requested page could not be found.') - }, - status=400 - ) + 'msg': _('The requested site provided malformed data and cannot be read.') + }, status=400) + except ConnectionError: + return JsonResponse({ + 'error': True, + 'msg': _('The requested page could not be found.') + }, status=400) try: instructions = scrape.instructions() @@ -1111,38 +1112,30 @@ def recipe_from_source(request): 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.') - # noqa: E501 - }, - status=400) + 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 (mode == 'source') or (mode == 'url' and auto == 'false'): + 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=HEADERS).content + data = requests.get(url, headers=external_request_headers).content except requests.exceptions.ConnectionError: - return JsonResponse( - { - 'error': True, - 'msg': _('Connection Refused.') - }, - status=400 - ) + return JsonResponse({ + 'error': True, + 'msg': _('Connection Refused.') + }, status=400) recipe_json, recipe_tree, recipe_html, 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 - ) + return JsonResponse({ + 'error': True, + 'msg': _('No usable data could be found.') + }, status=400) else: return JsonResponse({ 'recipe_tree': recipe_tree, @@ -1152,13 +1145,10 @@ def recipe_from_source(request): }) else: - return JsonResponse( - { - 'error': True, - 'msg': _('I couldn\'t find anything to do.') - }, - status=400 - ) + return JsonResponse({ + 'error': True, + 'msg': _('I couldn\'t find anything to do.') + }, status=400) @group_required('admin') diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue index 8761aefe4..3dccc870f 100644 --- a/vue/src/apps/ImportView/ImportView.vue +++ b/vue/src/apps/ImportView/ImportView.vue @@ -1,7 +1,270 @@ @@ -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: { - - } } diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index b70844ab8..31fc6e763 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -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.", diff --git a/vue/src/utils/integration.js b/vue/src/utils/integration.js new file mode 100644 index 000000000..5cdedbaea --- /dev/null +++ b/vue/src/utils/integration.js @@ -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}, +} diff --git a/vue/src/utils/utils.js b/vue/src/utils/utils.js index f03024ded..b5626b8be 100644 --- a/vue/src/utils/utils.js +++ b/vue/src/utils/utils.js @@ -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}) }) }) },