From 15abe9f24b7f0f413501f5240ada9f4c9a988012 Mon Sep 17 00:00:00 2001 From: Horst Date: Fri, 30 Aug 2024 10:05:19 +0200 Subject: [PATCH] import of gourmet files * in gourmet export as html and zip folder * 'menus' are not imported Note: it appears that the native format '.grmt' does not include the unit for ingredients --- cookbook/forms.py | 3 +- cookbook/integration/gourmet.py | 211 ++++++++++++++++++++++++++++ cookbook/integration/integration.py | 13 ++ cookbook/views/import_export.py | 3 + docs/features/import_export.md | 151 ++++++++++++-------- vue/src/utils/integration.js | 1 + 6 files changed, 320 insertions(+), 62 deletions(-) create mode 100644 cookbook/integration/gourmet.py diff --git a/cookbook/forms.py b/cookbook/forms.py index c0a670613f..a905b10f7e 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -89,12 +89,13 @@ class ImportExportBase(forms.Form): COOKMATE = 'COOKMATE' REZEPTSUITEDE = 'REZEPTSUITEDE' PDF = 'PDF' + GOURMET = 'GOURMET' type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (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'), (MELARECIPES, 'Melarecipes'), - (COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de'))) + (COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de'), (GOURMET, 'Gourmet'))) class MultipleFileInput(forms.ClearableFileInput): diff --git a/cookbook/integration/gourmet.py b/cookbook/integration/gourmet.py new file mode 100644 index 0000000000..0e6718b53f --- /dev/null +++ b/cookbook/integration/gourmet.py @@ -0,0 +1,211 @@ +import base64 +from io import BytesIO +from lxml import etree +import requests +from pathlib import Path + +from bs4 import BeautifulSoup, Tag + +from cookbook.helper.HelperFunctions import validate_import_url +from cookbook.helper.ingredient_parser import IngredientParser +from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time, iso_duration_to_minutes +from cookbook.integration.integration import Integration +from cookbook.models import Ingredient, Recipe, Step, Keyword +from recipe_scrapers import scrape_html + + +class Gourmet(Integration): + + def split_recipe_file(self, file): + encoding = 'utf-8' + byte_string = file.read() + text_obj = byte_string.decode(encoding, errors="ignore") + soup = BeautifulSoup(text_obj, "html.parser") + return soup.find_all("div", {"class": "recipe"}) + + def get_ingredients_recursive(self, step, ingredients, ingredient_parser): + if isinstance(ingredients, Tag): + for ingredient in ingredients.children: + if not isinstance(ingredient, Tag): + continue + + if ingredient.name in ["li"]: + step_name = "".join(ingredient.findAll(text=True, recursive=False)).strip().rstrip(":") + + step.ingredients.add(Ingredient.objects.create( + is_header=True, + note=step_name[:256], + original_text=step_name, + space=self.request.space, + )) + next_ingrediets = ingredient.find("ul", {"class": "ing"}) + self.get_ingredients_recursive(step, next_ingrediets, ingredient_parser) + + else: + try: + amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip()) + 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.text.strip(), + space=self.request.space, + ) + ) + except ValueError: + pass + + def get_recipe_from_file(self, file): + # 'file' comes is as a beautifulsoup object + + source_url = None + for item in file.find_all('a'): + if item.has_attr('href'): + source_url = item.get("href") + break + + name = file.find("p", {"class": "title"}).find("span", {"itemprop": "name"}).text.strip() + + recipe = Recipe.objects.create( + name=name[:128], + source_url=source_url, + created_by=self.request.user, + internal=True, + space=self.request.space, + ) + + for category in file.find_all("span", {"itemprop": "recipeCategory"}): + keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space) + recipe.keywords.add(keyword) + + try: + recipe.servings = parse_servings(file.find("span", {"itemprop": "recipeYield"}).text.strip()) + except AttributeError: + pass + + try: + prep_time = file.find("span", {"itemprop": "prepTime"}).text.strip().split() + prep_time[0] = prep_time[0].replace(',', '.') + if prep_time[1].lower() in ['stunde', 'stunden', 'hour', 'hours']: + prep_time_min = int(float(prep_time[0]) * 60) + elif prep_time[1].lower() in ['tag', 'tage', 'day', 'days']: + prep_time_min = int(float(prep_time[0]) * 60 * 24) + else: + prep_time_min = int(prep_time[0]) + recipe.waiting_time = prep_time_min + except AttributeError: + pass + + try: + cook_time = file.find("span", {"itemprop": "cookTime"}).text.strip().split() + cook_time[0] = cook_time[0].replace(',', '.') + if cook_time[1].lower() in ['stunde', 'stunden', 'hour', 'hours']: + cook_time_min = int(float(cook_time[0]) * 60) + elif cook_time[1].lower() in ['tag', 'tage', 'day', 'days']: + cook_time_min = int(float(cook_time[0]) * 60 * 24) + else: + cook_time_min = int(cook_time[0]) + + recipe.working_time = cook_time_min + except AttributeError: + pass + + for cuisine in file.find_all('span', {'itemprop': 'recipeCuisine'}): + cuisine_name = cuisine.text + keyword = Keyword.objects.get_or_create(space=self.request.space, name=cuisine_name) + if len(keyword): + recipe.keywords.add(keyword[0]) + + for category in file.find_all('span', {'itemprop': 'recipeCategory'}): + category_name = category.text + keyword = Keyword.objects.get_or_create(space=self.request.space, name=category_name) + if len(keyword): + recipe.keywords.add(keyword[0]) + + step = Step.objects.create( + instruction='', + space=self.request.space, + show_ingredients_table=self.request.user.userpreference.show_step_ingredients, + ) + + ingredient_parser = IngredientParser(self.request, True) + + ingredients = file.find("ul", {"class": "ing"}) + self.get_ingredients_recursive(step, ingredients, ingredient_parser) + + instructions = file.find("div", {"class": "instructions"}) + if isinstance(instructions, Tag): + for instruction in instructions.children: + if not isinstance(instruction, Tag) or instruction.text == "": + continue + if instruction.name == "h3": + if step.instruction: + step.save() + recipe.steps.add(step) + step = Step.objects.create( + instruction='', + space=self.request.space, + ) + + step.name = instruction.text.strip()[:128] + else: + if instruction.name == "div": + for instruction_step in instruction.children: + for br in instruction_step.find_all("br"): + br.replace_with("\n") + step.instruction += instruction_step.text.strip() + ' \n\n' + + notes = file.find("div", {"class": "modifications"}) + if notes: + for n in notes.children: + if n.text == "": + continue + if n.name == "h3": + step.instruction += f'*{n.text.strip()}:* \n\n' + else: + for br in n.find_all("br"): + br.replace_with("\n") + + step.instruction += '*' + n.text.strip() + '* \n\n' + + description = '' + try: + description = file.find("div", {"id": "description"}).text.strip() + except AttributeError: + pass + if len(description) <= 512: + recipe.description = description + else: + recipe.description = description[:480] + ' ... (full description below)' + step.instruction += '*Description:* \n\n*' + description + '* \n\n' + + step.save() + recipe.steps.add(step) + + # import the Primary recipe image that is stored in the Zip + try: + image_path = file.find("img").get("src") + image_filename = image_path.split("\\")[1] + + for f in self.import_zip.filelist: + zip_file_name = Path(f.filename).name + if image_filename == zip_file_name: + image_file = self.import_zip.read(f) + image_bytes = BytesIO(image_file) + self.import_recipe_image(recipe, image_bytes, filetype='.jpeg') + break + except Exception as e: + print(recipe.name, ': failed to import image ', str(e)) + + recipe.save() + return recipe + + def get_files_from_recipes(self, recipes, el, cookie): + raise NotImplementedError('Method not implemented in storage integration') + + def get_file_from_recipe(self, recipe): + raise NotImplementedError('Method not implemented in storage integration') diff --git a/cookbook/integration/integration.py b/cookbook/integration/integration.py index 7386b50d92..85e3180a03 100644 --- a/cookbook/integration/integration.py +++ b/cookbook/integration/integration.py @@ -153,6 +153,19 @@ def do_import(self, files, il, import_duplicates): il.total_recipes = len(new_file_list) file_list = new_file_list + if isinstance(self, cookbook.integration.gourmet.Gourmet): + self.import_zip = import_zip + new_file_list = [] + for file in file_list: + if file.file_size == 0: + next + if file.filename.startswith("index.htm"): + next + if file.filename.endswith(".htm"): + new_file_list += self.split_recipe_file(BytesIO(import_zip.read(file.filename))) + il.total_recipes = len(new_file_list) + file_list = new_file_list + for z in file_list: try: if not hasattr(z, 'filename') or isinstance(z, Tag): diff --git a/cookbook/views/import_export.py b/cookbook/views/import_export.py index b80de200b9..d36da2c354 100644 --- a/cookbook/views/import_export.py +++ b/cookbook/views/import_export.py @@ -31,6 +31,7 @@ from cookbook.integration.rezeptsuitede import Rezeptsuitede from cookbook.integration.rezkonv import RezKonv from cookbook.integration.saffron import Saffron +from cookbook.integration.gourmet import Gourmet from cookbook.models import ExportLog, Recipe from recipes import settings @@ -80,6 +81,8 @@ def get_integration(request, export_type): return Cookmate(request, export_type) if export_type == ImportExportBase.REZEPTSUITEDE: return Rezeptsuitede(request, export_type) + if export_type == ImportExportBase.GOURMET: + return Gourmet(request, export_type) @group_required('user') diff --git a/docs/features/import_export.md b/docs/features/import_export.md index 4cb9337e30..f6408e75d1 100644 --- a/docs/features/import_export.md +++ b/docs/features/import_export.md @@ -1,9 +1,9 @@ -This application features a very versatile import and export feature in order +This application features a very versatile import and export feature in order to offer the best experience possible and allow you to freely choose where your data goes. !!! WARNING "WIP" - The Module is relatively new. There is a known issue with [Timeouts](https://github.com/vabene1111/recipes/issues/417) on large exports. - A fix is being developed and will likely be released with the next version. +The Module is relatively new. There is a known issue with [Timeouts](https://github.com/vabene1111/recipes/issues/417) on large exports. +A fix is being developed and will likely be released with the next version. The Module is built with maximum flexibility and expandability in mind and allows to easily add new integrations to allow you to both import and export your recipes into whatever format you desire. @@ -12,59 +12,64 @@ Feel like there is an important integration missing? Just take a look at the [in if your favorite one is missing. !!! info "Export" - I strongly believe in everyone's right to use their data as they please and therefore want to give you - the best possible flexibility with your recipes. - That said for most of the people getting this application running with their recipes is the biggest priority. - Because of this importing as many formats as possible is prioritized over exporting. - Exporter for the different formats will follow over time. +I strongly believe in everyone's right to use their data as they please and therefore want to give you +the best possible flexibility with your recipes. +That said for most of the people getting this application running with their recipes is the biggest priority. +Because of this importing as many formats as possible is prioritized over exporting. +Exporter for the different formats will follow over time. Overview of the capabilities of the different integrations. | Integration | Import | Export | Images | -|--------------------| ------ | -- | ------ | -| Default | ✔️ | ✔️ | ✔️ | -| Nextcloud | ✔️ | ⌚ | ✔️ | -| Mealie | ✔️ | ⌚ | ✔️ | -| Chowdown | ✔️ | ⌚ | ✔️ | -| Safron | ✔️ | ✔️ | ❌ | -| Paprika | ✔️ | ⌚ | ✔️ | -| ChefTap | ✔️ | ❌ | ❌ | -| Pepperplate | ✔️ | ⌚ | ❌ | -| RecipeSage | ✔️ | ✔️ | ✔️ | -| Rezeptsuite.de | ✔️ | ❌ | ✔️ | -| Domestica | ✔️ | ⌚ | ✔️ | -| MealMaster | ✔️ | ❌ | ❌ | -| RezKonv | ✔️ | ❌ | ❌ | -| OpenEats | ✔️ | ❌ | ⌚ | -| Plantoeat | ✔️ | ❌ | ✔ | -| CookBookApp | ✔️ | ⌚ | ✔️ | -| CopyMeThat | ✔️ | ❌ | ✔️ | -| Melarecipes | ✔️ | ⌚ | ✔️ | -| Cookmate | ✔️ | ⌚ | ✔️ | -| PDF (experimental) | ⌚️ | ✔️ | ✔️ | +| ------------------ | ------ | ------ | ------ | +| Default | ✔️ | ✔️ | ✔️ | +| Nextcloud | ✔️ | ⌚ | ✔️ | +| Mealie | ✔️ | ⌚ | ✔️ | +| Chowdown | ✔️ | ⌚ | ✔️ | +| Safron | ✔️ | ✔️ | ❌ | +| Paprika | ✔️ | ⌚ | ✔️ | +| ChefTap | ✔️ | ❌ | ❌ | +| Pepperplate | ✔️ | ⌚ | ❌ | +| RecipeSage | ✔️ | ✔️ | ✔️ | +| Rezeptsuite.de | ✔️ | ❌ | ✔️ | +| Domestica | ✔️ | ⌚ | ✔️ | +| MealMaster | ✔️ | ❌ | ❌ | +| RezKonv | ✔️ | ❌ | ❌ | +| OpenEats | ✔️ | ❌ | ⌚ | +| Plantoeat | ✔️ | ❌ | ✔ | +| CookBookApp | ✔️ | ⌚ | ✔️ | +| CopyMeThat | ✔️ | ❌ | ✔️ | +| Melarecipes | ✔️ | ⌚ | ✔️ | +| Cookmate | ✔️ | ⌚ | ✔️ | +| PDF (experimental) | ⌚️ | ✔️ | ✔️ | +| Gourmet | ✔️ | ❌ | ✔️ | ✔️ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented ## Default + The default integration is the built in (and preferred) way to import and export recipes. It is maintained with new fields added and contains all data to transfer your recipes from one installation to another. -It is also one of the few recipe formats that is actually structured in a way that allows for -easy machine readability if you want to use the data for any other purpose. +It is also one of the few recipe formats that is actually structured in a way that allows for +easy machine readability if you want to use the data for any other purpose. ## RecipeSage -Go to Settings > Export Recipe Data and select `EXPORT AS JSON-LD (BEST)`. Then simply upload the exported file + +Go to Settings > Export Recipe Data and select `EXPORT AS JSON-LD (BEST)`. Then simply upload the exported file to Tandoor. -The RecipeSage integration also allows exporting. To migrate from Tandoor to RecipeSage simply export with Recipe Sage +The RecipeSage integration also allows exporting. To migrate from Tandoor to RecipeSage simply export with Recipe Sage selected and import the json file in RecipeSage. Images are currently not supported for exporting. ## Domestica -Go to Import/Export and select `Export Recipes`. Then simply upload the exported file + +Go to Import/Export and select `Export Recipes`. Then simply upload the exported file to Tandoor. ## Nextcloud -Importing recipes from Nextcloud cookbook is very easy and since Nextcloud Cookbook provides nice, standardized and + +Importing recipes from Nextcloud cookbook is very easy and since Nextcloud Cookbook provides nice, standardized and structured information most of your recipe is going to be intact. Follow these steps to import your recipes @@ -77,10 +82,9 @@ Follow these steps to import your recipes You will get a `Recipes.zip` file. Simply upload the file and choose the Nextcloud Cookbook type. !!! WARNING "Folder Structure" - Importing only works if the folder structure is correct. If you do not use the standard path or create the - zip file in any other way make sure the structure is as follows - ``` - Recipes.zip/ +Importing only works if the folder structure is correct. If you do not use the standard path or create the +zip file in any other way make sure the structure is as follows +` Recipes.zip/ └── Recipes/ ├── Recipe1/ │ ├── recipe.json @@ -88,27 +92,29 @@ You will get a `Recipes.zip` file. Simply upload the file and choose the Nextclo └── Recipe2/ ├── recipe.json └── full.jpg - ``` + ` ## Mealie -Mealie provides structured data similar to nextcloud. -To migrate your recipes +Mealie provides structured data similar to nextcloud. + +To migrate your recipes 1. Go to your Mealie settings and create a new Backup. 2. Download the backup by clicking on it and pressing download (this wasn't working for me, so I had to manually pull it from the server). 3. Upload the entire `.zip` file to the importer page and import everything. ## Chowdown -Chowdown stores all your recipes in plain text markdown files in a directory called `_recipes`. + +Chowdown stores all your recipes in plain text markdown files in a directory called `_recipes`. Images are saved in a directory called `images`. -In order to import your Chowdown recipes simply create a `.zip` file from those two folders and import them. +In order to import your Chowdown recipes simply create a `.zip` file from those two folders and import them. The folder structure should look as follows !!! info "_recipes" - For some reason chowdown uses `_` before the `recipes` folder. To avoid confusion the import supports both - `_recipes` and `recipes` +For some reason chowdown uses `_`before the`recipes`folder. To avoid confusion the import supports both + `\_recipes`and`recipes` ``` Recipes.zip/ @@ -123,31 +129,35 @@ Recipes.zip/ ``` ## Safron + Go to your safron settings page and export your recipes. Then simply upload the entire `.zip` file to the importer. !!! warning "Images" - Safron exports do not contain any images. They will be lost during import. +Safron exports do not contain any images. They will be lost during import. ## Paprika + A Paprika export contains a folder with a html representation of your recipes and a `.paprikarecipes` file. -The `.paprikarecipes` file is basically just a zip with gzipped contents. Simply upload the whole file and import -all your recipes. +The `.paprikarecipes` file is basically just a zip with gzipped contents. Simply upload the whole file and import +all your recipes. ## Pepperplate + Pepperplate provides a `.zip` file containing all of your recipes as `.txt` files. These files are well-structured and allow the import of all data without losing anything. -Simply export the recipes from Pepperplate and upload the zip to Tandoor. Images are not included in the export and +Simply export the recipes from Pepperplate and upload the zip to Tandoor. Images are not included in the export and thus cannot be imported. ## ChefTap + ChefTaps allows you to export your recipes from the app (I think). The export is a zip file containing a folder called `cheftap_export` which in turn contains `.txt` files with your recipes. This format is basically completely unstructured and every export looks different. This makes importing it very hard -and leads to suboptimal results. Images are also not supported as they are not included in the export (at least +and leads to suboptimal results. Images are also not supported as they are not included in the export (at least the tests I had). Usually the import should recognize all ingredients and put everything else into the instructions. If your import fails @@ -156,31 +166,36 @@ or is worse than this feel free to provide me with more example data and I can t As ChefTap cannot import these files anyway there won't be an exporter implemented in Tandoor. ## MealMaster -Meal master can be imported by uploading one or more meal master files. -The files should either be `.txt`, `.MMF` or `.MM` files. + +Meal master can be imported by uploading one or more meal master files. +The files should either be `.txt`, `.MMF` or `.MM` files. The MealMaster spec allows for many variations. Currently, only the one column format for ingredients is supported. Second line notes to ingredients are currently also not imported as a note but simply put into the instructions. If you have MealMaster recipes that cannot be imported feel free to raise an issue. ## RezKonv -The RezKonv format is primarily used in the german recipe manager RezKonv Suite. + +The RezKonv format is primarily used in the german recipe manager RezKonv Suite. To migrate from RezKonv Suite to Tandoor select `Export > Gesamtes Kochbuch exportieren` (the last option in the export menu). The generated file can simply be imported into Tandoor. As I only had limited sample data feel free to open an issue if your RezKonv export cannot be imported. ## Recipekeeper -Recipe keeper allows you to export a zip file containing recipes and images using its apps. + +Recipe keeper allows you to export a zip file containing recipes and images using its apps. This zip file can simply be imported into Tandoor. ## OpenEats + OpenEats does not provide any way to export the data using the interface. Luckily it is relatively easy to export it from the command line. You need to run the command `python manage.py dumpdata recipe ingredient` inside of the application api container. If you followed the default installation method you can use the following command `docker-compose -f docker-prod.yml run --rm --entrypoint 'sh' api ./manage.py dumpdata recipe ingredient`. This command might also work `docker exec -it openeats_api_1 ./manage.py dumpdata recipe ingredient rating recipe_groups > recipe_ingredients.json` Store the outputted json string in a `.json` file and simply import it using the importer. The file should look something like this + ```json [ { @@ -231,30 +246,44 @@ 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. + +RecetteTek exports are `.rtk` files which can simply be uploaded to tandoor to import all your recipes. ## Rezeptsuite.de + Rezeptsuite.de exports are `.xml` files which can simply be uploaded to tandoor to import all your recipes. It appears that Reptsuite, depending on the client, might export a `.zip` file containing a `.cml` file. -If this happens just unzip the zip file and change `.cml` to `.xml` to import your recipes. +If this happens just unzip the zip file and change `.cml` to `.xml` to import 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). +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. -For that to work it downloads a chromium binary of about 140 MB to your server and then renders the PDF files using that. +The PDF Exporter is an experimental feature that uses the puppeteer browser renderer to render each recipe and export it to PDF. +For that to work it downloads a chromium binary of about 140 MB to your server and then renders the PDF files using that. Since that is something some server administrators might not want there the PDF exporter is disabled by default and can be enabled with `ENABLE_PDF_EXPORT=1` in `.env`. -See [this issue](https://github.com/TandoorRecipes/recipes/pull/1211) for more discussion on this and +See [this issue](https://github.com/TandoorRecipes/recipes/pull/1211) for more discussion on this and [this issue](https://github.com/TandoorRecipes/recipes/issues/781) for the future plans to support server side rendering. + +## Gourmet + +An importer for files from [Gourmet](https://github.com/thinkle/gourmet/). As the `.grmt` files appears to lack the unit for ingredients +a file with `.zip` file with `.htm` and `.jpg`is expected. + +To generate the file export to 'html' in Gourmet and zip the folder generated. + +The import of menues is not supported + +Export is not supported due to problems with `.grmt` format. diff --git a/vue/src/utils/integration.js b/vue/src/utils/integration.js index 4fbf10f362..dfcb955d0b 100644 --- a/vue/src/utils/integration.js +++ b/vue/src/utils/integration.js @@ -22,4 +22,5 @@ export const INTEGRATIONS = [ {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'}, {id: 'REZEPTSUITEDE', name: "Rezeptsuite.de", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#rezeptsuitede'}, + {id: 'GOURMET', name: "Gourmet", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#gourmet'}, ]