Merge branch 'feature/importer_to_vue' into develop

# Conflicts:
#	vue/src/apps/RecipeView/RecipeView.vue
This commit is contained in:
vabene1111
2022-04-22 21:07:02 +02:00
40 changed files with 1512 additions and 1532 deletions

View File

@@ -155,11 +155,13 @@ class ImportExportBase(forms.Form):
RECIPESAGE = 'RECIPESAGE'
DOMESTICA = 'DOMESTICA'
MEALMASTER = 'MEALMASTER'
MELARECIPES = 'MELARECIPES'
REZKONV = 'REZKONV'
OPENEATS = 'OPENEATS'
PLANTOEAT = 'PLANTOEAT'
COOKBOOKAPP = 'COOKBOOKAPP'
COPYMETHAT = 'COPYMETHAT'
COOKMATE = 'COOKMATE'
PDF = 'PDF'
type = forms.ChoiceField(choices=(
@@ -167,7 +169,8 @@ 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'),
(COOKMATE, 'Cookmate')
))

View File

@@ -203,11 +203,14 @@ class IngredientParser:
def parse(self, x):
# initialize default values
amount = 0
unit = ''
unit = None
ingredient = ''
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()

View File

@@ -1,6 +1,3 @@
"""
Source: https://djangosnippets.org/snippets/1703/
"""
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test
@@ -12,7 +9,7 @@ from django.utils.translation import gettext as _
from rest_framework import permissions
from rest_framework.permissions import SAFE_METHODS
from cookbook.models import ShareLink
from cookbook.models import ShareLink, Recipe, UserPreference
def get_allowed_groups(groups_required):
@@ -262,3 +259,38 @@ class CustomIsShare(permissions.BasePermission):
if share:
return share_link_valid(obj, share)
return False
def above_space_limit(space): # TODO add file storage limit
"""
Test if the space has reached any limit (e.g. max recipes, users, ..)
:param space: Space to test for limits
:return: Tuple (True if above or equal any limit else false, message)
"""
r_limit, r_msg = above_space_recipe_limit(space)
u_limit, u_msg = above_space_user_limit(space)
return r_limit or u_limit, (r_msg + ' ' + u_msg).strip()
def above_space_recipe_limit(space):
"""
Test if a space has reached its recipe limit
:param space: Space to test for limits
:return: Tuple (True if above or equal limit else false, message)
"""
limit = space.max_recipes != 0 and Recipe.objects.filter(space=space).count() >= space.max_recipes
if limit:
return True, _('You have reached the maximum number of recipes for your space.')
return False, ''
def above_space_user_limit(space):
"""
Test if a space has reached its user limit
:param space: Space to test for limits
:return: Tuple (True if above or equal limit else false, message)
"""
limit = space.max_users != 0 and UserPreference.objects.filter(space=space).count() > space.max_users
if limit:
return True, _('You have more users than allowed in your space.')
return False, ''

View File

@@ -58,18 +58,6 @@ def get_recipe_from_source(text, url, request):
})
return kid_list
recipe_json = {
'name': '',
'url': '',
'description': '',
'image': '',
'keywords': [],
'recipeIngredient': [],
'recipeInstructions': '',
'servings': '',
'prepTime': '',
'cookTime': ''
}
recipe_tree = []
parse_list = []
html_data = []

View File

