Skip to content

Commit

Permalink
Merge pull request #22 from luebbert42/feature/export-recipe-to-paprika
Browse files Browse the repository at this point in the history
Export to paprika recipe manager
  • Loading branch information
ephes authored Feb 26, 2023
2 parents 554632c + c030a03 commit 33b4fbf
Show file tree
Hide file tree
Showing 8 changed files with 393 additions and 12 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,10 @@ __pycache__

# environment
.env

# export files for Paprika app accidently create in source code directory
*.paprikarecipes

# vim
.*swp
.*swo
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@ repos:
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/asottile/pyupgrade
rev: v3.3.0
rev: v3.3.1
hooks:
- id: pyupgrade
args: [--py310-plus]
- repo: https://github.com/psf/black
rev: 22.10.0
rev: 23.1.0
hooks:
- id: black
language_version: python3.11

- repo: https://github.com/timothycrosley/isort
# isort config is in setup.cfg
rev: 5.10.1
rev: 5.12.0
hooks:
- id: isort
language_version: python3
Expand Down
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ Options:
--help Show this message and exit.

Commands:
backup-favorites Store kptncook favorites in local repository.
kptncook-access-token Get access token for kptncook.
kptncook-today List all recipes for today from the kptncook...
list-recipes List all locally saved recipes.
save-todays-recipes Save recipes for today from kptncook site.
search-by-id Search for a recipe by id in kptncook api, id...
sync Fetch recipes for today from api, save them to...
sync-with-mealie Sync locally saced recipes with mealie.
backup-favorites Store kptncook favorites in local repository.
kptncook-access-token Get access token for kptncook.
kptncook-today List all recipes for today from the kptncook...
list-recipes List all locally saved recipes.
save-todays-recipes Save recipes for today from kptncook site.
search-by-id Search for a recipe by id in kptncook api, id...
sync Fetch recipes for today from api, save them to...
sync-with-mealie Sync locally saced recipes with mealie.
export-recipes-to-paprika Export a recipe by id or all recipes to Paprika app
```
## Environment
Expand Down Expand Up @@ -78,6 +79,12 @@ Install a symlinked development version of the package:
```
$ flit install -s
```
Install the git pre-commit hooks:
```
$ pre-commit install
```
## Run Tests
Flit should have already installed pytest:
Expand Down
56 changes: 55 additions & 1 deletion kptncook/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import sys
from datetime import date
from typing import Optional

import httpx
import typer
Expand All @@ -16,6 +17,7 @@
from .config import settings
from .mealie import MealieApiClient, kptncook_to_mealie
from .models import Recipe
from .paprika import PaprikaExporter
from .repositories import RecipeRepository

__all__ = [
Expand All @@ -27,9 +29,10 @@
"get_kptncook_access_token",
"list_recipes",
"search_kptncook_recipe_by_id",
"export_recipes_to_paprika",
]

__version__ = "0.0.9"
__version__ = "0.0.10"
cli = typer.Typer()


Expand Down Expand Up @@ -80,6 +83,16 @@ def get_kptncook_recipes_from_repository() -> list[Recipe]:
return recipes


def get_recipe_from_repository_by_oid(oid: str) -> list[Recipe]:
"""
get one single recipe from local repository
:param oid: oid of recipe
:return: list
"""
recipes = get_kptncook_recipes_from_repository()
return [(recipe) for num, recipe in enumerate(recipes) if recipe.id.oid == oid]


@cli.command(name="sync-with-mealie")
def sync_with_mealie():
"""
Expand Down Expand Up @@ -191,5 +204,46 @@ def search_kptncook_recipe_by_id(_id: str):
rprint(f"Added recipe {id_type} {id_value} to local repository")


# Optional needed by typer, standalone to trick pyupgrade to not change it
OptionalId = Optional[str]


@cli.command(name="export-recipes-to-paprika")
def export_recipes_to_paprika(_id: OptionalId = typer.Argument(None)):
"""
Export one recipe or all recipes to Paprika app
Example usage 1: kptncook export-recipes-to-paprika 635a68635100007500061cd7
Example usage 2: kptncook export-recipes-to-paprika
"""
if _id:
recipes = get_recipe_by_id(_id)
else:
recipes = get_kptncook_recipes_from_repository()
exporter = PaprikaExporter()
filename = exporter.export(recipes=recipes)
rprint(
"\n The data was exported to '%s'. Open the export file with the Paprika App.\n"
% filename
)


def get_recipe_by_id(_id: str):
parsed = parse_id(_id)
if parsed is None:
rprint("Could not parse id")
sys.exit(1)
# we can expect always an oid here - correct?
id_type, id_value = parsed
found_recipes = get_recipe_from_repository_by_oid(oid=id_value)
if len(found_recipes) == 0:
rprint("Recipe not found.")
sys.exit(1)
if len(found_recipes) > 1:
rprint("More than one recipe found with that ID.")
sys.exit(1)
return found_recipes


if __name__ == "__main__":
cli()
144 changes: 144 additions & 0 deletions kptncook/paprika.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""
Export a single recipe to Paprika App
file format:
1. export recipe to json
2. compress file as gz: naming convention: a_recipe_name.paprikarecipe (Singular)
3. zip this file as some_recipes.paprikarecipes (Plural!)
"""
import base64
import glob
import gzip
import os
import re
import secrets
import shutil
import tempfile
import zipfile
from datetime import datetime
from pathlib import Path

import httpx
from jinja2 import Environment, FileSystemLoader
from unidecode import unidecode

from kptncook.config import settings
from kptncook.models import Image, Recipe


class PaprikaExporter:
def export(self, recipes: list[Recipe]) -> str:
export_data = self.get_export_data(recipes=recipes)
filename = self.get_export_filename(export_data=export_data, recipes=recipes)
tmp_dir = tempfile.mkdtemp()
filename_full_path = self.save_recipes(
export_data=export_data, directory=tmp_dir, filename=filename
)
self.move_to_target_dir(
source=filename_full_path, target=os.path.join(str(Path.cwd()), filename)
)
return filename

def get_export_filename(
self, export_data: dict[str, str], recipes: list[Recipe]
) -> str:
if len(export_data) == 1:
return (
self.asciify_string(s=recipes[0].localized_title.de) + ".paprikarecipes"
)
else:
return "allrecipes.paprikarecipes"

def get_export_data(self, recipes: list[Recipe]) -> dict[str, str]:
renderer = ExportRenderer()
export_data = dict()
for recipe in recipes:
cover_filename, cover_img = self.get_cover_img_as_base64_string(
recipe=recipe
)
recipe_as_json = renderer.render(
template_name="paprika.jinja2.json",
recipe=recipe,
dtnow=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
cover_filename=cover_filename,
hash=secrets.token_hex(32),
cover_img=cover_img,
)
export_data[str(recipe.id.oid)] = recipe_as_json
return export_data

def move_to_target_dir(self, source: str, target: str) -> str:
return shutil.move(source, target)

def asciify_string(self, s) -> str:
s = unidecode(s)
s = re.sub(r"[^\w\s]", "_", s)
s = re.sub(r"\s+", "_", s)
return s

def save_recipes(
self, export_data: dict[str], filename: str, directory: str
) -> str:
for id, recipe_as_json in export_data.items():
recipe_as_gz = os.path.join(directory, "recipe_" + id + ".paprikarecipe")
with gzip.open(recipe_as_gz, "wb") as f:
f.write(recipe_as_json.encode("utf-8"))
filename_full_path = os.path.join(directory, filename)
with zipfile.ZipFile(
filename_full_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True
) as zip_file:
gz_files = glob.glob(os.path.join(directory, "*.paprikarecipe"))
print(gz_files)
for gz_file in gz_files:
zip_file.write(gz_file, arcname=os.path.basename(gz_file))
return filename_full_path

def get_cover_img_as_base64_string(self, recipe: Recipe) -> tuple[str, str]:
cover = self.get_cover(image_list=recipe.image_list)
if cover is None:
raise ValueError("No cover image found")
cover_url = recipe.get_image_url(api_key=settings.kptncook_api_key)
if not isinstance(cover_url, str):
raise ValueError("Cover URL must be a string")
try:
response = httpx.get(cover_url)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
print(
f'Cover image for "{recipe.localized_title.de}" not found online any more.'
)
else:
print(
f"While trying to fetch the cover img a HTTP error occured: {exc.response.status_code}: {exc}"
)
return None, None
return cover.name, base64.b64encode(response.content).decode("utf-8")

def get_cover(self, image_list: list[Image]) -> Image | None:
if not isinstance(image_list, list):
raise ValueError("Parameter image_list must be a list")
try:
[cover] = [i for i in image_list if i.type == "cover"]
except ValueError:
return None
return cover


class ExportRenderer:
def render(self, template_name: str, recipe: Recipe, **kwargs) -> str:
environment = self.get_environment()
template = environment.get_template(template_name)
return template.render(recipe=recipe, **kwargs)

@staticmethod
def get_template_dir() -> str:
module_path = os.path.abspath(__file__)
real_path = os.path.realpath(module_path)
root_dir = os.path.dirname(os.path.dirname(real_path))
return os.path.join(root_dir, "templates")

def get_environment(self) -> Environment:
return Environment(
loader=FileSystemLoader(self.get_template_dir()), trim_blocks=True
)
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,23 @@ dependencies = [
"pydantic[dotenv] >= 1.9",
"typer >= 0.4",
"click",
"unidecode",
"jinja2"
]

[project.optional-dependencies]
test = [
"pytest >= 6",
"pytest-cov >= 3",
"pytest-mock",
]
doc = [
"mkdocs >= 1.2",
]
dev = [
"jupyterlab >= 3.2.9",
"mypy",
"pre-commit"
]

[project.urls]
Expand Down
24 changes: 24 additions & 0 deletions templates/paprika.jinja2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"uid":"{{recipe.id.oid}}",
"name":"{{recipe.localized_title.de}}",
"directions": "{% for step in recipe.steps %}{{step.title.de}}\n{% endfor %}",
"servings":"2",
"rating":0,
"difficulty":"",
"ingredients":"{% for ingredient in recipe.ingredients %}{% if ingredient.quantity %}{{'{0:g}'.format(ingredient.quantity) }}{% endif %} {{ingredient.measure|default('',true)}} {{ingredient.ingredient.uncountableTitle.de|default('',true)}}\n{% endfor %}",
"notes":"",
"created":"{{dtnow}}",
"image_url":null,
"cook_time":"{{recipe.cooking_time|default('',true)}}",
"prep_time":"{{recipe.preparation_time|default('',true)}}",
"source":"Kptncook",
"source_url":"",
"hash" : "{{hash}}",
"photo_hash":null,
"photos":[],
"photo": "{{cover_filename}}",
"nutritional_info":"{% for nutrient, amount in recipe.recipe_nutrition %}{{nutrient}}: {{amount}}\n{% endfor %}",
"photo_data":"{{cover_img}}",
"photo_large":null,
"categories":["Kptncook"]
}
Loading

0 comments on commit 33b4fbf

Please sign in to comment.