From 85aad425292da0e9e95a4de1b53761afc7ca9ec1 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 4 Apr 2022 21:09:47 +0200 Subject: [PATCH] added mela recipes importer --- cookbook/forms.py | 3 +- cookbook/helper/ingredient_parser.py | 10 +++- cookbook/helper/recipe_url_import.py | 36 +++--------- cookbook/integration/integration.py | 2 +- cookbook/integration/melarecipes.py | 83 ++++++++++++++++++++++++++++ cookbook/views/data.py | 16 +----- cookbook/views/import_export.py | 3 + docs/features/import_export.md | 8 +++ vue/src/utils/integration.js | 1 + 9 files changed, 118 insertions(+), 44 deletions(-) create mode 100644 cookbook/integration/melarecipes.py diff --git a/cookbook/forms.py b/cookbook/forms.py index caa129b51..b21eb6ce7 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -153,6 +153,7 @@ class ImportExportBase(forms.Form): RECIPESAGE = 'RECIPESAGE' DOMESTICA = 'DOMESTICA' MEALMASTER = 'MEALMASTER' + MELARECIPES = 'MELARECIPES' REZKONV = 'REZKONV' OPENEATS = 'OPENEATS' PLANTOEAT = 'PLANTOEAT' @@ -165,7 +166,7 @@ class ImportExportBase(forms.Form): (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'), (PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'), (MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'), - (PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), + (PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'), )) diff --git a/cookbook/helper/ingredient_parser.py b/cookbook/helper/ingredient_parser.py index 0b97dc6d8..4987e0b5b 100644 --- a/cookbook/helper/ingredient_parser.py +++ b/cookbook/helper/ingredient_parser.py @@ -208,6 +208,9 @@ class IngredientParser: note = '' unit_note = '' + if len(x) == 0: + raise ValueError('string to parse cannot be empty') + # if the string contains parenthesis early on remove it and place it at the end # because its likely some kind of note if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', x): @@ -271,4 +274,9 @@ class IngredientParser: if unit_note not in note: note += ' ' + unit_note - return amount, self.apply_unit_automation(unit.strip()), self.apply_food_automation(ingredient.strip()), note.strip() + try: + unit = self.apply_unit_automation(unit.strip()) + except Exception: + pass + + return amount, unit, self.apply_food_automation(ingredient.strip()), note.strip() diff --git a/cookbook/helper/recipe_url_import.py b/cookbook/helper/recipe_url_import.py index d86e23aa0..ae6fecc4e 100644 --- a/cookbook/helper/recipe_url_import.py +++ b/cookbook/helper/recipe_url_import.py @@ -255,40 +255,22 @@ def parse_servings(servings): return servings -def parse_cooktime(cooktime): - if type(cooktime) not in [int, float]: +def parse_time(recipe_time): + if type(recipe_time) not in [int, float]: try: - cooktime = float(re.search(r'\d+', cooktime).group()) + recipe_time = float(re.search(r'\d+', recipe_time).group()) except (ValueError, AttributeError): try: - cooktime = round(iso_parse_duration(cooktime).seconds / 60) + recipe_time = round(iso_parse_duration(recipe_time).seconds / 60) except ISO8601Error: try: - if (type(cooktime) == list and len(cooktime) > 0): - cooktime = cooktime[0] - cooktime = round(parse_duration(cooktime).seconds / 60) + if (type(recipe_time) == list and len(recipe_time) > 0): + recipe_time = recipe_time[0] + recipe_time = round(parse_duration(recipe_time).seconds / 60) except AttributeError: - cooktime = 0 + recipe_time = 0 - return cooktime - - -def parse_preptime(preptime): - if type(preptime) not in [int, float]: - try: - preptime = float(re.search(r'\d+', preptime).group()) - except ValueError: - try: - preptime = round(iso_parse_duration(preptime).seconds / 60) - except ISO8601Error: - try: - if (type(preptime) == list and len(preptime) > 0): - preptime = preptime[0] - preptime = round(parse_duration(preptime).seconds / 60) - except AttributeError: - preptime = 0 - - return preptime + return recipe_time def parse_keywords(keyword_json, space): diff --git a/cookbook/integration/integration.py b/cookbook/integration/integration.py index d1d82c57c..65ed00aef 100644 --- a/cookbook/integration/integration.py +++ b/cookbook/integration/integration.py @@ -172,7 +172,7 @@ class Integration: traceback.print_exc() self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n') import_zip.close() - elif '.json' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name'] or '.rk' in f['name']: + elif '.json' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name'] or '.rk' in f['name'] or '.melarecipe' in f['name']: data_list = self.split_recipe_file(f['file']) il.total_recipes += len(data_list) for d in data_list: diff --git a/cookbook/integration/melarecipes.py b/cookbook/integration/melarecipes.py new file mode 100644 index 000000000..88ff4d355 --- /dev/null +++ b/cookbook/integration/melarecipes.py @@ -0,0 +1,83 @@ +import base64 +import json +from io import BytesIO + +from gettext import gettext as _ +from cookbook.helper.ingredient_parser import IngredientParser +from cookbook.helper.recipe_url_import import parse_servings, parse_time +from cookbook.integration.integration import Integration +from cookbook.models import Ingredient, Keyword, Recipe, Step + + +class MelaRecipes(Integration): + + def split_recipe_file(self, file): + return [json.loads(file.getvalue().decode("utf-8"))] + + def get_files_from_recipes(self, recipes, el, cookie): + raise NotImplementedError('Method not implemented in storage integration') + + def get_recipe_from_file(self, file): + recipe_json = file + + recipe = Recipe.objects.create( + name=recipe_json['title'].strip(), + created_by=self.request.user, internal=True, space=self.request.space) + + if 'yield' in recipe_json: + recipe.servings = parse_servings(recipe_json['yield']) + + if 'cookTime' in recipe_json: + recipe.waiting_time = parse_time(recipe_json['cookTime']) + + if 'prepTime' in recipe_json: + recipe.working_time = parse_time(recipe_json['prepTime']) + + if 'favorite' in recipe_json and recipe_json['favorite']: + recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=_('Favorite'))[0]) + + if 'categories' in recipe_json: + try: + for x in recipe_json['categories']: + recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=x)[0]) + except Exception: + pass + + instruction = '' + if 'text' in recipe_json: + instruction += f'*{recipe_json["text"].strip()}* \n' + + if 'instructions' in recipe_json: + instruction += recipe_json["instructions"].strip() + ' \n' + + if 'notes' in recipe_json: + instruction += recipe_json["notes"].strip() + ' \n' + + if 'link' in recipe_json: + recipe.source_url = recipe_json['link'] + + step = Step.objects.create( + instruction=instruction, space=self.request.space, + ) + + ingredient_parser = IngredientParser(self.request, True) + for ingredient in recipe_json['ingredients'].split('\n'): + if ingredient.strip() != '': + amount, unit, food, note = ingredient_parser.parse(ingredient) + f = ingredient_parser.get_food(food) + u = ingredient_parser.get_unit(unit) + step.ingredients.add(Ingredient.objects.create( + food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space, + )) + recipe.steps.add(step) + + if recipe_json.get("images", None): + try: + self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['images'][0])), filetype='.jpeg') + except Exception: + pass + + return recipe + + def get_file_from_recipe(self, recipe): + raise NotImplementedError('Method not implemented in storage integration') diff --git a/cookbook/views/data.py b/cookbook/views/data.py index 70e7fcbfd..9980744b4 100644 --- a/cookbook/views/data.py +++ b/cookbook/views/data.py @@ -1,29 +1,17 @@ -import json -import uuid from datetime import datetime -from io import BytesIO -import requests from django.contrib import messages -from django.core.exceptions import ObjectDoesNotExist -from django.core.files import File -from django.db.transaction import atomic -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponseRedirect from django.shortcuts import redirect, render from django.urls import reverse from django.utils.translation import gettext as _ from django.utils.translation import ngettext from django_tables2 import RequestConfig -from PIL import UnidentifiedImageError -from requests.exceptions import MissingSchema from rest_framework.authtoken.models import Token from cookbook.forms import BatchEditForm, SyncForm -from cookbook.helper.image_processing import handle_image -from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.permission_helper import group_required, has_group_permission -from cookbook.helper.recipe_url_import import parse_cooktime -from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, RecipeImport, Step, Sync, +from cookbook.models import (Comment, Food, Keyword, Recipe, RecipeImport, Sync, Unit, UserPreference, BookmarkletImport) from cookbook.tables import SyncTable from recipes import settings diff --git a/cookbook/views/import_export.py b/cookbook/views/import_export.py index edc18525e..7875fa832 100644 --- a/cookbook/views/import_export.py +++ b/cookbook/views/import_export.py @@ -20,6 +20,7 @@ from cookbook.integration.default import Default from cookbook.integration.domestica import Domestica from cookbook.integration.mealie import Mealie from cookbook.integration.mealmaster import MealMaster +from cookbook.integration.melarecipes import MelaRecipes from cookbook.integration.nextcloud_cookbook import NextcloudCookbook from cookbook.integration.openeats import OpenEats from cookbook.integration.paprika import Paprika @@ -74,6 +75,8 @@ def get_integration(request, export_type): return CopyMeThat(request, export_type) if export_type == ImportExportBase.PDF: return PDFexport(request, export_type) + if export_type == ImportExportBase.MELARECIPES: + return MelaRecipes(request, export_type) @group_required('user') diff --git a/docs/features/import_export.md b/docs/features/import_export.md index 98424158d..18ead2820 100644 --- a/docs/features/import_export.md +++ b/docs/features/import_export.md @@ -38,6 +38,7 @@ Overview of the capabilities of the different integrations. | Plantoeat | ✔️ | ❌ | ✔ | | CookBookApp | ✔️ | ⌚ | ✔️ | | CopyMeThat | ✔️ | ❌ | ✔️ | +| CopyMeThat | ✔️ | ⌚ | ✔️ | | PDF (experimental) | ⌚️ | ✔️ | ✔️ | ✔️ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented @@ -225,6 +226,13 @@ CookBookApp can export .zip files containing .html files. Upload the entire ZIP CopyMeThat can export .zip files containing an `.html` file as well as a folder containing all the images. Upload the entire ZIP to Tandoor to import all included recipes. +## Melarecipes + +Melarecipes provides multiple export formats but only the `MelaRecipes` format can export the complete collection. +Perform this export and open the `.melarecipes` file using your favorite archive opening program (e.g 7zip). +Repeat this if the file contains another `.melarecipes` file until you get a list of one or many `.melarecipe` files. +Upload all `.melarecipe` files you want to import to tandoor and start the import. + ## PDF The PDF Exporter is an experimental feature that uses the puppeteer browser renderer to render each recipe and export it to PDF. diff --git a/vue/src/utils/integration.js b/vue/src/utils/integration.js index 326d0a30f..94ab94693 100644 --- a/vue/src/utils/integration.js +++ b/vue/src/utils/integration.js @@ -9,6 +9,7 @@ export const INTEGRATIONS = [ {id: 'DOMESTICA', name: "Domestica", import: true, export: false}, {id: 'MEALIE', name: "Mealie", import: true, export: false}, {id: 'MEALMASTER', name: "Mealmaster", import: true, export: false}, + {id: 'MELARECIPES', name: "Melarecipes", import: true, export: false}, {id: 'NEXTCLOUD', name: "Nextcloud Cookbook", import: true, export: false}, {id: 'OPENEATS', name: "Openeats", import: true, export: false}, {id: 'PAPRIKA', name: "Paprika", import: true, export: false},