@@ -14,6 +14,9 @@ from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.models import Keyword
# from recipe_scrapers._utils import get_minutes ## temporary until/unless upstream incorporates get_minutes() PR
def get_from_scraper(scrape, request):
# converting the scrape_me object to the existing json format based on ld+json
recipe_json = {}
@@ -37,7 +40,7 @@ def get_from_scraper(scrape, request):
except Exception:
description = ''
recipe_json['description'] = parse_description(description)
recipe_json['internal'] = True
try:
servings = scrape.yields() or None
@@ -48,34 +51,31 @@ def get_from_scraper(scrape, request):
servings = scrape.schema.data.get('recipeYield') or 1
except Exception:
servings = 1
if type(servings) != int:
try:
servings = int(re.findall(r'\b\d+\b', servings)[0])
except Exception:
servings = 1
recipe_json['servings'] = max(servings, 1)
recipe_json['servings'] = parse_servings(servings)
recipe_json['servings_text'] = parse_servings_text(servings)
try:
recipe_json['prepTime'] = get_minutes(scrape.prep_time()) or 0
recipe_json['working_time'] = get_minutes(scrape.prep_time()) or 0
except Exception:
try:
recipe_json['prepTime'] = get_minutes(scrape.schema.data.get("prepTime")) or 0
recipe_json['working_time'] = get_minutes(scrape.schema.data.get("prepTime")) or 0
except Exception:
recipe_json['prepTime'] = 0
recipe_json['working_time'] = 0
try:
recipe_json['cookTime'] = get_minutes(scrape.cook_time()) or 0
recipe_json['waiting_time'] = get_minutes(scrape.cook_time()) or 0
except Exception:
try:
recipe_json['cookTime'] = get_minutes(scrape.schema.data.get("cookTime")) or 0
recipe_json['waiting_time'] = get_minutes(scrape.schema.data.get("cookTime")) or 0
except Exception:
recipe_json['cookTime'] = 0
recipe_json['waiting_time'] = 0
if recipe_json['cookTime'] + recipe_json['prepTime'] == 0:
if recipe_json['working_time'] + recipe_json['waiting_time'] == 0:
try:
recipe_json['prepTime'] = get_minutes(scrape.total_time()) or 0
recipe_json['working_time'] = get_minutes(scrape.total_time()) or 0
except Exception:
try:
recipe_json['prepTime'] = get_minutes(scrape.schema.data.get("totalTime")) or 0
recipe_json['working_time'] = get_minutes(scrape.schema.data.get("totalTime")) or 0
except Exception:
pass
@@ -113,6 +113,14 @@ def get_from_scraper(scrape, request):
keywords += listify_keywords(scrape.schema.data.get("recipeCuisine"))
except Exception:
pass
if source_url := scrape.canonical_url():
recipe_json['source_url'] = source_url
try:
keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0])
except Exception:
pass
try:
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request.space)
except AttributeError:
@@ -120,54 +128,49 @@ def get_from_scraper(scrape, request):
ingredient_parser = IngredientParser(request, True)
ingredients = []
recipe_json['steps'] = []
for i in parse_instructions(scrape.instructions()):
recipe_json['steps'].append({'instruction': i, 'ingredients': [], })
if len(recipe_json['steps']) == 0:
recipe_json['steps'].append({'instruction': '', 'ingredients': [], })
if len(parse_description(description)) > 256: # split at 256 as long descriptions dont look good on recipe cards
recipe_json['steps'][0]['instruction'] = f'*{parse_description(description)}* \n\n' + recipe_json['steps'][0]['instruction']
else:
recipe_json['description'] = parse_description(description)[:512]
try:
for x in scrape.ingredients():
try:
amount, unit, ingredient, note = ingredient_parser.parse(x)
ingredients.append(
{
ingredient = {
'amount': amount,
'unit': {
'text': unit,
'id': random.randrange(10000, 99999)
},
'ingredient': {
'text': ingredient,
'id': random.randrange(10000, 99999)
'food': {
'name': ingredient,
},
'unit': None,
'note': note,
'original_text': x
}
)
if unit:
ingredient['unit'] = {'name': unit, }
recipe_json['steps'][0]['ingredients'].append(ingredient)
except Exception:
ingredients.append(
recipe_json['steps'][0]['ingredients'].append(
{
'amount': 0,
'unit': {
'text': '',
'id': random.randrange(10000, 99999)
},
'ingredient': {
'text': x,
'id': random.randrange(10000, 99999)
'unit': None,
'food': {
'name': x,
},
'note': '',
'original_text': x
}
)
recipe_json['recipeIngredient'] = ingredients
except Exception:
recipe_json['recipeIngredient'] = ingredients
pass
try:
recipe_json['recipeInstructions'] = parse_instructions(scrape.instructions())
except Exception:
recipe_json['recipeInstructions'] = ""
if scrape.canonical_url():
recipe_json['url'] = scrape.canonical_url()
recipe_json['recipeInstructions'] += "\n\n" + _("Imported from") + ": " + scrape.canonical_url()
return recipe_json
@@ -180,102 +183,46 @@ def parse_name(name):
return normalize_string(name)
def parse_ingredients(ingredients):
# some pages have comma separated ingredients in a single array entry
try:
if type(ingredients[0]) == dict:
return ingredients
except (KeyError, IndexError):
pass
if (len(ingredients) == 1 and type(ingredients) == list):
ingredients = ingredients[0].split(',')
elif type(ingredients) == str:
ingredients = ingredients.split(',')
for x in ingredients:
if '\n' in x:
ingredients.remove(x)
for i in x.split('\n'):
ingredients.insert(0, i)
ingredient_list = []
for x in ingredients:
if x.replace(' ', '') != '':
x = x.replace('½', "0.5").replace('¼', "0.25").replace('¾', "0.75")
try:
amount, unit, ingredient, note = parse_single_ingredient(x)
if ingredient:
ingredient_list.append(
{
'amount': amount,
'unit': {
'text': unit,
'id': random.randrange(10000, 99999)
},
'ingredient': {
'text': ingredient,
'id': random.randrange(10000, 99999)
},
'note': note,
'original_text': x
}
)
except Exception:
ingredient_list.append(
{
'amount': 0,
'unit': {
'text': '',
'id': random.randrange(10000, 99999)
},
'ingredient': {
'text': x,
'id': random.randrange(10000, 99999)
},
'note': '',
'original_text': x
}
)
ingredients = ingredient_list
else:
ingredients = []
return ingredients
def parse_description(description):
return normalize_string(description)
def parse_instructions(instructions):
instruction_text = ''
# flatten instructions if they are in a list
if type(instructions) == list:
for i in instructions:
if type(i) == str:
instruction_text += i
else:
if 'text' in i:
instruction_text += i['text'] + '\n\n'
elif 'itemListElement' in i:
for ile in i['itemListElement']:
if type(ile) == str:
instruction_text += ile + '\n\n'
elif 'text' in ile:
instruction_text += ile['text'] + '\n\n'
else:
instruction_text += str(i)
instructions = instruction_text
normalized_string = normalize_string(instructions)
def clean_instruction_string(instruction):
normalized_string = normalize_string(instruction)
normalized_string = normalized_string.replace('\n', ' \n')
normalized_string = normalized_string.replace(' \n \n', '\n\n')
return normalized_string
def parse_instructions(instructions):
"""
Convert arbitrary instructions object from website import and turn it into a flat list of strings
:param instructions: any instructions object from import
:return: list of strings (from one to many elements depending on website)
"""
instruction_list = []
if type(instructions) == list:
for i in instructions:
if type(i) == str:
instruction_list.append(clean_instruction_string(i))
else:
if 'text' in i:
instruction_list.append(clean_instruction_string(i['text']))
elif 'itemListElement' in i:
for ile in i['itemListElement']:
if type(ile) == str:
instruction_list.append(clean_instruction_string(ile))
elif 'text' in ile:
instruction_list.append(clean_instruction_string(ile['text']))
else:
instruction_list.append(clean_instruction_string(str(i)))
else:
instruction_list.append(clean_instruction_string(instructions))
return instruction_list
def parse_image(image):
# check if list of images is returned, take first if so
if not image:
@@ -310,40 +257,31 @@ def parse_servings(servings):
return servings
def parse_cooktime(cooktime):
if type(cooktime) not in [int, float]:
def parse_servings_text(servings):
if type(servings) == str:
try:
cooktime = float(re.search(r'\d+', cooktime).group())
servings = re.sub("\d+", '', servings).strip()
except Exception:
servings = ''
return servings
def parse_time(recipe_time):
if type(recipe_time) not in [int, float]:
try:
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):
@@ -353,9 +291,9 @@ def parse_keywords(keyword_json, space):
kw = normalize_string(kw)
if len(kw) != 0:
if k := Keyword.objects.filter(name=kw, space=space).first():
keywords.append({'id': str(k.id), 'text': str(k.name)})
keywords.append({'label': str(k), 'name': k.name, 'id': k.id})
else:
keywords.append({'id': random.randrange(1111111, 9999999, 1), 'text': kw})
keywords.append({'label': kw, 'name': kw})
return keywords

View File

@@ -0,0 +1,77 @@
import base64
import json
from io import BytesIO
from gettext import gettext as _
import requests
from lxml import etree
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_time, parse_servings_text
from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step
class Cookmate(Integration):
def import_file_name_filter(self, zip_info_object):
return zip_info_object.filename.endswith('.xml')
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_xml = file
recipe = Recipe.objects.create(
name=recipe_xml.find('title').text.strip(),
created_by=self.request.user, internal=True, space=self.request.space)
if recipe_xml.find('preptime') is not None:
recipe.working_time = parse_time(recipe_xml.find('preptime').text.strip())
if recipe_xml.find('cooktime') is not None:
recipe.waiting_time = parse_time(recipe_xml.find('cooktime').text.strip())
if recipe_xml.find('quantity') is not None:
recipe.servings = parse_servings(recipe_xml.find('quantity').text.strip())
recipe.servings_text = parse_servings_text(recipe_xml.find('quantity').text.strip())
if recipe_xml.find('url') is not None:
recipe.source_url = recipe_xml.find('url').text.strip()
if recipe_xml.find('description') is not None: # description is a list of <li>'s with text
if len(recipe_xml.find('description')) > 0:
recipe.description = recipe_xml.find('description')[0].text[:512]
for step in recipe_xml.find('recipetext').getchildren():
step = Step.objects.create(
instruction=step.text.strip(), space=self.request.space,
)
recipe.steps.add(step)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in recipe_xml.find('ingredient').getchildren():
if ingredient.text.strip() != '':
amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip())
f = ingredient_parser.get_food(food)
u = ingredient_parser.get_unit(unit)
recipe.steps.first().ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, original_text=ingredient.text.strip(), space=self.request.space,
))
if recipe_xml.find('imageurl') is not None:
try:
response = requests.get(recipe_xml.find('imageurl').text.strip())
self.import_recipe_image(recipe, BytesIO(response.content))
except Exception as e:
print('failed to import image ', str(e))
recipe.save()
return recipe
def get_file_from_recipe(self, recipe):
raise NotImplementedError('Method not implemented in storage integration')

View File

@@ -32,7 +32,14 @@ class CopyMeThat(Integration):
recipe.servings = parse_servings(file.find("a", {"id": "recipeYield"}).text.strip())
recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip())
recipe.waiting_time = iso_duration_to_minutes(file.find("span", {"meta": "cookTime"}).text.strip())
recipe.save()
recipe.description = (file.find("div ", {"id": "description"}).text.strip())[:512]
except AttributeError:
pass
try:
if len(file.find("span", {"id": "starred"}).text.strip()) > 0:
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=_('Favorite'))[0])
except AttributeError:
pass

View File

@@ -5,6 +5,8 @@ import traceback
import uuid
from io import BytesIO, StringIO
from zipfile import BadZipFile, ZipFile
import lxml
from django.core.cache import cache
import datetime
@@ -16,6 +18,7 @@ from django.http import HttpResponse
from django.utils.formats import date_format
from django.utils.translation import gettext as _
from django_scopes import scope
from lxml import etree
from cookbook.forms import ImportExportBase
from cookbook.helper.image_processing import get_filetype, handle_image
@@ -144,7 +147,7 @@ class Integration:
il.imported_recipes += 1
il.save()
import_zip.close()
elif '.zip' in f['name'] or '.paprikarecipes' in f['name']:
elif '.zip' in f['name'] or '.paprikarecipes' in f['name'] or '.mcb' in f['name']:
import_zip = ZipFile(f['file'])
file_list = []
for z in import_zip.filelist:
@@ -157,9 +160,16 @@ class Integration:
file_list = self.split_recipe_file(BytesIO(import_zip.read('recipes.html')))
il.total_recipes += len(file_list)
if isinstance(self, cookbook.integration.cookmate.Cookmate):
new_file_list = []
for file in file_list:
new_file_list += etree.parse(BytesIO(import_zip.read(file.filename))).getroot().getchildren()
il.total_recipes = len(new_file_list)
file_list = new_file_list
for z in file_list:
try:
if isinstance(z, Tag):
if not hasattr(z, 'filename'):
recipe = self.get_recipe_from_file(z)
else:
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
@@ -172,7 +182,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:

View File

@@ -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')

View File

@@ -31,6 +31,9 @@ class NextcloudCookbook(Integration):
except Exception:
pass
if 'url' in recipe_json:
recipe.source_url = recipe_json['url'].strip()
if 'recipeCategory' in recipe_json:
try:
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=recipe_json['recipeCategory'])[0])

View File

@@ -6,6 +6,7 @@ from gettext import gettext as _
from io import BytesIO
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text
from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step
@@ -26,10 +27,9 @@ class Paprika(Integration):
recipe.description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip()
try:
if re.match(r'([0-9])+\s(.)*', recipe_json['servings']):
s = recipe_json['servings'].split(' ')
recipe.servings = s[0]
recipe.servings_text = s[1]
if 'servings' in recipe_json['servings']:
recipe.servings = parse_servings(recipe_json['servings'])
recipe.servings_text = parse_servings_text(recipe_json['servings'])
if len(recipe_json['cook_time'].strip()) > 0:
recipe.waiting_time = re.findall(r'\d+', recipe_json['cook_time'])[0]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2022-03-04 13:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0172_ingredient_original_text'),
]
operations = [
migrations.AddField(
model_name='recipe',
name='source_url',
field=models.CharField(blank=True, default=None, max_length=1024, null=True),
),
]

View File

@@ -593,6 +593,8 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
order = models.IntegerField(default=0)
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
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')
@@ -663,9 +665,7 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
servings = models.IntegerField(default=1)
servings_text = models.CharField(default='', blank=True, max_length=32)
image = models.ImageField(upload_to='recipes/', blank=True, null=True)
storage = models.ForeignKey(
Storage, on_delete=models.PROTECT, blank=True, null=True
)
storage = models.ForeignKey(Storage, on_delete=models.PROTECT, blank=True, null=True)
file_uid = models.CharField(max_length=256, default="", blank=True)
file_path = models.CharField(max_length=512, default="", blank=True)
link = models.CharField(max_length=512, null=True, blank=True)
@@ -675,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)

View File

@@ -12,6 +12,7 @@ from rest_framework.exceptions import NotFound, ValidationError
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.permission_helper import above_space_limit
from cookbook.helper.shopping_helper import RecipeShoppingEditor
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, CustomFilter,
ExportLog, Food, FoodInheritField, ImportLog, Ingredient, Keyword,
@@ -649,6 +650,12 @@ class RecipeSerializer(RecipeBaseSerializer):
)
read_only_fields = ['image', 'created_by', 'created_at']
def validate(self, data):
above_limit, msg = above_space_limit(self.context['request'].space)
if above_limit:
raise serializers.ValidationError(msg)
return super().validate(data)
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
validated_data['space'] = self.context['request'].space
@@ -656,9 +663,12 @@ class RecipeSerializer(RecipeBaseSerializer):
class RecipeImageSerializer(WritableNestedModelSerializer):
image = serializers.ImageField(required=False, allow_null=True)
image_url = serializers.CharField(max_length=4096, required=False, allow_null=True)
class Meta:
model = Recipe
fields = ['image', ]
fields = ['image', 'image_url', ]
class RecipeImportSerializer(SpacedModelSerializer):
@@ -968,12 +978,19 @@ class AutomationSerializer(serializers.ModelSerializer):
# CORS, REST and Scopes aren't currently working
# Scopes are evaluating before REST has authenticated the user assigning a None space
# I've made the change below to fix the bookmarklet, other serializers likely need a similar/better fix
class BookmarkletImportSerializer(serializers.ModelSerializer):
class BookmarkletImportListSerializer(serializers.ModelSerializer):
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
validated_data['space'] = self.context['request'].user.userpreference.space
return super().create(validated_data)
class Meta:
model = BookmarkletImport
fields = ('id', 'url', 'created_by', 'created_at')
read_only_fields = ('created_by', 'space')
class BookmarkletImportSerializer(BookmarkletImportListSerializer):
class Meta:
model = BookmarkletImport
fields = ('id', 'url', 'html', 'created_by', 'created_at')

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -68,6 +68,7 @@
<script>
$('#id_login').focus()
$('#id_remember').prop('checked', true);
</script>
{% endblock %}

View File

@@ -1,26 +1,36 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
{% load i18n %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% load l10n %}
{% load custom_tags %}
{% block title %}Test{% endblock %}
{% block content_fluid %}
<div id="app">
<import-view></import-view>
</div>
{% block title %}{% trans 'Import Recipes' %}{% endblock %}
{% block extra_head %}
{{ form.media }}
{% endblock %}
{% block content %}
<h2>{% trans 'Import' %}</h2>
<div class="row">
<div class="col col-md-12">
<form action="." method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-file-import"></i> {% trans 'Import' %}
</button>
</form>
</div>
</div>
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
<script type="application/javascript">
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
window.API_TOKEN = '{{ api_token }}'
window.BOOKMARKLET_IMPORT_ID = {{ bookmarklet_import_id }}
</script>
{% render_bundle 'import_view' %}
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -136,7 +136,7 @@ def bookmarklet(request):
if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user)
bookmark = "javascript: \
bookmark = "<a href='javascript: \
(function(){ \
if(window.bookmarkletTandoor!==undefined){ \
bookmarkletTandoor(); \
@@ -146,8 +146,8 @@ def bookmarklet(request):
localStorage.setItem('token', '" + api_token.__str__() + "'); \
document.body.appendChild(document.createElement(\'script\')).src=\'" \
+ server + prefix + static('js/bookmarklet.js') + "? \
r=\'+Math.floor(Math.random()*999999999);}})();"
return re.sub(r"[\n\t\s]*", "", bookmark)
r=\'+Math.floor(Math.random()*999999999);}})();'>Test</a>"
return re.sub(r"[\n\t]*", "", bookmark)
@register.simple_tag

View File

@@ -5,6 +5,7 @@ import uuid
from collections import OrderedDict
import requests
from PIL import UnidentifiedImageError
from annoying.decorators import ajax_request
from annoying.functions import get_object_or_None
from django.contrib import messages
@@ -23,6 +24,7 @@ from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from icalendar import Calendar, Event
from recipe_scrapers import NoSchemaFoundInWildMode, WebsiteNotImplementedError, scrape_me
from requests.exceptions import MissingSchema
from rest_framework import decorators, status, viewsets
from rest_framework.exceptions import APIException, PermissionDenied
from rest_framework.pagination import PageNumberPagination
@@ -68,7 +70,7 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializ
SupermarketCategorySerializer, SupermarketSerializer,
SyncLogSerializer, SyncSerializer, UnitSerializer,
UserFileSerializer, UserNameSerializer, UserPreferenceSerializer,
ViewLogSerializer, IngredientSimpleSerializer)
ViewLogSerializer, IngredientSimpleSerializer, BookmarkletImportListSerializer)
from recipes import settings
@@ -767,20 +769,33 @@ class RecipeViewSet(viewsets.ModelViewSet):
serializer = self.serializer_class(obj, data=request.data, partial=True)
if self.request.space.demo:
raise PermissionDenied(detail='Not available in demo', code=None)
if serializer.is_valid():
serializer.save()
image = None
if serializer.validated_data == {}:
obj.image = None
else:
img, filetype = handle_image(request, obj.image)
if 'image' in serializer.validated_data:
image = obj.image
elif 'image_url' in serializer.validated_data:
try:
response = requests.get(serializer.validated_data['image_url'])
image = File(io.BytesIO(response.content))
print('test')
except UnidentifiedImageError as e:
print(e)
pass
except MissingSchema as e:
print(e)
pass
except Exception as e:
print(e)
pass
if image is not None:
img, filetype = handle_image(request, image)
obj.image = File(img, name=f'{uuid.uuid4()}_{obj.pk}{filetype}')
obj.save()
return Response(serializer.data)
return Response(serializer.errors, 400)
# TODO: refactor API to use post/put/delete or leave as put and change VUE to use list_recipe after creating
@@ -959,6 +974,11 @@ class BookmarkletImportViewSet(viewsets.ModelViewSet):
serializer_class = BookmarkletImportSerializer
permission_classes = [CustomIsUser]
def get_serializer_class(self):
if self.action == 'list':
return BookmarkletImportListSerializer
return self.serializer_class
def get_queryset(self):
return self.queryset.filter(space=self.request.space).all()
@@ -1132,103 +1152,56 @@ 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
- (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes
:return: JsonResponse containing the parsed json, original html,json and images
"""
request_payload = json.loads(request.body.decode('utf-8'))
url = request_payload.get('url', None)
data = request_payload.get('data', None)
bookmarklet = request_payload.get('bookmarklet', None)
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 bookmarklet := BookmarkletImport.objects.filter(pk=bookmarklet).first():
url = bookmarklet.url
data = bookmarklet.html
bookmarklet.delete()
if (not url and not data) or (mode == 'url' and not url) or (mode == 'source' and not data):
return JsonResponse(
{
# 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:
return JsonResponse({
'error': True,
'msg': _('Nothing to do.')
},
status=400
)
}, status=400)
if mode == 'url' and auto == 'true':
# in manual mode request complete page to return it later
if url:
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(
{
'error': True,
'msg': _('The requested page could not be found.')
},
status=400
)
try:
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.')
# noqa: E501
},
status=400)
else:
return JsonResponse({"recipe_json": get_from_scraper(scrape, request)})
elif (mode == 'source') or (mode == 'url' and auto == 'false'):
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(
{
return JsonResponse({
'error': True,
'msg': _('Connection Refused.')
},
status=400
)
recipe_json, recipe_tree, recipe_html, images = get_recipe_from_source(data, url, request)
}, 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(
{
return JsonResponse({
'error': True,
'msg': _('No usable data could be found.')
},
status=400
)
}, status=400)
else:
return JsonResponse({
'recipe_tree': recipe_tree,
'recipe_json': recipe_json,
'recipe_tree': recipe_tree,
'recipe_html': recipe_html,
'images': images,
'recipe_images': list(dict.fromkeys(recipe_images)),
})
else:
return JsonResponse(
{
'error': True,
'msg': _('I couldn\'t find anything to do.')
},
status=400
)
@group_required('admin')
def get_backup(request):

View File

@@ -1,41 +1,27 @@
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,
Unit, UserPreference)
from cookbook.helper.permission_helper import group_required, has_group_permission, above_space_limit
from cookbook.models import (Comment, Food, Keyword, Recipe, RecipeImport, Sync,
Unit, UserPreference, BookmarkletImport)
from cookbook.tables import SyncTable
from recipes import settings
@group_required('user')
def sync(request):
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
return HttpResponseRedirect(reverse('index'))
if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
limit, msg = above_space_limit(request.space)
if limit:
messages.add_message(request, messages.WARNING, msg)
return HttpResponseRedirect(reverse('index'))
if request.space.demo or settings.HOSTED:
@@ -123,103 +109,21 @@ def batch_edit(request):
@group_required('user')
@atomic
def import_url(request):
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
limit, msg = above_space_limit(request.space)
if limit:
messages.add_message(request, messages.WARNING, msg)
return HttpResponseRedirect(reverse('index'))
if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
return HttpResponseRedirect(reverse('index'))
if request.method == 'POST':
data = json.loads(request.body)
data['cookTime'] = parse_cooktime(data.get('cookTime', ''))
data['prepTime'] = parse_cooktime(data.get('prepTime', ''))
recipe = Recipe.objects.create(
name=data['name'],
description=data['description'],
waiting_time=data['cookTime'],
working_time=data['prepTime'],
servings=data['servings'],
internal=True,
created_by=request.user,
space=request.space,
)
step = Step.objects.create(
instruction=data['recipeInstructions'], space=request.space,
)
recipe.steps.add(step)
for kw in data['keywords']:
if data['all_keywords']: # do not remove this check :) https://github.com/vabene1111/recipes/issues/645
k, created = Keyword.objects.get_or_create(name=kw['text'], space=request.space)
recipe.keywords.add(k)
else:
try:
k = Keyword.objects.get(name=kw['text'], space=request.space)
recipe.keywords.add(k)
except ObjectDoesNotExist:
pass
ingredient_parser = IngredientParser(request, True)
for ing in data['recipeIngredient']:
original = ing.pop('original', None) or ing.pop('original_text', None)
ingredient = Ingredient(original_text=original, space=request.space, )
if food_text := ing['ingredient']['text'].strip():
ingredient.food = ingredient_parser.get_food(food_text)
if ing['unit']:
if unit_text := ing['unit']['text'].strip():
ingredient.unit = ingredient_parser.get_unit(unit_text)
# TODO properly handle no_amount recipes
if isinstance(ing['amount'], str):
try:
ingredient.amount = float(ing['amount'].replace(',', '.'))
except ValueError:
ingredient.no_amount = True
pass
elif isinstance(ing['amount'], float) \
or isinstance(ing['amount'], int):
ingredient.amount = ing['amount']
ingredient.note = ing['note'].strip() if 'note' in ing else ''
ingredient.save()
step.ingredients.add(ingredient)
if 'image' in data and data['image'] != '' and data['image'] is not None:
try:
response = requests.get(data['image'])
img, filetype = handle_image(request, File(BytesIO(response.content), name='image'))
recipe.image = File(
img, name=f'{uuid.uuid4()}_{recipe.pk}{filetype}'
)
recipe.save()
except UnidentifiedImageError as e:
print(e)
pass
except MissingSchema as e:
print(e)
pass
except Exception as e:
print(e)
pass
return HttpResponse(reverse('view_recipe', args=[recipe.pk]))
if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user)
bookmarklet_import_id = -1
if 'id' in request.GET:
context = {'bookmarklet': request.GET.get('id', '')}
else:
context = {}
if bookmarklet_import := BookmarkletImport.objects.filter(id=request.GET['id']).first():
bookmarklet_import_id = bookmarklet_import.pk
return render(request, 'url_import.html', context)
return render(request, 'url_import.html', {'api_token': api_token, 'bookmarklet_import_id': bookmarklet_import_id})
class Object(object):

View File

@@ -9,7 +9,7 @@ from django.views.generic import UpdateView
from django.views.generic.edit import FormMixin
from cookbook.forms import CommentForm, ExternalRecipeForm, MealPlanForm, StorageForm, SyncForm
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required, above_space_limit
from cookbook.models import (Comment, MealPlan, MealType, Recipe, RecipeImport, Storage, Sync,
UserPreference)
from cookbook.provider.dropbox import Dropbox
@@ -39,12 +39,9 @@ def convert_recipe(request, pk):
@group_required('user')
def internal_recipe_update(request, pk):
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() > request.space.max_recipes: # TODO move to central helper function
messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
return HttpResponseRedirect(reverse('view_recipe', args=[pk]))
if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
limit, msg = above_space_limit(request.space)
if limit:
messages.add_message(request, messages.WARNING, msg)
return HttpResponseRedirect(reverse('view_recipe', args=[pk]))
recipe_instance = get_object_or_404(Recipe, pk=pk, space=request.space)

View File

@@ -10,16 +10,18 @@ from django.urls import reverse
from django.utils.translation import gettext as _
from cookbook.forms import ExportForm, ImportExportBase, ImportForm
from cookbook.helper.permission_helper import group_required
from cookbook.helper.permission_helper import group_required, above_space_limit
from cookbook.helper.recipe_search import RecipeSearch
from cookbook.integration.cheftap import ChefTap
from cookbook.integration.chowdown import Chowdown
from cookbook.integration.cookbookapp import CookBookApp
from cookbook.integration.cookmate import Cookmate
from cookbook.integration.copymethat import CopyMeThat
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,16 +76,17 @@ 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)
if export_type == ImportExportBase.COOKMATE:
return Cookmate(request, export_type)
@group_required('user')
def import_recipe(request):
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
return HttpResponseRedirect(reverse('index'))
if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
limit, msg = above_space_limit(request.space)
if limit:
messages.add_message(request, messages.WARNING, msg)
return HttpResponseRedirect(reverse('index'))
if request.method == "POST":
@@ -100,7 +103,7 @@ def import_recipe(request):
t.setDaemon(True)
t.start()
return JsonResponse({'import_id': [il.pk]})
return JsonResponse({'import_id': il.pk})
except NotImplementedError:
return JsonResponse(
{

View File

@@ -662,11 +662,10 @@ def test(request):
if not settings.DEBUG:
return HttpResponseRedirect(reverse('index'))
with scopes_disabled():
result = ShoppingList.objects.filter(
Q(created_by=request.user) | Q(shared=request.user)).filter(
space=request.space).values().distinct()
return JsonResponse(list(result), safe=False, json_dumps_params={'indent': 2})
if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user)
return render(request, 'test.html', {'api_token': api_token})
def test2(request):

View File

@@ -38,6 +38,8 @@ Overview of the capabilities of the different integrations.
| Plantoeat | ✔️ | ❌ | ✔ |
| CookBookApp | ✔️ | ⌚ | ✔️ |
| CopyMeThat | ✔️ | ❌ | ✔️ |
| Melarecipes | ✔️ | ⌚ | ✔️ |
| Cookmate | ✔️ | ⌚ | ✔️ |
| PDF (experimental) | ⌚️ | ✔️ | ✔️ |
✔️ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented
@@ -225,6 +227,19 @@ 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.
## Cookmate
Cookmate allows you to export a `.mcb` file which you can simply upload to tandoor and import all your recipes.
## RecetteTek
RecetteTek exports are `.rtk` files which can simply be uploaded to tandoor to import all your 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.

0
openapitools.json Normal file
View File

View File

@@ -0,0 +1,671 @@
<template>
<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" v-model="tab_index">
<!-- URL Tab -->
<b-tab v-bind:title="$t('Website')" id="id_tab_url" active>
<!-- URL -->
<b-card no-body>
<b-card-header header-tag="header" class="p-1" role="tab">
<b-col cols="12" md="6" offset="0" offset-md="3">
<b-button block v-b-toggle.id_accordion_url variant="info">Website</b-button>
<!-- TODO localize -->
</b-col>
</b-card-header>
<b-collapse id="id_accordion_url" visible accordion="url_import_accordion"
role="tabpanel" v-model="collapse_visible.url">
<div class="row justify-content-center p-2">
<div class="col-12 col-lg-10 col-xl-8 mt-3 mb-3">
<div class="row justify-content-center">
<div class="col-12 justify-content-cente">
<b-checkbox v-model="import_multiple" switch><span
v-if="import_multiple">Multiple Recipes</span><span
v-if="!import_multiple">Single Recipe</span></b-checkbox>
<!-- TODO localize -->
</div>
</div>
<b-input-group class="mt-2" :class="{ bounce: empty_input }"
v-if="!import_multiple">
<b-input
class="form-control form-control-lg form-control-borderless form-control-search"
v-model="website_url"
placeholder="Website URL" @paste="onURLPaste"></b-input>
<!-- TODO localize -->
<b-input-group-append>
<b-button variant="primary"
@click="loadRecipe(website_url,false,undefined)"><i
class="fas fa-search fa-fw"></i>
</b-button>
</b-input-group-append>
</b-input-group>
<b-textarea rows="10" placeholder="Enter one URL per line"
v-model="website_url_list"
v-if="import_multiple"> <!-- TODO localize -->
</b-textarea>
<b-button class="float-right" v-if="import_multiple"
:disabled="website_url_list.length < 1"
@click="autoImport()">Import
</b-button>
<div class="row mt-2"> <!-- TODO remove -->
<div class="col col-md-12">
<div v-if="!import_multiple">
<a href="#" @click="clearRecentImports()">Clear recent
imports</a>
<ul>
<li v-for="x in recent_urls" v-bind:key="x">
<a href="#"
@click="loadRecipe(x, false, undefined)">{{
x
}}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</b-collapse>
</b-card>
<!-- Loading -->
<b-card no-body v-if="loading">
<loading-spinner></loading-spinner>
</b-card>
<!-- OPTIONS -->
<b-card no-body v-if="recipe_json !== undefined">
<b-card-header header-tag="header" class="p-1" role="tab">
<b-col cols="12" md="6" offset="0" offset-md="3">
<b-button block v-b-toggle.id_accordion_add_options variant="info"
:disabled="recipe_json === undefined">Options
</b-button>
</b-col>
</b-card-header>
<b-collapse id="id_accordion_add_options" accordion="url_import_accordion"
role="tabpanel" v-model="collapse_visible.options">
<b-card-body v-if="recipe_json !== undefined"
class="p-1 pb-md-5 pr-md-5 pl-md-5 pt-md-2">
<div class="row">
<div class="col-12 col-md-8 offset-0 offset-md-2">
<h4 class="text-center flex-grow-1" v-b-tooltip.hover.bottom
:title="$t('Click_To_Edit')" v-if="!edit_name"
@click="edit_name = true">{{
recipe_json.name
}} <span class="text-primary"><i class="fa fa-edit"></i> </span>
</h4>
<b-input-group v-if="edit_name" class="mb-2">
<b-input
class="form-control form-control-borderless form-control-search"
v-model="recipe_json.name"></b-input>
<b-input-group-append>
<b-button variant="primary" @click="edit_name = false"><i
class="fas fa-save fa-fw"></i>
</b-button>
</b-input-group-append>
</b-input-group>
</div>
</div>
<div class="row">
<div class="col col-md-12 text-center">
<b-img rounded fluid :src="recipe_json.image"
style="max-height: 30vh"></b-img>
</div>
</div>
<div class="row mt-1">
<div class="col col-md-12 text-center">
<small class="text-muted">Click the image you want to import for this
recipe</small> <!-- TODO localize -->
<span v-if="recipe_images.length === 0">No additional images found in source.</span>
<div class="scrolling-wrapper-flexbox">
<div class="wrapper-card" v-for="i in recipe_images"
v-bind:key="i"
@click="recipe_json.image = i">
<b-img rounded thumbnail fluid :src="i"
style="max-height: 10vh"
></b-img>
</div>
</div>
</div>
</div>
<div class="row mt-2 mt-md-4">
<div class="col-12 col-md-6 offset-0 offset-md-3">
<b-card no-body>
<b-card-title>
<div class="clearfix">
<span class="float-left h5">Keywords</span>
<b-button-group class="float-right">
<b-button class="float-right" variant="primary"
@click="$set(recipe_json, 'keywords', recipe_json.keywords.map(x => {x.show = true; return x}))">
<i
class="fa fa-check-double"></i></b-button>
<b-button class="float-right" variant="secondary"
@click="$set(recipe_json, 'keywords', recipe_json.keywords.map(x => {x.show = false; return x}))">
<i
class="fa fa-times"></i></b-button>
</b-button-group>
</div>
</b-card-title>
<b-card-body class="m-0 p-0 p-md-5">
<b-list-group>
<b-list-group-item
v-for="(k, index) in recipe_json.keywords"
v-bind:key="k.name" style="cursor: pointer"
v-hover v-bind:class="{ 'bg-success': k.show }"
@click="k.show = (!k.show);$set(recipe_json.keywords, index, k)">
<div class="clearfix">
<span class="float-left">{{
k.label
}} </span>
<b-checkbox class="float-right" v-model="k.show"
disabled></b-checkbox>
</div>
</b-list-group-item>
</b-list-group>
</b-card-body>
</b-card>
</div>
</div>
<import-view-step-editor :recipe="recipe_json"
@change="recipe_json = $event"></import-view-step-editor>
</b-card-body>
</b-collapse>
</b-card>
<!-- IMPORT -->
<b-card no-body
v-if="recipe_json !== undefined || (imported_recipes.length > 0 || failed_imports.length>0)">
<b-card-header header-tag="header" class="p-1" role="tab">
<b-col cols="12" md="6" offset="0" offset-md="3">
<b-button block v-b-toggle.id_accordion_import variant="info"
:disabled="recipe_json === undefined">Import
</b-button>
</b-col>
</b-card-header>
<b-collapse id="id_accordion_import" visible accordion="url_import_accordion"
role="tabpanel" v-model="collapse_visible.import">
<b-card-body class="text-center">
<b-row>
<b-col cols="12" md="6" xl="4" offset="0" offset-md="3" offset-xl="4"
v-if="!import_multiple">
<recipe-card :recipe="recipe_json" :detailed="false"
:show_context_menu="false"
></recipe-card>
</b-col>
<b-col>
<b-list-group>
<b-list-group-item
v-for="r in imported_recipes"
v-bind:key="r.id"
v-hover>
<div class="clearfix">
<a class="float-left" target="_blank"
rel="noreferrer nofollow"
:href="resolveDjangoUrl('view_recipe',r.id)">{{
r.name
}}</a>
<span class="float-right">Imported</span>
<!-- TODO localize -->
</div>
</b-list-group-item>
<b-list-group-item
v-for="u in failed_imports"
v-bind:key="u"
v-hover>
<div class="clearfix">
<a class="float-left" target="_blank"
rel="noreferrer nofollow" :href="u">{{ u }}</a>
<span class="float-right">Failed</span>
<!-- TODO localize -->
</div>
</b-list-group-item>
</b-list-group>
</b-col>
</b-row>
</b-card-body>
<b-card-footer class="text-center">
<b-button-group>
<b-button @click="importRecipe('view')" v-if="!import_multiple">Import &
View
</b-button> <!-- TODO localize -->
<b-button @click="importRecipe('edit')" variant="success"
v-if="!import_multiple">Import & Edit
</b-button>
<b-button @click="importRecipe('import')" v-if="!import_multiple">Import &
Restart
</b-button>
<b-button @click="location.reload()">Restart
</b-button>
</b-button-group>
</b-card-footer>
</b-collapse>
</b-card>
</b-tab>
<!-- App Tab -->
<b-tab v-bind:title="$t('App')">
<select class="form-control" v-model="recipe_app">
<option v-for="i in INTEGRATIONS" :value="i.id" v-bind:key="i.id">{{ i.name }}</option>
</select>
<b-form-checkbox v-model="import_duplicates" name="check-button" switch
style="margin-top: 1vh">
{{ $t('import_duplicates') }}
</b-form-checkbox>
<a href="recipe_app_info.help_url"
v-if="recipe_app_info !== undefined && recipe_app_info.help_url !== ''">{{
$t('Help')
}}</a>
<b-form-file
class="my-2"
multiple
v-model="recipe_files"
:placeholder="$t('Select_File')"
drop-placeholder="Drop recipe files here...">
</b-form-file>
<button @click="importAppRecipe()" class="btn btn-primary shadow-none" type="button"
id="id_btn_app"><i class="fas fa-file-archive"></i> {{ $t('Import') }}
</button>
</b-tab>
<!-- Source Tab -->
<b-tab v-bind:title="$t('Source')">
<div class="input-group mt-4">
<b-textarea class="form-control input-group-append" v-model="source_data" rows=10
:placeholder="$t('paste_json')" style="font-size: 12px">
</b-textarea>
</div>
<b-button @click="loadRecipe('',false, undefined)" variant="primary"><i
class="fas fa-code"></i>
{{ $t('Import') }}
</b-button>
</b-tab>
<!-- Bookmarklet Tab -->
<b-tab v-bind:title="$t('Bookmarklet')">
<!-- TODO localize -->
Some pages cannot be imported from their URL, the Bookmarklet can be used to import from
some of them anyway.<br/>
1. Drag the following button to your bookmarks bar <a class="btn btn-outline-info btn-sm"
:href="makeBookmarklet()">Import into
Tandoor</a> <br/>
2. Open the page you want to import from <br/>
3. Click on the bookmark to perform the import <br/>
</b-tab>
</b-tabs>
</div>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import {resolveDjangoStatic, resolveDjangoUrl, ResolveUrlMixin, StandardToasts, ToastMixin} from "@/utils/utils";
import axios from "axios";
import {ApiApiFactory} from "@/utils/openapi/api";
import {INTEGRATIONS} from "@/utils/integration";
import ImportViewStepEditor from "@/apps/ImportView/ImportViewStepEditor";
import RecipeCard from "@/components/RecipeCard";
import LoadingSpinner from "@/components/LoadingSpinner";
Vue.use(BootstrapVue)
export default {
name: 'ImportView',
mixins: [
ResolveUrlMixin,
ToastMixin,
],
components: {
LoadingSpinner,
RecipeCard,
ImportViewStepEditor
},
computed: {
recipe_app_info: function () {
return this.INTEGRATIONS.filter(x => x.id === this.recipe_app)[0]
},
},
data() {
return {
tab_index: 0,
collapse_visible: {
url: true,
options: false,
advanced_options: false,
import: false,
},
// URL import
LS_IMPORT_RECENT: 'import_recent_urls', //TODO use central helper to manage all local storage keys (and maybe even access)
website_url: '',
website_url_list: 'https://madamedessert.de/schokoladenpudding-rezept-mit-echter-schokolade/\nhttps://www.essen-und-trinken.de/rezepte/58294-rzpt-schokoladenpudding\n' +
'https://www.chefkoch.de/rezepte/1825781296124455/Schokoladenpudding-selbst-gemacht.html\ntest.com\nhttps://bla.com',
import_multiple: false,
recent_urls: [],
source_data: '',
recipe_json: undefined,
recipe_html: undefined,
recipe_tree: undefined,
recipe_images: [],
imported_recipes: [],
failed_imports: [],
// App Import
INTEGRATIONS: INTEGRATIONS,
recipe_app: undefined,
import_duplicates: false,
recipe_files: [],
loading: false,
empty_input: false,
edit_name: false,
// Bookmarklet
BOOKMARKLET_CODE: window.BOOKMARKLET_CODE
}
},
mounted() {
let local_storage_recent = JSON.parse(window.localStorage.getItem(this.LS_IMPORT_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
if (window.BOOKMARKLET_IMPORT_ID !== -1) {
this.loadRecipe('', false, window.BOOKMARKLET_IMPORT_ID)
}
},
methods: {
/**
* Import recipe based on the data configured by the client
* @param action: action to perform after import (options are: edit, view, import)
* @param data: if parameter is passed ignore global application state and import form data variable
* @param silent do not show any messages for imports
*/
importRecipe: function (action, data, silent) {
if (this.recipe_json !== undefined) {
this.$set(this.recipe_json, 'keywords', this.recipe_json.keywords.filter(k => k.show))
}
let apiFactory = new ApiApiFactory()
let recipe_json = data !== undefined ? data : this.recipe_json
if (recipe_json !== undefined) {
apiFactory.createRecipe(recipe_json).then(response => { // save recipe
let recipe = response.data
apiFactory.imageRecipe(response.data.id, undefined, recipe_json.image).then(response => { // save recipe image
if (!silent) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}
this.afterImportAction(action, recipe)
}).catch(e => {
if (!silent) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
}
this.afterImportAction(action, recipe)
})
}).catch(err => {
if (recipe_json.source_url !== '') {
this.failed_imports.push(recipe_json.source_url)
}
if (!silent) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
}
})
} else {
console.log('cant import recipe without data')
if (!silent) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
}
}
},
/**
* Action performed after URL import
* @param action: action to perform after import
* edit: edit imported recipe
* view: view imported recipe
* import: restart the importer
* nothing: do nothing
* @param recipe: recipe that was imported
*/
afterImportAction: function (action, recipe) {
switch (action) {
case 'edit':
window.location = resolveDjangoUrl('edit_recipe', recipe.id)
break;
case 'view':
window.location = resolveDjangoUrl('view_recipe', recipe.id)
break;
case 'import':
location.reload();
break;
case 'multi_import':
this.imported_recipes.push(recipe)
break;
case 'nothing':
break;
}
},
/**
* 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
* @param url url to import (optional, empty string for bookmarklet imports)
* @param silent do not open the options tab after loading the recipe
* @param bookmarklet id of bookmarklet import to load instead of url, default undefined
*/
loadRecipe: function (url, silent, bookmarklet) {
// keep list of recently imported urls
if (url !== '') {
if (this.recent_urls.length > 5) {
this.recent_urls.pop()
}
if (this.recent_urls.filter(x => x === url).length === 0) {
this.recent_urls.push(url)
}
window.localStorage.setItem(this.LS_IMPORT_RECENT, JSON.stringify(this.recent_urls))
}
if (url === '' && bookmarklet === undefined) {
this.empty_input = true
setTimeout(() => {
this.empty_input = false
}, 1000)
return
}
if (!silent) {
this.loading = true
}
// reset all variables
this.recipe_html = undefined
this.recipe_json = undefined
this.recipe_tree = undefined
this.recipe_images = []
// load recipe
let payload = {
'url': url,
'data': this.source_data,
}
if (bookmarklet !== undefined) {
payload['bookmarklet'] = bookmarklet
}
return axios.post(resolveDjangoUrl('api_recipe_from_source'), payload,).then((response) => {
this.loading = false
this.recipe_json = response.data['recipe_json'];
this.recipe_json.keywords.map(x => {
if (x.id === undefined) {
x.show = false
} else {
x.show = true
}
return x
})
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'] : [];
if (!silent) {
this.tab_index = 0 // open tab 0 with import wizard
this.collapse_visible.options = true // open options collapse
}
return this.recipe_json
}).catch((err) => {
if (url !== '') {
this.failed_imports.push(url)
}
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH, err.response.data.msg)
throw "Load Recipe Error"
})
},
/**
* Function to automatically import multiple urls
* Takes input from website_url_list
*/
autoImport: function () {
this.collapse_visible.import = true
this.website_url_list.split('\n').forEach(r => {
this.loadRecipe(r, true, undefined).then((recipe_json) => {
this.importRecipe('multi_import', recipe_json, true)
})
})
this.website_url_list = ''
},
/**
* Import recipes with uploaded files and app integration
*/
importAppRecipe: function () {
let formData = new FormData();
formData.append('type', this.recipe_app);
formData.append('duplicates', this.import_duplicates)
for (let i = 0; i < this.recipe_files.length; i++) {
formData.append('files', this.recipe_files[i]);
}
axios.post(resolveDjangoUrl('view_import'), formData, {headers: {'Content-Type': 'multipart/form-data'}}).then((response) => {
window.location.href = resolveDjangoUrl('view_import_response', response.data['import_id'])
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
},
/**
* Handles pasting URLs
*/
onURLPaste: function (evt) {
this.website_url = evt.clipboardData.getData('text')
this.loadRecipe(false, undefined)
return true;
},
/**loadRecipe(false,undefined)
* Clear list of recently imported recipe urls
*/
clearRecentImports: function () {
window.localStorage.setItem(this.LS_IMPORT_RECENT, JSON.stringify([]))
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 () {
return 'javascript:(function(){' +
'if(window.bookmarkletTandoor!==undefined){' +
'bookmarkletTandoor();' +
'} else {' +
`localStorage.setItem("importURL", "${localStorage.getItem('BASE_PATH')}${this.resolveDjangoUrl('api:bookmarkletimport-list')}");` +
`localStorage.setItem("redirectURL", "${localStorage.getItem('BASE_PATH')}${this.resolveDjangoUrl('data_import_url')}");` +
`localStorage.setItem("token", "${window.API_TOKEN}");` +
`document.body.appendChild(document.createElement("script")).src="${localStorage.getItem('BASE_PATH')}${resolveDjangoStatic('/js/bookmarklet.js')}?r="+Math.floor(Math.random()*999999999)}` +
`})()`
},
},
directives: {
hover: {
inserted: function (el) {
el.addEventListener("mouseenter", () => {
el.classList.add("shadow")
})
el.addEventListener("mouseleave", () => {
el.classList.remove("shadow")
})
},
},
},
}
</script>
<style>
.bounce {
animation: bounce 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translate3d(0, 0, 0);
}
@keyframes bounce {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
.scrolling-wrapper-flexbox {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.wrapper-card {
flex: 0 0 auto;
margin-right: 3px;
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<div v-if="recipe_json !== undefined" class="mt-2 mt-md-0">
<h5>Steps</h5>
<div class="row">
<div class="col col-md-12 text-center">
<b-button @click="splitAllSteps('\n')" variant="secondary" v-b-tooltip.hover :title="$t('Split_All_Steps')"><i
class="fas fa-expand-arrows-alt"></i> {{ $t('All') }}
</b-button>
<b-button @click="mergeAllSteps()" variant="primary" class="ml-1" v-b-tooltip.hover :title="$t('Combine_All_Steps')"><i
class="fas fa-compress-arrows-alt"></i> {{ $t('All') }}
</b-button>
</div>
</div>
<div class="row mt-2" v-for="(s, index) in recipe_json.steps"
v-bind:key="index">
<div class="col col-md-4 d-none d-md-block">
<draggable :list="s.ingredients" group="ingredients"
:empty-insert-threshold="10">
<b-list-group-item v-for="i in s.ingredients"
v-bind:key="i.original_text"><i
class="fas fa-arrows-alt"></i> {{ i.original_text }}
</b-list-group-item>
</draggable>
</div>
<div class="col col-md-8 col-12">
<b-input-group>
<b-textarea
style="white-space: pre-wrap" v-model="s.instruction"
max-rows="10"></b-textarea>
<b-input-group-append>
<b-button variant="secondary" @click="splitStep(s,'\n')"><i
class="fas fa-expand-arrows-alt"></i></b-button>
<b-button variant="danger"
@click="recipe_json.steps.splice(recipe_json.steps.findIndex(x => x === s),1)">
<i class="fas fa-trash-alt"></i>
</b-button>
</b-input-group-append>
</b-input-group>
<div class="text-center mt-1">
<b-button @click="mergeStep(s)" variant="primary"
v-if="index + 1 < recipe_json.steps.length"><i
class="fas fa-compress-arrows-alt"></i>
</b-button>
<b-button variant="success"
@click="recipe_json.steps.splice(recipe_json.steps.findIndex(x => x === s) +1,0,{ingredients:[], instruction: ''})">
<i class="fas fa-plus"></i>
</b-button>
</div>
</div>
</div>
</div>
</template>
<script>
import draggable from "vuedraggable";
export default {
name: "ImportViewStepEditor",
components: {
draggable
},
props: {
recipe: undefined
},
data() {
return {
recipe_json: undefined
}
},
watch: {
recipe_json: function () {
this.$emit('change', this.recipe_json)
},
},
mounted() {
this.recipe_json = this.recipe
},
methods: {
/**
* utility function used by splitAllSteps and splitStep to split a single step object into multiple step objects
* @param step: single step
* @param split_character: character to split steps at
* @return array of step objects
*/
splitStepObject: function (step, split_character) {
let steps = []
step.instruction.split(split_character).forEach(part => {
if (part.trim() !== '') {
steps.push({'instruction': part, 'ingredients': []})
}
})
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
*/
splitAllSteps: function (split_character) {
let steps = []
this.recipe_json.steps.forEach(step => {
steps = steps.concat(this.splitStepObject(step, split_character))
})
this.recipe_json.steps = steps
},
/**
* Splits the given step at the split character (e.g. \n or \n\n)
* @param step: step ingredients to split
* @param split_character: character to split steps at
*/
splitStep: function (step, split_character) {
let old_index = this.recipe_json.steps.findIndex(x => x === step)
let new_steps = this.splitStepObject(step, split_character)
this.recipe_json.steps.splice(old_index, 1, ...new_steps)
},
/**
* Merge all steps of a given recipe_json into one
*/
mergeAllSteps: function () {
let step = {'instruction': '', 'ingredients': []}
this.recipe_json.steps.forEach(s => {
step.instruction += s.instruction + '\n'
step.ingredients = step.ingredients.concat(s.ingredients)
})
this.recipe_json.steps = [step]
},
/**
* Merge two steps (the given and next one)
*/
mergeStep: function (step) {
let step_index = this.recipe_json.steps.findIndex(x => x === step)
let removed_steps = this.recipe_json.steps.splice(step_index, 2)
this.recipe_json.steps.splice(step_index, 0, {
'instruction': removed_steps.flatMap(x => x.instruction).join('\n'),
'ingredients': removed_steps.flatMap(x => x.ingredients)
})
},
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,18 @@
import Vue from 'vue'
import App from './ImportView.vue'
import i18n from '@/i18n'
Vue.config.productionTip = false
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
let publicPath = localStorage.STATIC_URL + 'vue/'
if (process.env.NODE_ENV === 'development') {
publicPath = 'http://localhost:8080/'
}
export default __webpack_public_path__ = publicPath // eslint-disable-line
new Vue({
i18n,
render: h => h(App),
}).$mount('#app')

View File

@@ -370,6 +370,9 @@
<div v-for="(ingredient, index) in step.ingredients"
:key="ingredient.id">
<hr class="d-md-none"/>
<div class="text-center">
<small class="text-muted"><i class="fas fa-globe"></i> {{ingredient.original_text}}</small>
</div>
<div class="d-flex">
<div class="flex-grow-0 handle align-self-start">
<button type="button"

View File

@@ -138,6 +138,9 @@
@checked-state-changed="updateIngredientCheckedState"
></step-component>
</div>
<h5 class="d-print-none"><i class="fas fa-file-import"></i> {{ $t("Imported_From") }}</h5>
<span class="text-muted mt-1"><a :href="recipe.source_url">{{ recipe.source_url }}</a></span>
</div>
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="recipe.keywords.length > 0">
<span :key="k.id" v-for="k in recipe.keywords" class="pl-1">
<span :key="k.id" v-for="k in recipe.keywords.filter((kk) => { return kk.show || kk.show === undefined })" class="pl-1">
<a :href="`${resolveDjangoUrl('view_search')}?keyword=${k.id}`"><b-badge pill variant="light"
class="font-weight-normal">{{ k.label }}</b-badge></a>

View File

@@ -2,7 +2,7 @@
<b-card no-body v-hover v-if="recipe">
<a :href="clickUrl()">
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src="recipe_image" v-bind:alt="$t('Recipe_Image')" top></b-card-img-lazy>
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1" v-if="show_context_menu">
<a>
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
</a>
@@ -24,7 +24,7 @@
<b-card-text style="text-overflow: ellipsis">
<template v-if="recipe !== null">
<recipe-rating :recipe="recipe"></recipe-rating>
<template v-if="recipe.description !== null">
<template v-if="recipe.description !== null && recipe.description !== undefined">
<span v-if="recipe.description.length > text_length">
{{ recipe.description.substr(0, text_length) + "\u2026" }}
</span>
@@ -78,6 +78,7 @@ export default {
footer_text: String,
footer_icon: String,
detailed: { type: Boolean, default: true },
show_context_menu: { type: Boolean, default: true }
},
mounted() {},
computed: {

View File

@@ -336,6 +336,11 @@
"ingredient_list": "Ingredient List",
"explain": "Explain",
"filter": "Filter",
"Website": "Website",
"App": "App",
"Bookmarklet": "Bookmarklet",
"import_duplicates": "To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.",
"paste_json": "Paste json or html source here to load recipe.",
"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,24 @@
// containing all data and functions regarding the different integrations
export const INTEGRATIONS = [
{id: 'DEFAULT', name: "Tandoor", import: true, export: true, help_url: 'https://docs.tandoor.dev/features/import_export/#default'},
{id: 'CHEFTAP', name: "Cheftap", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#cheftap'},
{id: 'CHOWDOWN', name: "Chowdown", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#chowdown'},
{id: 'COOKBOOKAPP', name: "CookBookApp", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#cookbookapp'},
{id: 'COOKMATE', name: "Cookmate", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#cookmate'},
{id: 'COPYMETHAT', name: "CopyMeThat", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#copymethat'},
{id: 'DOMESTICA', name: "Domestica", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#domestica'},
{id: 'MEALIE', name: "Mealie", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#mealie'},
{id: 'MEALMASTER', name: "Mealmaster", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#mealmaster'},
{id: 'MELARECIPES', name: "Melarecipes", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#melarecipes'},
{id: 'NEXTCLOUD', name: "Nextcloud Cookbook", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#nextcloud'},
{id: 'OPENEATS', name: "Openeats", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#openeats'},
{id: 'PAPRIKA', name: "Paprika", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#paprika'},
{id: 'PEPPERPLATE', name: "Pepperplate", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#pepperplate'},
{id: 'PLANTOEAT', name: "Plantoeat", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#plantoeat'},
{id: 'RECETTETEK', name: "RecetteTek", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#recettetek'},
{id: 'RECIPEKEEPER', name: "Recipekeeper", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#recipekeeper'},
{id: 'RECIPESAGE', name: "Recipesage", import: true, export: true, help_url: 'https://docs.tandoor.dev/features/import_export/#recipesage'},
{id: 'REZKONV', name: "Rezkonv", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#rezkonv'},
{id: 'SAFRON', name: "Safron", import: true, export: true, help_url: 'https://docs.tandoor.dev/features/import_export/#safron'},
]

View File

@@ -472,7 +472,7 @@ export interface FoodRecipe {
* @type {string}
* @memberof FoodRecipe
*/
name: string;
name?: string;
/**
*
* @type {string}
@@ -537,7 +537,7 @@ export interface FoodSubstitute {
* @type {string}
* @memberof FoodSubstitute
*/
name: string;
name?: string;
}
/**
*
@@ -752,12 +752,6 @@ export interface Ingredient {
* @memberof Ingredient
*/
original_text?: string | null;
/**
*
* @type {string}
* @memberof Ingredient
*/
used_in_recipes?: string;
}
/**
*
@@ -1862,6 +1856,12 @@ export interface RecipeImage {
* @memberof RecipeImage
*/
image?: any | null;
/**
*
* @type {string}
* @memberof RecipeImage
*/
image_url?: string | null;
}
/**
*
@@ -1923,12 +1923,6 @@ export interface RecipeIngredients {
* @memberof RecipeIngredients
*/
original_text?: string | null;
/**
*
* @type {string}
* @memberof RecipeIngredients
*/
used_in_recipes?: string;
}
/**
*
@@ -2197,7 +2191,7 @@ export interface RecipeSimple {
* @type {string}
* @memberof RecipeSimple
*/
name: string;
name?: string;
/**
*
* @type {string}
@@ -5239,10 +5233,11 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
*
* @param {string} id A unique integer value identifying this recipe.
* @param {any} [image]
* @param {string} [imageUrl]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
imageRecipe: async (id: string, image?: any, options: any = {}): Promise<RequestArgs> => {
imageRecipe: async (id: string, image?: any, imageUrl?: string, options: any = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('imageRecipe', 'id', id)
const localVarPath = `/api/recipe/{id}/image/`
@@ -5264,6 +5259,10 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
localVarFormParams.append('image', image as any);
}
if (imageUrl !== undefined) {
localVarFormParams.append('image_url', imageUrl as any);
}
localVarHeaderParameter['Content-Type'] = 'multipart/form-data';
@@ -10353,11 +10352,12 @@ export const ApiApiFp = function(configuration?: Configuration) {
*
* @param {string} id A unique integer value identifying this recipe.
* @param {any} [image]
* @param {string} [imageUrl]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async imageRecipe(id: string, image?: any, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RecipeImage>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.imageRecipe(id, image, options);
async imageRecipe(id: string, image?: any, imageUrl?: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RecipeImage>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.imageRecipe(id, image, imageUrl, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -12186,11 +12186,12 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
*
* @param {string} id A unique integer value identifying this recipe.
* @param {any} [image]
* @param {string} [imageUrl]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
imageRecipe(id: string, image?: any, options?: any): AxiosPromise<RecipeImage> {
return localVarFp.imageRecipe(id, image, options).then((request) => request(axios, basePath));
imageRecipe(id: string, image?: any, imageUrl?: string, options?: any): AxiosPromise<RecipeImage> {
return localVarFp.imageRecipe(id, image, imageUrl, options).then((request) => request(axios, basePath));
},
/**
*
@@ -14004,12 +14005,13 @@ export class ApiApi extends BaseAPI {
*
* @param {string} id A unique integer value identifying this recipe.
* @param {any} [image]
* @param {string} [imageUrl]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public imageRecipe(id: string, image?: any, options?: any) {
return ApiApiFp(this.configuration).imageRecipe(id, image, options).then((request) => request(this.axios, this.basePath));
public imageRecipe(id: string, image?: any, imageUrl?: string, options?: any) {
return ApiApiFp(this.configuration).imageRecipe(id, image, imageUrl, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@@ -50,37 +50,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_DELETE_PROTECTED:
makeToast(i18n.tc("Protected"), i18n.tc("err_deleting_protected_resource"), "danger")
@@ -149,6 +149,27 @@ export function resolveDjangoUrl(url, params = null) {
}
}
/*
* Utility functions to use djangos static files
* */
export const StaticMixin = {
methods: {
/**
* access django static files from javascript
* @param {string} param path to static file
*/
resolveDjangoStatic: function (param) {
return resolveDjangoStatic(param)
},
},
}
export function resolveDjangoStatic(param) {
let url = localStorage.getItem('STATIC_URL') + param
return url.replace('//','/') //replace // with / in case param started with / which resulted in // after the static base url
}
/*
* other utilities
* */

View File

@@ -13,6 +13,10 @@ const pages = {
entry: "./src/apps/OfflineView/main.js",
chunks: ["chunk-vendors"],
},
import_view: {
entry: "./src/apps/ImportView/main.js",
chunks: ["chunk-vendors"],
},
import_response_view: {
entry: "./src/apps/ImportResponseView/main.js",
chunks: ["chunk-vendors"],

View File

@@ -12697,6 +12697,11 @@ vue-infinite-loading@^2.4.5:
resolved "https://registry.yarnpkg.com/vue-infinite-loading/-/vue-infinite-loading-2.4.5.tgz#cc20fd40af7f20188006443c99b60470cf1de1b3"
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":
version "16.8.3"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-16.8.3.tgz#d43e675def5ba9345d6c7f05914c13d861997087"