v0.2.0 (#143)
* Changed uvicorn port to 80 * Changed port in docker-compose to match dockerfile * Readded environment variables in docker-compose * production image rework * Use opengraph metadata to make basic recipe cards when full recipe metadata is not available * fixed instrucitons on parse * add last_recipe * automated testing * roadmap update * Sqlite (#75) * file structure * auto-test * take 2 * refactor ap scheduler and startup process * fixed scraper error * database abstraction * database abstraction * port recipes over to new schema * meal migration * start settings migration * finale mongo port * backup improvements * migration imports to new DB structure * unused import cleanup * docs strings * settings and theme import logic * cleanup * fixed tinydb error * requirements * fuzzy search * remove scratch file * sqlalchemy models * improved search ui * recipe models almost done * sql modal population * del scratch * rewrite database model mixins * mostly grabage * recipe updates * working sqllite * remove old files and reorganize * final cleanup Co-authored-by: Hayden <hay-kot@pm.me> * Backup card (#78) * backup / import dialog * upgrade to new tag method * New import card * rename settings.py to app_config.py * migrate to poetry for development * fix failing test Co-authored-by: Hayden <hay-kot@pm.me> * added mkdocs to docker-compose * Translations (#72) * Translations + danish * changed back proxy target to use ENV * Resolved more merge conflicts * Removed test in translation * Documentation of translations * Updated translations * removed old packages Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com> * fail to start bug fixes * feature: prep/cook/total time slots (#80) Co-authored-by: Hayden <hay-kot@pm.me> * missing bind attributes * Bug fixes (#81) * fix: url remains after succesful import * docs: changelog + update todos * arm image * arm compose * compose updates * update poetry * arm support Co-authored-by: Hayden <hay-kot@pm.me> * dockerfile hotfix * dockerfile hotfix * Version Release Final Touches (#84) * Remove slim * bug: opacity issues * bug: startup failure with no database * ci/cd on dev branch * formatting * v0.1.0 documentation Co-authored-by: Hayden <hay-kot@pm.me> * db init hotfix * bug: fix crash in mongo * fix mongo bug * fixed version notifier * finale changelog * Dropping Mongo From Dev Branch (#89) * Fix link to Docker Hub Found an extra s. DESTROYED it. * initial pass * second pass cleanup * backup card framework * backup card functionality * translation * upload button vile creation * Release v0.1.0 Candidate (#85) * Changed uvicorn port to 80 * Changed port in docker-compose to match dockerfile * Readded environment variables in docker-compose * production image rework * Use opengraph metadata to make basic recipe cards when full recipe metadata is not available * fixed instrucitons on parse * add last_recipe * automated testing * roadmap update * Sqlite (#75) * file structure * auto-test * take 2 * refactor ap scheduler and startup process * fixed scraper error * database abstraction * database abstraction * port recipes over to new schema * meal migration * start settings migration * finale mongo port * backup improvements * migration imports to new DB structure * unused import cleanup * docs strings * settings and theme import logic * cleanup * fixed tinydb error * requirements * fuzzy search * remove scratch file * sqlalchemy models * improved search ui * recipe models almost done * sql modal population * del scratch * rewrite database model mixins * mostly grabage * recipe updates * working sqllite * remove old files and reorganize * final cleanup Co-authored-by: Hayden <hay-kot@pm.me> * Backup card (#78) * backup / import dialog * upgrade to new tag method * New import card * rename settings.py to app_config.py * migrate to poetry for development * fix failing test Co-authored-by: Hayden <hay-kot@pm.me> * added mkdocs to docker-compose * Translations (#72) * Translations + danish * changed back proxy target to use ENV * Resolved more merge conflicts * Removed test in translation * Documentation of translations * Updated translations * removed old packages Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com> * fail to start bug fixes * feature: prep/cook/total time slots (#80) Co-authored-by: Hayden <hay-kot@pm.me> * missing bind attributes * Bug fixes (#81) * fix: url remains after succesful import * docs: changelog + update todos * arm image * arm compose * compose updates * update poetry * arm support Co-authored-by: Hayden <hay-kot@pm.me> * dockerfile hotfix * dockerfile hotfix * Version Release Final Touches (#84) * Remove slim * bug: opacity issues * bug: startup failure with no database * ci/cd on dev branch * formatting * v0.1.0 documentation Co-authored-by: Hayden <hay-kot@pm.me> * db init hotfix * bug: fix crash in mongo * fix mongo bug * fixed version notifier * finale changelog Co-authored-by: kentora <=> Co-authored-by: Hayden <hay-kot@pm.me> Co-authored-by: Richard Mitic <richard.h.mitic@gmail.com> Co-authored-by: kentora <kentora@kentora.dk> * build container * webscraper hotfix * dev bug: change data location to prevent reloads * api docs * api docs bug * workflow update Co-authored-by: David Young <davidy@funkypenguin.co.nz> Co-authored-by: Hayden <hay-kot@pm.me> Co-authored-by: Richard Mitic <richard.h.mitic@gmail.com> Co-authored-by: kentora <kentora@kentora.dk> * Add French Translation (#93) * New tests (#94) * dev-bug: fixed vscode freezes * test: refactor database init to support tests Co-authored-by: Hayden <hay-kot@pm.me> * Mealplan CRUD Tests (#95) * dev-bug: fixed vscode freezes * test: refactor database init to support tests * mealplan CRUD testing Co-authored-by: Hayden <hay-kot@pm.me> * Fix typos (#96) * Settings, Themes and Migration Route Tests (#100) * dev-bug: fixed vscode freezes * test: refactor database init to support tests * mealplan CRUD testing * restructure test folder * git attributes * tests: migration, settings, theme routes testing Co-authored-by: Hayden <hay-kot@pm.me> * Refactor + New Docker File (#105) * dev-bug: fixed vscode freezes * test: refactor database init to support tests * mealplan CRUD testing * restructure test folder * git attributes * tests: migration, settings, theme routes testing * docker-file shrink * rebuild * refactor: moving directories around * adding funding Co-authored-by: Hayden <hay-kot@pm.me> * Meal planner improvements (#107) * dev-bug: fixed vscode freezes * test: refactor database init to support tests * mealplan CRUD testing * restructure test folder * git attributes * tests: migration, settings, theme routes testing * docker-file shrink * rebuild * refactor: moving directories around * adding funding * mealplan redesign Co-authored-by: Hayden <hay-kot@pm.me> * Upload component (#108) * unified upload button + download backups * javascript toolings * fix vuetur config * fixed type check error * refactor: clean up bag javascript Co-authored-by: Hayden <hay-kot@pm.me> * Upload component (#113) * unified upload button + download backups * javascript toolings * fix vuetur config * fixed type check error * refactor: clean up bag javascript * UI updates + name validation * docs: changelog + sp * fixed route links * changelog Co-authored-by: Hayden <hay-kot@pm.me> * fixed menu links * fixed poetry install on docker.dev build * Migration redesign (#119) * migration redesign init * new color picker * changelog * added UI language selection * fix layout issue on recipe editor * remove git as dependency * added UI editor for original URL * CI/CD Tests * test: fixed migration routes Co-authored-by: Hayden <hay-kot@pm.me> * Fix link to dev-notes.md (#110) * translation: add swedish (#128) * language: da is Danish * translations: add swedish * scraper: unescape html in instructions (#129) Some urls erroneously deliver escaped html their instructions, sometimes they are even escaped on multiple levels like here: https://www.ica.se/recept/kladdig-kladdkaka-722982/ ``` >>> normalize_instruction("S&auml;tt ugnen p&aring; 200&deg;C.") 'Sätt ugnen på 200°C.' ``` * v0.2.0 Updates (#130) * migration redesign init * new color picker * changelog * added UI language selection * fix layout issue on recipe editor * remove git as dependency * added UI editor for original URL * CI/CD Tests * test: fixed migration routes * test todos * bug/added docker volume * chowdow test data * partial image recipe image testing * added card section card * settings form * homepage cetegory ui * frontend category placeholder * fixed broken scheduler * remove old files * removed temp test Co-authored-by: Hayden <hay-kot@pm.me> * Fix missing translations key (#133) * translation: add simplified & traditional chinese * Fix missing translations * fix chinese translations * v0.2.0 Release Candidate (#141) * Fix link to Docker Hub Found an extra s. DESTROYED it. * Release v0.1.0 Candidate (#85) * Changed uvicorn port to 80 * Changed port in docker-compose to match dockerfile * Readded environment variables in docker-compose * production image rework * Use opengraph metadata to make basic recipe cards when full recipe metadata is not available * fixed instrucitons on parse * add last_recipe * automated testing * roadmap update * Sqlite (#75) * file structure * auto-test * take 2 * refactor ap scheduler and startup process * fixed scraper error * database abstraction * database abstraction * port recipes over to new schema * meal migration * start settings migration * finale mongo port * backup improvements * migration imports to new DB structure * unused import cleanup * docs strings * settings and theme import logic * cleanup * fixed tinydb error * requirements * fuzzy search * remove scratch file * sqlalchemy models * improved search ui * recipe models almost done * sql modal population * del scratch * rewrite database model mixins * mostly grabage * recipe updates * working sqllite * remove old files and reorganize * final cleanup Co-authored-by: Hayden <hay-kot@pm.me> * Backup card (#78) * backup / import dialog * upgrade to new tag method * New import card * rename settings.py to app_config.py * migrate to poetry for development * fix failing test Co-authored-by: Hayden <hay-kot@pm.me> * added mkdocs to docker-compose * Translations (#72) * Translations + danish * changed back proxy target to use ENV * Resolved more merge conflicts * Removed test in translation * Documentation of translations * Updated translations * removed old packages Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com> * fail to start bug fixes * feature: prep/cook/total time slots (#80) Co-authored-by: Hayden <hay-kot@pm.me> * missing bind attributes * Bug fixes (#81) * fix: url remains after succesful import * docs: changelog + update todos * arm image * arm compose * compose updates * update poetry * arm support Co-authored-by: Hayden <hay-kot@pm.me> * dockerfile hotfix * dockerfile hotfix * Version Release Final Touches (#84) * Remove slim * bug: opacity issues * bug: startup failure with no database * ci/cd on dev branch * formatting * v0.1.0 documentation Co-authored-by: Hayden <hay-kot@pm.me> * db init hotfix * bug: fix crash in mongo * fix mongo bug * fixed version notifier * finale changelog Co-authored-by: kentora <=> Co-authored-by: Hayden <hay-kot@pm.me> Co-authored-by: Richard Mitic <richard.h.mitic@gmail.com> Co-authored-by: kentora <kentora@kentora.dk> * build container * webscraper hotfix * notes hot fix * bug: mongo updates fail #99 * Fix error message (#101) * gh funding * Create Issue Templates (#125) * Create bug_report.md * Create config.yml Included a link to feature requests. * Update config.yml Fixed link I had for testing to the actual link * Update bug_report.md fix capitalization * Update .github/ISSUE_TEMPLATE/bug_report.md Co-authored-by: Stephen Brown II <Stephen.Brown2@gmail.com> Co-authored-by: Stephen Brown II <Stephen.Brown2@gmail.com> * merge kentors changes * refactor/recipe routers * category/tag database relationship and endpoints * frontend category management * update branch todos * bug/normalize recipe steps html * remove console.log + refactor categories * fix categories database errors * refactor/ router endpoint * refactor/ remove old code * drag and drop ingredients * general cleanup * route refactoring * changelog * api refactoring + random cleanup * fixed backwards sort * Update mkdocs.yml (#137) Fix warning from Deploy Docs github action * fixed navigate on enter in search * refactor/create global css * added category scroll * cleanup todos * debug routes * docs/new gifs & general updates * cleanup * fix list test Co-authored-by: David Young <davidy@funkypenguin.co.nz> Co-authored-by: Hayden <hay-kot@pm.me> Co-authored-by: Richard Mitic <richard.h.mitic@gmail.com> Co-authored-by: kentora <kentora@kentora.dk> Co-authored-by: Alexei Pesic <pesic.alexei@gmail.com> Co-authored-by: Andrew <dpieski@gmail.com> Co-authored-by: Stephen Brown II <Stephen.Brown2@gmail.com> * fix build * fix duplicate editor * fixed docker mount problem * python 3.9 * added tasks for non-docker development * remove old scripts * dev updates * fixed no image upload option * get version from backend * final docs pass * .gitignore Co-authored-by: kentora <=> Co-authored-by: Hayden <hay-kot@pm.me> Co-authored-by: Richard Mitic <richard.h.mitic@gmail.com> Co-authored-by: kentora <kentora@kentora.dk> Co-authored-by: David Young <davidy@funkypenguin.co.nz> Co-authored-by: Bastien <43323819+Batgame@users.noreply.github.com> Co-authored-by: sephrat <34862846+sephrat@users.noreply.github.com> Co-authored-by: Nick CJ <17556895+nickcj931@users.noreply.github.com> Co-authored-by: dekvall <dkvldev@gmail.com> Co-authored-by: wengtad <wengtad93@gmail.com> Co-authored-by: Alexei Pesic <pesic.alexei@gmail.com> Co-authored-by: Andrew <dpieski@gmail.com> Co-authored-by: Stephen Brown II <Stephen.Brown2@gmail.com>
@@ -3,23 +3,41 @@ from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
# import utils.startup as startup
|
||||
from app_config import PORT, PRODUCTION, WEB_PATH, docs_url, redoc_url
|
||||
from app_config import APP_VERSION, PORT, PRODUCTION, WEB_PATH, docs_url, redoc_url
|
||||
from routes import (
|
||||
backup_routes,
|
||||
debug_routes,
|
||||
meal_routes,
|
||||
migration_routes,
|
||||
recipe_routes,
|
||||
setting_routes,
|
||||
static_routes,
|
||||
theme_routes,
|
||||
user_routes,
|
||||
)
|
||||
from utils.api_docs import generate_api_docs
|
||||
from routes.recipe import (
|
||||
all_recipe_routes,
|
||||
category_routes,
|
||||
recipe_crud_routes,
|
||||
tag_routes,
|
||||
)
|
||||
from utils.logger import logger
|
||||
|
||||
"""
|
||||
TODO:
|
||||
- [x] Fix Duplicate Category
|
||||
- [x] Fix category overflow
|
||||
- [ ] Enable Database Name Versioning
|
||||
- [ ] Finish Frontend Category Management
|
||||
- [x] Delete Category
|
||||
- [ ] Sort Sidebar A-Z
|
||||
- [ ] Refactor Test Endpoints - Abstract to fixture?
|
||||
|
||||
|
||||
"""
|
||||
app = FastAPI(
|
||||
title="Mealie",
|
||||
description="A place for all your recipes",
|
||||
version="0.0.1",
|
||||
version=APP_VERSION,
|
||||
docs_url=docs_url,
|
||||
redoc_url=redoc_url,
|
||||
)
|
||||
@@ -29,14 +47,28 @@ def mount_static_files():
|
||||
app.mount("/static", StaticFiles(directory=WEB_PATH, html=True))
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
import services.scheduler.scheduled_jobs
|
||||
|
||||
|
||||
def api_routers():
|
||||
# First
|
||||
app.include_router(recipe_routes.router)
|
||||
# Recipes
|
||||
app.include_router(all_recipe_routes.router)
|
||||
app.include_router(category_routes.router)
|
||||
app.include_router(tag_routes.router)
|
||||
app.include_router(recipe_crud_routes.router)
|
||||
# Meal Routes
|
||||
app.include_router(meal_routes.router)
|
||||
# Settings Routes
|
||||
app.include_router(setting_routes.router)
|
||||
app.include_router(theme_routes.router)
|
||||
# Backups/Imports Routes
|
||||
app.include_router(backup_routes.router)
|
||||
# User Routes
|
||||
app.include_router(user_routes.router)
|
||||
# Migration Routes
|
||||
app.include_router(migration_routes.router)
|
||||
app.include_router(debug_routes.router)
|
||||
|
||||
|
||||
if PRODUCTION:
|
||||
@@ -57,6 +89,8 @@ app.include_router(static_routes.router)
|
||||
# if not PRODUCTION:
|
||||
# generate_api_docs(app)
|
||||
|
||||
start_scheduler()
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("-----SYSTEM STARTUP-----")
|
||||
|
||||
|
||||
@@ -15,14 +15,31 @@ def ensure_dirs():
|
||||
ENV = CWD.joinpath(".env")
|
||||
dotenv.load_dotenv(ENV)
|
||||
|
||||
# General
|
||||
APP_VERSION = "v0.2.0"
|
||||
PRODUCTION = os.environ.get("ENV")
|
||||
PORT = int(os.getenv("mealie_port", 9000))
|
||||
API = os.getenv("api_docs", True)
|
||||
|
||||
if API:
|
||||
docs_url = "/docs"
|
||||
redoc_url = "/redoc"
|
||||
else:
|
||||
docs_url = None
|
||||
redoc_url = None
|
||||
|
||||
# Helpful Globals
|
||||
BASE_DIR = CWD
|
||||
DATA_DIR = CWD.joinpath("data")
|
||||
DATA_DIR = CWD.parent.joinpath("app_data")
|
||||
if PRODUCTION:
|
||||
DATA_DIR = Path("/app/data")
|
||||
|
||||
WEB_PATH = CWD.joinpath("dist")
|
||||
IMG_DIR = DATA_DIR.joinpath("img")
|
||||
BACKUP_DIR = DATA_DIR.joinpath("backups")
|
||||
DEBUG_DIR = DATA_DIR.joinpath("debug")
|
||||
MIGRATION_DIR = DATA_DIR.joinpath("migration")
|
||||
NEXTCLOUD_DIR = MIGRATION_DIR.joinpath("nextcloud")
|
||||
CHOWDOWN_DIR = MIGRATION_DIR.joinpath("chowdown")
|
||||
TEMPLATE_DIR = DATA_DIR.joinpath("templates")
|
||||
SQLITE_DIR = DATA_DIR.joinpath("db")
|
||||
TEMP_DIR = DATA_DIR.joinpath(".temp")
|
||||
@@ -35,37 +52,23 @@ REQUIRED_DIRS = [
|
||||
MIGRATION_DIR,
|
||||
TEMPLATE_DIR,
|
||||
SQLITE_DIR,
|
||||
NEXTCLOUD_DIR,
|
||||
CHOWDOWN_DIR,
|
||||
]
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
# General
|
||||
PRODUCTION = os.environ.get("ENV")
|
||||
PORT = int(os.getenv("mealie_port", 9000))
|
||||
API = os.getenv("api_docs", True)
|
||||
|
||||
if API:
|
||||
docs_url = "/docs"
|
||||
redoc_url = "/redoc"
|
||||
else:
|
||||
docs_url = None
|
||||
redoc_url = None
|
||||
|
||||
SQLITE_FILE = None
|
||||
# DATABASE ENV
|
||||
DATABASE_TYPE = os.getenv("db_type", "sqlite") # mongo, sqlite
|
||||
SQLITE_FILE = None
|
||||
DATABASE_TYPE = os.getenv("db_type", "sqlite")
|
||||
if DATABASE_TYPE == "sqlite":
|
||||
USE_SQL = True
|
||||
USE_MONGO = False
|
||||
SQLITE_FILE = SQLITE_DIR.joinpath("mealie.sqlite")
|
||||
|
||||
|
||||
elif DATABASE_TYPE == "mongo":
|
||||
USE_MONGO = True
|
||||
USE_SQL = False
|
||||
SQLITE_FILE = SQLITE_DIR.joinpath(f"mealie_{APP_VERSION}.sqlite")
|
||||
|
||||
else:
|
||||
raise Exception(
|
||||
"Unable to determine database type. Acceptable options are 'mongo' or 'sqlite' "
|
||||
"Unable to determine database type. Acceptible options are 'sqlite' "
|
||||
)
|
||||
|
||||
# Mongo Database
|
||||
@@ -78,6 +81,3 @@ DB_PORT = os.getenv("db_port", 27017)
|
||||
# SFTP Email Stuff - For use Later down the line!
|
||||
SFTP_USERNAME = os.getenv("sftp_username", None)
|
||||
SFTP_PASSWORD = os.getenv("sftp_password", None)
|
||||
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"@context": "http://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Romige linguine met broccoli met geitenkaas, walnoot en semi-gedroogde tomaten",
|
||||
"author": "HelloFresh",
|
||||
"image": "romige-linguine-met-broccoli-met-geitenkaas-walnoot-en-semi-gedroogde-tomaten.jpg",
|
||||
"thumbnailUrl": "https://img.hellofresh.com/f_auto,fl_lossy,h_300,q_auto,w_450/hellofresh_s3/image/5f6b33831268ce386f370e83-a3c36485.jpg",
|
||||
"description": "Linguine is Italiaans voor: a) linten, b) tongetjes of c) lijnen? Het antwoord vind je op de receptkaart!",
|
||||
"datePublished": "2020-09-23 11:37:39+00:00",
|
||||
"totalTime": "0:20:00",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "761 kcal",
|
||||
"fatContent": "37 g",
|
||||
"saturatedFatContent": "13 g",
|
||||
"carbohydrateContent": "75 g",
|
||||
"sugarContent": "9 g",
|
||||
"proteinContent": "27 g",
|
||||
"fiberContent": "12 g",
|
||||
"cholesterolContent": "0 mg",
|
||||
"sodiumContent": "1 g",
|
||||
"servingSize": 519
|
||||
},
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"text": "Bereid de bouillon.Kook ruim water in een pan met deksel voor de linguine en de broccoli. Snijd de bloem van de broccoli in roosjes en de steel in blokjes. Snipper de sjalot en pers de knoflook of snijd fijn."
|
||||
},
|
||||
{
|
||||
"text": "Snijd de semi-gedroogde tomaten klein.Kook de linguine, afgedekt, 10 \u2013 12 minuten in de pan met deksel.Voeg na 5 \u2013 7 minuten de broccoli toe aan de linguine. Giet daarna af en laat zonder deksel uitstomen."
|
||||
},
|
||||
{
|
||||
"text": "Verhit ondertussen de olijfolie in een wok of hapjespan met deksel en fruit de sjalot, knoflook en gedroogde oregano 2 - 3 minuten op middelmatig vuur.Voeg de bouillon en de kookroom toe en breng aan de kook.Voeg de linguine, broccoli en de helft van de geitenkaas toe en verhit nog 1 minuut. Meng er de semi-gedroogde tomaten door en breng op smaak met peper en zout."
|
||||
},
|
||||
{
|
||||
"text": "Verdeel de pasta over de borden.Bestrooi met de overige geitenkaas en de walnootstukjes."
|
||||
}
|
||||
],
|
||||
"recipeIngredient": [
|
||||
"200 g Broccoli",
|
||||
"\u00bd stuks Sjalot",
|
||||
"1 stuks Knoflookteen",
|
||||
"35 g Semi-gedroogde tomaten",
|
||||
"10 g Walnootstukjes",
|
||||
"90 g Linguine",
|
||||
"1 tl Gedroogde oregano",
|
||||
"25 g Geraspte belegen geitenkaas",
|
||||
"75 ml Groentebouillon",
|
||||
"\u00bd el Olijfolie",
|
||||
"naar smaak Peper en zout",
|
||||
"40 ml Kookroom"
|
||||
],
|
||||
"recipeYield": 2,
|
||||
"keywords": [
|
||||
"Familie",
|
||||
"Veggie",
|
||||
"Lekker snel"
|
||||
],
|
||||
"recipeCategory": "Hoofdgerecht",
|
||||
"recipeCuisine": "Italiaans",
|
||||
"slug": "romige-linguine-met-broccoli-met-geitenkaas-walnoot-en-semi-gedroogde-tomaten",
|
||||
"orgURL": "https://www.hellofresh.nl/recipes/romige-linguine-met-broccoli-5f6b33831268ce386f370e83",
|
||||
"categories": [],
|
||||
"tags": [],
|
||||
"dateAdded": null,
|
||||
"notes": [],
|
||||
"extras": [],
|
||||
"prepTime": null,
|
||||
"performTime": null
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
|
||||
|
||||

|
||||
|
||||
# {{ recipe.name }}
|
||||
{{ recipe.description }}
|
||||
|
||||
## Ingredients
|
||||
{% for ingredient in recipe.recipeIngredient %}
|
||||
- [ ] {{ ingredient }}
|
||||
{% endfor %}
|
||||
|
||||
## Instructions
|
||||
{% for step in recipe.recipeInstructions %}
|
||||
- [ ] {{ step.text }}
|
||||
{% endfor %}
|
||||
|
||||
{% for note in recipe.notes %}
|
||||
**{{ note.title }}:** {{ note.text }}
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
|
||||
Tags: {{ recipe.tags }}
|
||||
Categories: {{ recipe.categories }}
|
||||
Original URL: {{ recipe.orgURL }}
|
||||
@@ -1,7 +1,67 @@
|
||||
from db.db_mealplan import _Meals
|
||||
from db.db_recipes import _Recipes
|
||||
from db.db_settings import _Settings
|
||||
from db.db_themes import _Themes
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from db.db_base import BaseDocument
|
||||
from db.sql.meal_models import MealPlanModel
|
||||
from db.sql.recipe_models import Category, RecipeModel, Tag
|
||||
from db.sql.settings_models import SiteSettingsModel
|
||||
from db.sql.theme_models import SiteThemeModel
|
||||
|
||||
"""
|
||||
# TODO
|
||||
- [ ] Abstract Classes to use save_new, and update from base models
|
||||
- [x] Create Category and Tags Table with Many to Many relationship
|
||||
"""
|
||||
|
||||
|
||||
class _Recipes(BaseDocument):
|
||||
def __init__(self) -> None:
|
||||
self.primary_key = "slug"
|
||||
self.sql_model = RecipeModel
|
||||
|
||||
def update_image(self, session: Session, slug: str, extension: str) -> str:
|
||||
entry: RecipeModel = self._query_one(session, match_value=slug)
|
||||
entry.image = f"{slug}.{extension}"
|
||||
session.commit()
|
||||
|
||||
return f"{slug}.{extension}"
|
||||
|
||||
|
||||
class _Categories(BaseDocument):
|
||||
def __init__(self) -> None:
|
||||
self.primary_key = "slug"
|
||||
self.sql_model = Category
|
||||
|
||||
|
||||
class _Tags(BaseDocument):
|
||||
def __init__(self) -> None:
|
||||
self.primary_key = "slug"
|
||||
self.sql_model = Tag
|
||||
|
||||
|
||||
class _Meals(BaseDocument):
|
||||
def __init__(self) -> None:
|
||||
self.primary_key = "uid"
|
||||
self.sql_model = MealPlanModel
|
||||
|
||||
|
||||
class _Settings(BaseDocument):
|
||||
def __init__(self) -> None:
|
||||
self.primary_key = "name"
|
||||
self.sql_model = SiteSettingsModel
|
||||
|
||||
def save_new(self, session: Session, main: dict, webhooks: dict) -> str:
|
||||
new_settings = self.sql_model(main.get("name"), webhooks)
|
||||
|
||||
session.add(new_settings)
|
||||
session.commit()
|
||||
|
||||
return new_settings.dict()
|
||||
|
||||
|
||||
class _Themes(BaseDocument):
|
||||
def __init__(self) -> None:
|
||||
self.primary_key = "name"
|
||||
self.sql_model = SiteThemeModel
|
||||
|
||||
|
||||
class Database:
|
||||
@@ -10,6 +70,8 @@ class Database:
|
||||
self.meals = _Meals()
|
||||
self.settings = _Settings()
|
||||
self.themes = _Themes()
|
||||
self.categories = _Categories()
|
||||
self.tags = _Tags()
|
||||
|
||||
|
||||
db = Database()
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import json
|
||||
from typing import Union
|
||||
from typing import List
|
||||
|
||||
import mongoengine
|
||||
from app_config import USE_MONGO, USE_SQL
|
||||
from sqlalchemy.orm import load_only
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from db.sql.db_session import create_session
|
||||
from db.sql.model_base import SqlAlchemyBase
|
||||
|
||||
|
||||
@@ -13,81 +10,68 @@ class BaseDocument:
|
||||
def __init__(self) -> None:
|
||||
self.primary_key: str
|
||||
self.store: str
|
||||
self.document: mongoengine.Document
|
||||
self.sql_model: SqlAlchemyBase
|
||||
self.create_session = create_session
|
||||
|
||||
@staticmethod # TODO: Probably Put a version in each class to speed up reads?
|
||||
def _unpack_mongo(document) -> dict:
|
||||
document = json.loads(document.to_json())
|
||||
del document["_id"]
|
||||
# TODO: Improve Get All Query Functionality
|
||||
def get_all(
|
||||
self, session: Session, limit: int = None, order_by: str = None
|
||||
) -> List[dict]:
|
||||
list = [x.dict() for x in session.query(self.sql_model).limit(limit).all()]
|
||||
|
||||
# Recipe Cleanup
|
||||
try:
|
||||
document["dateAdded"] = document["dateAdded"]["$date"]
|
||||
except:
|
||||
pass
|
||||
if limit == 1:
|
||||
return list[0]
|
||||
|
||||
try:
|
||||
document["uid"] = document["uid"]["$uuid"]
|
||||
except:
|
||||
pass
|
||||
return list
|
||||
|
||||
# Meal Plan
|
||||
try:
|
||||
document["startDate"] = document["startDate"]["$date"]
|
||||
document["endDate"] = document["endDate"]["$date"]
|
||||
def get_all_limit_columns(
|
||||
self, session: Session, fields: List[str], limit: int = None
|
||||
) -> List[SqlAlchemyBase]:
|
||||
"""Queries the database for the selected model. Restricts return responses to the
|
||||
keys specified under "fields"
|
||||
|
||||
meals = []
|
||||
for meal in document["meals"]:
|
||||
meal["date"] = meal["date"]["$date"]
|
||||
meals.append(meal)
|
||||
document["meals"] = meals
|
||||
except:
|
||||
pass
|
||||
Args: \n
|
||||
session (Session): Database Session Object
|
||||
fields (List[str]): List of column names to query
|
||||
limit (int): A limit of values to return
|
||||
|
||||
return document
|
||||
Returns:
|
||||
list[SqlAlchemyBase]: Returns a list of ORM objects
|
||||
"""
|
||||
results = (
|
||||
session.query(self.sql_model).options(load_only(*fields)).limit(limit).all()
|
||||
)
|
||||
|
||||
def get_all(self, limit: int = None, order_by: str = None):
|
||||
if USE_MONGO:
|
||||
if order_by:
|
||||
documents = self.document.objects.order_by(str(order_by)).limit(limit)
|
||||
elif limit == None:
|
||||
documents = self.document.objects()
|
||||
else:
|
||||
documents = self.document.objects().limit(limit)
|
||||
return results
|
||||
|
||||
docs = [BaseDocument._unpack_mongo(item) for item in documents]
|
||||
def get_all_primary_keys(self, session: Session) -> List[str]:
|
||||
"""Queries the database of the selected model and returns a list
|
||||
of all primary_key values
|
||||
|
||||
if limit == 1:
|
||||
return docs[0]
|
||||
return docs
|
||||
Args: \n
|
||||
session (Session): Database Session object
|
||||
|
||||
elif USE_SQL:
|
||||
session = create_session()
|
||||
list = [x.dict() for x in session.query(self.sql_model).all()]
|
||||
session.close()
|
||||
|
||||
if limit == 1:
|
||||
return list[0]
|
||||
|
||||
return list
|
||||
Returns:
|
||||
list[str]:
|
||||
"""
|
||||
results = session.query(self.sql_model).options(
|
||||
load_only(str(self.primary_key))
|
||||
)
|
||||
results_as_dict = [x.dict() for x in results]
|
||||
return [x.get(self.primary_key) for x in results_as_dict]
|
||||
|
||||
def _query_one(
|
||||
self, match_value: str, match_key: str = None
|
||||
) -> Union[Session, SqlAlchemyBase]:
|
||||
self, session: Session, match_value: str, match_key: str = None
|
||||
) -> SqlAlchemyBase:
|
||||
"""Query the sql database for one item an return the sql alchemy model
|
||||
object. If no match key is provided the primary_key attribute will be used.
|
||||
|
||||
Args:
|
||||
Args: \n
|
||||
match_value (str): The value to use in the query
|
||||
match_key (str, optional): the key/property to match against. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Union[Session, SqlAlchemyBase]: Will return both the session and found model
|
||||
"""
|
||||
session = self.create_session()
|
||||
|
||||
if match_key == None:
|
||||
match_key = self.primary_key
|
||||
|
||||
@@ -95,11 +79,11 @@ class BaseDocument:
|
||||
session.query(self.sql_model).filter_by(**{match_key: match_value}).one()
|
||||
)
|
||||
|
||||
return session, result
|
||||
return result
|
||||
|
||||
def get(
|
||||
self, match_value: str, match_key: str = None, limit=1
|
||||
) -> dict or list[dict]:
|
||||
self, session: Session, match_value: str, match_key: str = None, limit=1
|
||||
) -> dict or List[dict]:
|
||||
"""Retrieves an entry from the database by matching a key/value pair. If no
|
||||
key is provided the class objects primary key will be used to match against.
|
||||
|
||||
@@ -115,77 +99,55 @@ class BaseDocument:
|
||||
if match_key == None:
|
||||
match_key = self.primary_key
|
||||
|
||||
if USE_MONGO:
|
||||
document = self.document.objects.get(**{str(match_key): match_value})
|
||||
db_entry = BaseDocument._unpack_mongo(document)
|
||||
result = (
|
||||
session.query(self.sql_model).filter_by(**{match_key: match_value}).one()
|
||||
)
|
||||
db_entry = result.dict()
|
||||
|
||||
elif USE_SQL:
|
||||
session = self.create_session()
|
||||
result = (
|
||||
session.query(self.sql_model)
|
||||
.filter_by(**{match_key: match_value})
|
||||
.one()
|
||||
)
|
||||
db_entry = result.dict()
|
||||
session.close()
|
||||
return db_entry
|
||||
|
||||
return db_entry
|
||||
def save_new(self, session: Session, document: dict) -> dict:
|
||||
"""Creates a new database entry for the given SQL Alchemy Model.
|
||||
|
||||
else:
|
||||
raise Exception("No database type established")
|
||||
Args: \n
|
||||
session (Session): A Database Session
|
||||
document (dict): A python dictionary representing the data structure
|
||||
|
||||
if limit == 1 and type(db_entry) == list:
|
||||
return db_entry[0]
|
||||
else:
|
||||
return db_entry
|
||||
|
||||
def save_new(self, document: dict) -> dict:
|
||||
if USE_MONGO:
|
||||
new_document = self.document(**document)
|
||||
new_document.save()
|
||||
return BaseDocument._unpack_mongo(new_document)
|
||||
elif USE_SQL:
|
||||
session = self.create_session()
|
||||
new_document = self.sql_model(**document)
|
||||
session.add(new_document)
|
||||
return_data = new_document.dict()
|
||||
session.commit()
|
||||
|
||||
return return_data
|
||||
|
||||
def update(self, match_value, new_data) -> dict:
|
||||
if USE_MONGO:
|
||||
return_data = self.update_mongo(match_value, new_data)
|
||||
elif USE_SQL:
|
||||
session, entry = self._query_one(match_value=match_value)
|
||||
entry.update(session=session, **new_data)
|
||||
return_data = entry.dict()
|
||||
session.commit()
|
||||
|
||||
session.close()
|
||||
else:
|
||||
raise Exception("No Database Configured")
|
||||
Returns:
|
||||
dict: A dictionary representation of the database entry
|
||||
"""
|
||||
new_document = self.sql_model(session=session, **document)
|
||||
session.add(new_document)
|
||||
return_data = new_document.dict()
|
||||
session.commit()
|
||||
|
||||
return return_data
|
||||
|
||||
def delete(self, primary_key_value) -> dict:
|
||||
if USE_MONGO:
|
||||
document = self.document.objects.get(
|
||||
**{str(self.primary_key): primary_key_value}
|
||||
)
|
||||
def update(self, session: Session, match_value: str, new_data: str) -> dict:
|
||||
"""Update a database entry.
|
||||
|
||||
if document:
|
||||
document.delete()
|
||||
elif USE_SQL:
|
||||
session = create_session()
|
||||
Args: \n
|
||||
session (Session): Database Session
|
||||
match_value (str): Match "key"
|
||||
new_data (str): Match "value"
|
||||
|
||||
result = (
|
||||
session.query(self.sql_model)
|
||||
.filter_by(**{self.primary_key: primary_key_value})
|
||||
.one()
|
||||
)
|
||||
Returns:
|
||||
dict: Returns a dictionary representation of the database entry
|
||||
"""
|
||||
|
||||
session.delete(result)
|
||||
entry = self._query_one(session=session, match_value=match_value)
|
||||
entry.update(session=session, **new_data)
|
||||
return_data = entry.dict()
|
||||
session.commit()
|
||||
|
||||
session.commit()
|
||||
session.close()
|
||||
return return_data
|
||||
|
||||
def delete(self, session: Session, primary_key_value) -> dict:
|
||||
result = (
|
||||
session.query(self.sql_model)
|
||||
.filter_by(**{self.primary_key: primary_key_value})
|
||||
.one()
|
||||
)
|
||||
|
||||
session.delete(result)
|
||||
session.commit()
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
from app_config import SQLITE_FILE, USE_MONGO, USE_SQL
|
||||
from app_config import SQLITE_FILE, USE_SQL
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from db.sql.db_session import globa_init as sql_global_init
|
||||
from db.sql.db_session import sql_global_init
|
||||
|
||||
sql_exists = True
|
||||
|
||||
if USE_SQL:
|
||||
sql_exists = SQLITE_FILE.is_file()
|
||||
sql_global_init(SQLITE_FILE)
|
||||
SessionLocal = sql_global_init(SQLITE_FILE)
|
||||
else:
|
||||
raise Exception("Cannot identify database type")
|
||||
|
||||
pass
|
||||
|
||||
elif USE_MONGO:
|
||||
from db.mongo.mongo_setup import global_init as mongo_global_init
|
||||
def create_session() -> Session:
|
||||
global SessionLocal
|
||||
return SessionLocal()
|
||||
|
||||
mongo_global_init()
|
||||
|
||||
def generate_session() -> Session:
|
||||
global SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import uuid
|
||||
|
||||
import mongoengine
|
||||
|
||||
|
||||
class MealDocument(mongoengine.EmbeddedDocument):
|
||||
slug = mongoengine.StringField()
|
||||
name = mongoengine.StringField()
|
||||
date = mongoengine.DateField()
|
||||
dateText = mongoengine.StringField()
|
||||
image = mongoengine.StringField()
|
||||
description = mongoengine.StringField()
|
||||
|
||||
|
||||
class MealPlanDocument(mongoengine.Document):
|
||||
uid = mongoengine.UUIDField(default=uuid.uuid1)
|
||||
startDate = mongoengine.DateField(required=True)
|
||||
endDate = mongoengine.DateField(required=True)
|
||||
meals = mongoengine.ListField(required=True)
|
||||
|
||||
meta = {
|
||||
"db_alias": "core",
|
||||
"collection": "meals",
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import mongoengine
|
||||
from app_config import DB_HOST, DB_PASSWORD, DB_PORT, DB_USERNAME, MEALIE_DB_NAME
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
def global_init():
|
||||
mongoengine.register_connection(
|
||||
alias="core",
|
||||
name=MEALIE_DB_NAME,
|
||||
host=DB_HOST,
|
||||
port=int(DB_PORT),
|
||||
username=DB_USERNAME,
|
||||
password=DB_PASSWORD,
|
||||
authentication_source="admin",
|
||||
)
|
||||
|
||||
logger.info("Mongo Data Initialized")
|
||||
@@ -1,36 +0,0 @@
|
||||
import datetime
|
||||
|
||||
import mongoengine
|
||||
|
||||
|
||||
class RecipeDocument(mongoengine.Document):
|
||||
# Standard Schema
|
||||
# id = mongoengine.UUIDField(primary_key=True)
|
||||
name = mongoengine.StringField(required=True)
|
||||
description = mongoengine.StringField(required=True)
|
||||
image = mongoengine.StringField(required=False)
|
||||
recipeYield = mongoengine.StringField(required=True, default="")
|
||||
recipeIngredient = mongoengine.ListField(required=True, default=[])
|
||||
recipeInstructions = mongoengine.ListField(requiredd=True, default=[])
|
||||
totalTime = mongoengine.StringField(required=False)
|
||||
prepTime = mongoengine.StringField(required=False)
|
||||
performTime = mongoengine.StringField(required=False)
|
||||
|
||||
# Mealie Specific
|
||||
slug = mongoengine.StringField(required=True, unique=True)
|
||||
categories = mongoengine.ListField(default=[])
|
||||
tags = mongoengine.ListField(default=[])
|
||||
dateAdded = mongoengine.DateTimeField(binary=True, default=datetime.date.today)
|
||||
notes = mongoengine.ListField(default=[])
|
||||
rating = mongoengine.IntField(required=True, default=0)
|
||||
orgURL = mongoengine.URLField(required=False)
|
||||
extras = mongoengine.DictField(required=False)
|
||||
|
||||
meta = {
|
||||
"db_alias": "core",
|
||||
"collection": "recipes",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
@@ -1,37 +0,0 @@
|
||||
import mongoengine
|
||||
|
||||
|
||||
class WebhooksDocument(mongoengine.EmbeddedDocument):
|
||||
webhookURLs = mongoengine.ListField(required=False, default=[])
|
||||
webhookTime = mongoengine.StringField(required=False, default="00:00")
|
||||
enabled = mongoengine.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class SiteSettingsDocument(mongoengine.Document):
|
||||
name = mongoengine.StringField(require=True, default="main", unique=True)
|
||||
webhooks = mongoengine.EmbeddedDocumentField(WebhooksDocument, required=True)
|
||||
|
||||
meta = {
|
||||
"db_alias": "core",
|
||||
"collection": "settings",
|
||||
}
|
||||
|
||||
|
||||
class ThemeColorsDocument(mongoengine.EmbeddedDocument):
|
||||
primary = mongoengine.StringField(require=True)
|
||||
accent = mongoengine.StringField(require=True)
|
||||
secondary = mongoengine.StringField(require=True)
|
||||
success = mongoengine.StringField(require=True)
|
||||
info = mongoengine.StringField(require=True)
|
||||
warning = mongoengine.StringField(require=True)
|
||||
error = mongoengine.StringField(require=True)
|
||||
|
||||
|
||||
class SiteThemeDocument(mongoengine.Document):
|
||||
name = mongoengine.StringField(require=True, unique=True)
|
||||
colors = mongoengine.EmbeddedDocumentField(ThemeColorsDocument, required=True)
|
||||
|
||||
meta = {
|
||||
"db_alias": "core",
|
||||
"collection": "themes",
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
|
||||
# import mongoengine
|
||||
|
||||
# class User(mongoengine.Document):
|
||||
# username: mongoengine.EmailField()
|
||||
# password: mongoengine.ReferenceField
|
||||
@@ -1,31 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlalchemy.orm as orm
|
||||
from db.sql.model_base import SqlAlchemyBase
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
__factory = None
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
def globa_init(db_file: Path):
|
||||
global __factory
|
||||
def sql_global_init(db_file: Path, check_thread=False):
|
||||
|
||||
if __factory:
|
||||
return
|
||||
conn_str = "sqlite:///" + str(db_file.absolute())
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///" + str(db_file.absolute())
|
||||
# SQLALCHEMY_DATABASE_URL = "postgresql://user:password@postgresserver/db"
|
||||
|
||||
engine = sa.create_engine(
|
||||
conn_str, echo=False, connect_args={"check_same_thread": False}
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": check_thread},
|
||||
)
|
||||
|
||||
__factory = orm.sessionmaker(bind=engine)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
import db.sql._all_models
|
||||
|
||||
SqlAlchemyBase.metadata.create_all(engine)
|
||||
|
||||
|
||||
def create_session() -> Session:
|
||||
global __factory
|
||||
return __factory()
|
||||
return SessionLocal
|
||||
|
||||
@@ -17,7 +17,9 @@ class Meal(SqlAlchemyBase):
|
||||
image = sa.Column(sa.String)
|
||||
description = sa.Column(sa.String)
|
||||
|
||||
def __init__(self, slug, name, date, dateText, image, description) -> None:
|
||||
def __init__(
|
||||
self, slug, name, date, dateText, image, description, session=None
|
||||
) -> None:
|
||||
self.slug = slug
|
||||
self.name = name
|
||||
self.date = date
|
||||
@@ -45,7 +47,7 @@ class MealPlanModel(SqlAlchemyBase, BaseMixins):
|
||||
endDate = sa.Column(sa.Date)
|
||||
meals: List[Meal] = orm.relation(Meal)
|
||||
|
||||
def __init__(self, startDate, endDate, meals, uid=None) -> None:
|
||||
def __init__(self, startDate, endDate, meals, uid=None, session=None) -> None:
|
||||
self.startDate = startDate
|
||||
self.endDate = endDate
|
||||
self.meals = [Meal(**meal) for meal in meals]
|
||||
|
||||
@@ -5,7 +5,9 @@ from typing import List
|
||||
import sqlalchemy as sa
|
||||
import sqlalchemy.orm as orm
|
||||
from db.sql.model_base import BaseMixins, SqlAlchemyBase
|
||||
from slugify import slugify
|
||||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
class ApiExtras(SqlAlchemyBase):
|
||||
@@ -23,25 +25,100 @@ class ApiExtras(SqlAlchemyBase):
|
||||
return {self.key_name: self.value}
|
||||
|
||||
|
||||
recipes2categories = sa.Table(
|
||||
"recipes2categories",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
|
||||
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")),
|
||||
)
|
||||
|
||||
recipes2tags = sa.Table(
|
||||
"recipes2tags",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
|
||||
sa.Column("tag_slug", sa.Integer, sa.ForeignKey("tags.slug")),
|
||||
)
|
||||
|
||||
|
||||
class Category(SqlAlchemyBase):
|
||||
__tablename__ = "categories"
|
||||
id = sa.Column(sa.Integer, primary_key=True)
|
||||
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
|
||||
name = sa.Column(sa.String, index=True)
|
||||
slug = sa.Column(sa.String, index=True, unique=True)
|
||||
recipes = orm.relationship(
|
||||
"RecipeModel", secondary=recipes2categories, back_populates="categories"
|
||||
)
|
||||
|
||||
def __init__(self, name) -> None:
|
||||
self.name = name.strip()
|
||||
self.slug = slugify(name)
|
||||
|
||||
@staticmethod
|
||||
def create_if_not_exist(session, name: str = None):
|
||||
try:
|
||||
result = session.query(Category).filter(Category.name == name.strip()).one()
|
||||
if result:
|
||||
logger.info("Category exists, associating recipe")
|
||||
return result
|
||||
else:
|
||||
logger.info("Category doesn't exists, creating tag")
|
||||
return Category(name=name)
|
||||
except:
|
||||
logger.info("Category doesn't exists, creating category")
|
||||
return Category(name=name)
|
||||
|
||||
def to_str(self):
|
||||
return self.name
|
||||
|
||||
def dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"slug": self.slug,
|
||||
"name": self.name,
|
||||
"recipes": [x.dict() for x in self.recipes],
|
||||
}
|
||||
|
||||
|
||||
class Tag(SqlAlchemyBase):
|
||||
__tablename__ = "tags"
|
||||
id = sa.Column(sa.Integer, primary_key=True)
|
||||
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
|
||||
name = sa.Column(sa.String, index=True)
|
||||
slug = sa.Column(sa.String, index=True, unique=True)
|
||||
recipes = orm.relationship(
|
||||
"RecipeModel", secondary=recipes2tags, back_populates="tags"
|
||||
)
|
||||
|
||||
def to_str(self):
|
||||
return self.name
|
||||
|
||||
def __init__(self, name) -> None:
|
||||
self.name = name.strip()
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
def dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"slug": self.slug,
|
||||
"name": self.name,
|
||||
"recipes": [x.dict() for x in self.recipes],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_if_not_exist(session, name: str = None):
|
||||
try:
|
||||
result = session.query(Tag).filter(Tag.name == name.strip()).first()
|
||||
|
||||
if result:
|
||||
logger.info("Tag exists, associating recipe")
|
||||
|
||||
return result
|
||||
else:
|
||||
logger.info("Tag doesn't exists, creating tag")
|
||||
return Tag(name=name)
|
||||
except:
|
||||
logger.info("Tag doesn't exists, creating tag")
|
||||
return Tag(name=name)
|
||||
|
||||
|
||||
class Note(SqlAlchemyBase):
|
||||
__tablename__ = "notes"
|
||||
@@ -116,25 +193,21 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
# Mealie Specific
|
||||
slug = sa.Column(sa.String, index=True, unique=True)
|
||||
categories: List[Category] = orm.relationship(
|
||||
"Category",
|
||||
cascade="all, delete",
|
||||
categories: List = orm.relationship(
|
||||
"Category", secondary=recipes2categories, back_populates="recipes"
|
||||
)
|
||||
tags: List[Tag] = orm.relationship(
|
||||
"Tag",
|
||||
cascade="all, delete",
|
||||
"Tag", secondary=recipes2tags, back_populates="recipes"
|
||||
)
|
||||
dateAdded = sa.Column(sa.Date, default=date.today)
|
||||
notes: List[Note] = orm.relationship(
|
||||
"Note",
|
||||
cascade="all, delete",
|
||||
)
|
||||
notes: List[Note] = orm.relationship("Note", cascade="all, delete")
|
||||
rating = sa.Column(sa.Integer)
|
||||
orgURL = sa.Column(sa.String)
|
||||
extras: List[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session,
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
image: str = None,
|
||||
@@ -161,7 +234,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
RecipeIngredient(ingredient=ingr) for ingr in recipeIngredient
|
||||
]
|
||||
self.recipeInstructions = [
|
||||
RecipeInstruction(text=instruc.get("text"), type=instruc.get("text"))
|
||||
RecipeInstruction(text=instruc.get("text"), type=instruc.get("@type", None))
|
||||
for instruc in recipeInstructions
|
||||
]
|
||||
self.totalTime = totalTime
|
||||
@@ -170,8 +243,13 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
# Mealie Specific
|
||||
self.slug = slug
|
||||
self.categories = [Category(name=cat) for cat in categories]
|
||||
self.tags = [Tag(name=tag) for tag in tags]
|
||||
self.categories = [
|
||||
Category.create_if_not_exist(session=session, name=cat)
|
||||
for cat in categories
|
||||
]
|
||||
|
||||
self.tags = [Tag.create_if_not_exist(session=session, name=tag) for tag in tags]
|
||||
|
||||
self.dateAdded = dateAdded
|
||||
self.notes = [Note(**note) for note in notes]
|
||||
self.rating = rating
|
||||
@@ -200,10 +278,11 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
extras: dict = None,
|
||||
):
|
||||
"""Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions"""
|
||||
list_of_tables = [RecipeIngredient, RecipeInstruction, Category, Tag, ApiExtras]
|
||||
list_of_tables = [RecipeIngredient, RecipeInstruction, ApiExtras]
|
||||
RecipeModel._sql_remove_list(session, list_of_tables, self.id)
|
||||
|
||||
self.__init__(
|
||||
session=session,
|
||||
name=name,
|
||||
description=description,
|
||||
image=image,
|
||||
|
||||
@@ -8,7 +8,7 @@ class SiteSettingsModel(SqlAlchemyBase):
|
||||
name = sa.Column(sa.String, primary_key=True)
|
||||
webhooks = orm.relationship("WebHookModel", uselist=False, cascade="all, delete")
|
||||
|
||||
def __init__(self, name: str = None, webhooks: dict = None) -> None:
|
||||
def __init__(self, name: str = None, webhooks: dict = None, session=None) -> None:
|
||||
self.name = name
|
||||
self.webhooks = WebHookModel(**webhooks)
|
||||
|
||||
@@ -33,7 +33,7 @@ class WebHookModel(SqlAlchemyBase, BaseMixins):
|
||||
enabled = sa.Column(sa.Boolean, default=False)
|
||||
|
||||
def __init__(
|
||||
self, webhookURLs: list, webhookTime: str, enabled: bool = False
|
||||
self, webhookURLs: list, webhookTime: str, enabled: bool = False, session=None
|
||||
) -> None:
|
||||
|
||||
self.webhookURLs = [WebhookURLModel(url=x) for x in webhookURLs]
|
||||
|
||||
@@ -8,11 +8,11 @@ class SiteThemeModel(SqlAlchemyBase):
|
||||
name = sa.Column(sa.String, primary_key=True)
|
||||
colors = orm.relationship("ThemeColorsModel", uselist=False, cascade="all, delete")
|
||||
|
||||
def __init__(self, name: str, colors: dict) -> None:
|
||||
def __init__(self, name: str, colors: dict, session=None) -> None:
|
||||
self.name = name
|
||||
self.colors = ThemeColorsModel(**colors)
|
||||
|
||||
def update(self, name, colors: dict) -> dict:
|
||||
def update(self, session=None, name: str = None, colors: dict = None) -> dict:
|
||||
self.colors.update(**colors)
|
||||
return self.dict()
|
||||
|
||||
|
||||
@@ -4,15 +4,32 @@ from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BackupOptions(BaseModel):
|
||||
recipes: bool = True
|
||||
settings: bool = True
|
||||
themes: bool = True
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"recipes": True,
|
||||
"settings": True,
|
||||
"themes": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BackupJob(BaseModel):
|
||||
tag: Optional[str]
|
||||
template: Optional[List[str]]
|
||||
options: BackupOptions
|
||||
templates: Optional[List[str]]
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"tag": "July 23rd 2021",
|
||||
"template": "recipes.md",
|
||||
"options": BackupOptions(),
|
||||
"template": ["recipes.md"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,13 +66,13 @@ class ImportJob(BaseModel):
|
||||
settings: bool = False
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"name": "my_local_backup.zip",
|
||||
"recipes": True,
|
||||
"force": False,
|
||||
"rebase": False,
|
||||
"themes": False,
|
||||
"settings": False
|
||||
}
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"name": "my_local_backup.zip",
|
||||
"recipes": True,
|
||||
"force": False,
|
||||
"rebase": False,
|
||||
"themes": False,
|
||||
"settings": False,
|
||||
}
|
||||
}
|
||||
|
||||
14
mealie/models/category_models.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic.main import BaseModel
|
||||
from services.recipe_services import Recipe
|
||||
|
||||
|
||||
class RecipeCategoryResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
recipes: List[Recipe]
|
||||
|
||||
class Config:
|
||||
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}
|
||||
@@ -1,3 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from pydantic.main import BaseModel
|
||||
|
||||
|
||||
@@ -10,3 +13,13 @@ class ChowdownURL(BaseModel):
|
||||
"url": "https://chowdownrepo.com/repo",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MigrationFile(BaseModel):
|
||||
name: str
|
||||
date: datetime
|
||||
|
||||
|
||||
class Migrations(BaseModel):
|
||||
type: str
|
||||
files: List[MigrationFile] = []
|
||||
|
||||
@@ -4,8 +4,8 @@ import pydantic
|
||||
from pydantic.main import BaseModel
|
||||
|
||||
|
||||
class RecipeResponse(BaseModel):
|
||||
List
|
||||
class AllRecipeResponse(BaseModel):
|
||||
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import operator
|
||||
import shutil
|
||||
|
||||
from app_config import BACKUP_DIR, TEMPLATE_DIR
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from db.db_setup import generate_session
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from models.backup_models import BackupJob, ImportJob, Imports, LocalBackup
|
||||
from services.backups.exports import backup_all
|
||||
from services.backups.imports import ImportDatabase
|
||||
from sqlalchemy.orm.session import Session
|
||||
from starlette.responses import FileResponse
|
||||
from utils.snackbar import SnackResponse
|
||||
|
||||
router = APIRouter(tags=["Import / Export"])
|
||||
router = APIRouter(prefix="/api/backups", tags=["Backups"])
|
||||
|
||||
|
||||
@router.get("/api/backups/available/", response_model=Imports)
|
||||
@router.get("/available", response_model=Imports)
|
||||
def available_imports():
|
||||
"""Returns a list of avaiable .zip files for import into Mealie."""
|
||||
imports = []
|
||||
@@ -19,7 +23,7 @@ def available_imports():
|
||||
backup = LocalBackup(name=archive.name, date=archive.stat().st_ctime)
|
||||
imports.append(backup)
|
||||
|
||||
for template in TEMPLATE_DIR.glob("*.md"):
|
||||
for template in TEMPLATE_DIR.glob("*.*"):
|
||||
templates.append(template.name)
|
||||
|
||||
imports.sort(key=operator.attrgetter("date"), reverse=True)
|
||||
@@ -27,10 +31,17 @@ def available_imports():
|
||||
return Imports(imports=imports, templates=templates)
|
||||
|
||||
|
||||
@router.post("/api/backups/export/database/", status_code=201)
|
||||
def export_database(data: BackupJob):
|
||||
@router.post("/export/database", status_code=201)
|
||||
def export_database(data: BackupJob, db: Session = Depends(generate_session)):
|
||||
"""Generates a backup of the recipe database in json format."""
|
||||
export_path = backup_all(data.tag, data.template)
|
||||
export_path = backup_all(
|
||||
session=db,
|
||||
tag=data.tag,
|
||||
templates=data.templates,
|
||||
export_recipes=data.options.recipes,
|
||||
export_settings=data.options.settings,
|
||||
export_themes=data.options.themes,
|
||||
)
|
||||
try:
|
||||
return SnackResponse.success("Backup Created at " + export_path)
|
||||
except:
|
||||
@@ -40,11 +51,41 @@ def export_database(data: BackupJob):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/backups/{file_name}/import/", status_code=200)
|
||||
def import_database(file_name: str, import_data: ImportJob):
|
||||
@router.post("/upload")
|
||||
def upload_backup_zipfile(archive: UploadFile = File(...)):
|
||||
""" Upload a .zip File to later be imported into Mealie """
|
||||
dest = BACKUP_DIR.joinpath(archive.filename)
|
||||
|
||||
with dest.open("wb") as buffer:
|
||||
shutil.copyfileobj(archive.file, buffer)
|
||||
|
||||
if dest.is_file:
|
||||
return SnackResponse.success("Backup uploaded")
|
||||
else:
|
||||
return SnackResponse.error("Failure uploading file")
|
||||
|
||||
|
||||
@router.get("/{file_name}/download")
|
||||
def upload_nextcloud_zipfile(file_name: str):
|
||||
""" Upload a .zip File to later be imported into Mealie """
|
||||
file = BACKUP_DIR.joinpath(file_name)
|
||||
|
||||
if file.is_file:
|
||||
return FileResponse(
|
||||
file, media_type="application/octet-stream", filename=file_name
|
||||
)
|
||||
else:
|
||||
return SnackResponse.error("No File Found")
|
||||
|
||||
|
||||
@router.post("/{file_name}/import", status_code=200)
|
||||
def import_database(
|
||||
file_name: str, import_data: ImportJob, db: Session = Depends(generate_session)
|
||||
):
|
||||
""" Import a database backup file generated from Mealie. """
|
||||
|
||||
import_db = ImportDatabase(
|
||||
session=db,
|
||||
zip_archive=import_data.name,
|
||||
import_recipes=import_data.recipes,
|
||||
force_import=import_data.force,
|
||||
@@ -57,20 +98,16 @@ def import_database(file_name: str, import_data: ImportJob):
|
||||
return imported
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/backups/{backup_name}/delete/",
|
||||
tags=["Import / Export"],
|
||||
status_code=200,
|
||||
)
|
||||
def delete_backup(backup_name: str):
|
||||
@router.delete("/{file_name}/delete", status_code=200)
|
||||
def delete_backup(file_name: str):
|
||||
""" Removes a database backup from the file system """
|
||||
|
||||
try:
|
||||
BACKUP_DIR.joinpath(backup_name).unlink()
|
||||
BACKUP_DIR.joinpath(file_name).unlink()
|
||||
except:
|
||||
HTTPException(
|
||||
status_code=400,
|
||||
detail=SnackResponse.error("Unable to Delete Backup. See Log File"),
|
||||
)
|
||||
|
||||
return SnackResponse.success(f"{backup_name} Deleted")
|
||||
return SnackResponse.success(f"{file_name} Deleted")
|
||||
|
||||
67
mealie/routes/debug_routes.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import json
|
||||
|
||||
from app_config import APP_VERSION, DEBUG_DIR
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
from utils.logger import LOGGER_FILE
|
||||
|
||||
router = APIRouter(prefix="/api/debug", tags=["Debug"])
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
async def get_mealie_version():
|
||||
""" Returns the current version of mealie"""
|
||||
return {"version": APP_VERSION}
|
||||
|
||||
|
||||
@router.get("/last-recipe-json")
|
||||
async def get_last_recipe_json():
|
||||
""" Doc Str """
|
||||
|
||||
with open(DEBUG_DIR.joinpath("last_recipe.json"), "r") as f:
|
||||
return json.loads(f.read())
|
||||
|
||||
|
||||
@router.get("/log/{num}", response_class=HTMLResponse)
|
||||
async def get_log(num: int):
|
||||
""" Doc Str """
|
||||
with open(LOGGER_FILE, "rb") as f:
|
||||
log_text = tail(f, num)
|
||||
HTML_RESPONSE = f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>Mealie Log</title>
|
||||
</head>
|
||||
<body style="white-space: pre-line">
|
||||
<p>
|
||||
{log_text}
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return HTML_RESPONSE
|
||||
|
||||
|
||||
def tail(f, lines=20):
|
||||
total_lines_wanted = lines
|
||||
|
||||
BLOCK_SIZE = 1024
|
||||
f.seek(0, 2)
|
||||
block_end_byte = f.tell()
|
||||
lines_to_go = total_lines_wanted
|
||||
block_number = -1
|
||||
blocks = []
|
||||
while lines_to_go > 0 and block_end_byte > 0:
|
||||
if block_end_byte - BLOCK_SIZE > 0:
|
||||
f.seek(block_number * BLOCK_SIZE, 2)
|
||||
blocks.append(f.read(BLOCK_SIZE))
|
||||
else:
|
||||
f.seek(0, 0)
|
||||
blocks.append(f.read(block_end_byte))
|
||||
lines_found = blocks[-1].count(b"\n")
|
||||
lines_to_go -= lines_found
|
||||
block_end_byte -= BLOCK_SIZE
|
||||
block_number -= 1
|
||||
all_read_text = b"".join(reversed(blocks))
|
||||
return b"<br/>".join(all_read_text.splitlines()[-total_lines_wanted:])
|
||||
@@ -1,24 +1,26 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from db.db_setup import generate_session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from services.meal_services import MealPlan
|
||||
from sqlalchemy.orm.session import Session
|
||||
from utils.snackbar import SnackResponse
|
||||
|
||||
router = APIRouter(tags=["Meal Plan"])
|
||||
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
|
||||
|
||||
|
||||
@router.get("/api/meal-plan/all/", response_model=List[MealPlan])
|
||||
def get_all_meals():
|
||||
@router.get("/all", response_model=List[MealPlan])
|
||||
def get_all_meals(db: Session = Depends(generate_session)):
|
||||
""" Returns a list of all available Meal Plan """
|
||||
|
||||
return MealPlan.get_all()
|
||||
return MealPlan.get_all(db)
|
||||
|
||||
|
||||
@router.post("/api/meal-plan/create/")
|
||||
def set_meal_plan(data: MealPlan):
|
||||
@router.post("/create")
|
||||
def set_meal_plan(data: MealPlan, db: Session = Depends(generate_session)):
|
||||
""" Creates a meal plan database entry """
|
||||
data.process_meals()
|
||||
data.save_to_db()
|
||||
data.process_meals(db)
|
||||
data.save_to_db(db)
|
||||
|
||||
# raise HTTPException(
|
||||
# status_code=404,
|
||||
@@ -28,11 +30,20 @@ def set_meal_plan(data: MealPlan):
|
||||
return SnackResponse.success("Mealplan Created")
|
||||
|
||||
|
||||
@router.post("/api/meal-plan/{plan_id}/update/")
|
||||
def update_meal_plan(plan_id: str, meal_plan: MealPlan):
|
||||
@router.get("/this-week", response_model=MealPlan)
|
||||
def get_this_week(db: Session = Depends(generate_session)):
|
||||
""" Returns the meal plan data for this week """
|
||||
|
||||
return MealPlan.this_week(db)
|
||||
|
||||
|
||||
@router.put("/{plan_id}")
|
||||
def update_meal_plan(
|
||||
plan_id: str, meal_plan: MealPlan, db: Session = Depends(generate_session)
|
||||
):
|
||||
""" Updates a meal plan based off ID """
|
||||
meal_plan.process_meals()
|
||||
meal_plan.update(plan_id)
|
||||
meal_plan.process_meals(db)
|
||||
meal_plan.update(db, plan_id)
|
||||
# try:
|
||||
# meal_plan.process_meals()
|
||||
# meal_plan.update(plan_id)
|
||||
@@ -45,30 +56,20 @@ def update_meal_plan(plan_id: str, meal_plan: MealPlan):
|
||||
return SnackResponse.success("Mealplan Updated")
|
||||
|
||||
|
||||
@router.delete("/api/meal-plan/{plan_id}/delete/")
|
||||
def delete_meal_plan(plan_id):
|
||||
@router.delete("/{plan_id}")
|
||||
def delete_meal_plan(plan_id, db: Session = Depends(generate_session)):
|
||||
""" Removes a meal plan from the database """
|
||||
|
||||
MealPlan.delete(plan_id)
|
||||
MealPlan.delete(db, plan_id)
|
||||
|
||||
return SnackResponse.success("Mealplan Deleted")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/meal-plan/today/",
|
||||
tags=["Meal Plan"],
|
||||
)
|
||||
def get_today():
|
||||
@router.get("/today", tags=["Meal Plan"])
|
||||
def get_today(db: Session = Depends(generate_session)):
|
||||
"""
|
||||
Returns the recipe slug for the meal scheduled for today.
|
||||
If no meal is scheduled nothing is returned
|
||||
"""
|
||||
|
||||
return MealPlan.today()
|
||||
|
||||
|
||||
@router.get("/api/meal-plan/this-week/", response_model=MealPlan)
|
||||
def get_this_week():
|
||||
""" Returns the meal plan data for this week """
|
||||
|
||||
return MealPlan.this_week()
|
||||
return MealPlan.today(db)
|
||||
|
||||
@@ -1,60 +1,59 @@
|
||||
import operator
|
||||
import shutil
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from models.migration_models import ChowdownURL
|
||||
from app_config import MIGRATION_DIR
|
||||
from db.db_setup import generate_session
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from models.migration_models import MigrationFile, Migrations
|
||||
from services.migrations.chowdown import chowdown_migrate as chowdow_migrate
|
||||
from services.migrations.nextcloud import migrate as nextcloud_migrate
|
||||
from app_config import MIGRATION_DIR
|
||||
from sqlalchemy.orm.session import Session
|
||||
from utils.snackbar import SnackResponse
|
||||
|
||||
router = APIRouter(tags=["Migration"])
|
||||
router = APIRouter(prefix="/api/migrations", tags=["Migration"])
|
||||
|
||||
|
||||
# Chowdown
|
||||
@router.post("/api/migration/chowdown/repo/")
|
||||
def import_chowdown_recipes(repo: ChowdownURL):
|
||||
""" Import Chowsdown Recipes from Repo URL """
|
||||
try:
|
||||
report = chowdow_migrate(repo.url)
|
||||
return SnackResponse.success(
|
||||
"Recipes Imported from Git Repo, see report for failures.",
|
||||
additional_data=report,
|
||||
)
|
||||
except:
|
||||
return HTTPException(
|
||||
status_code=400,
|
||||
detail=SnackResponse.error(
|
||||
"Unable to Migrate Recipes. See Log for Details"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Nextcloud
|
||||
@router.get("/api/migration/nextcloud/available/")
|
||||
@router.get("", response_model=List[Migrations])
|
||||
def get_avaiable_nextcloud_imports():
|
||||
""" Returns a list of avaiable directories that can be imported into Mealie """
|
||||
available = []
|
||||
for dir in MIGRATION_DIR.iterdir():
|
||||
if dir.is_dir():
|
||||
available.append(dir.stem)
|
||||
elif dir.suffix == ".zip":
|
||||
available.append(dir.name)
|
||||
response_data = []
|
||||
migration_dirs = [
|
||||
MIGRATION_DIR.joinpath("nextcloud"),
|
||||
MIGRATION_DIR.joinpath("chowdown"),
|
||||
]
|
||||
for directory in migration_dirs:
|
||||
migration = Migrations(type=directory.stem)
|
||||
for zip in directory.iterdir():
|
||||
if zip.suffix == ".zip":
|
||||
migration_zip = MigrationFile(name=zip.name, date=zip.stat().st_ctime)
|
||||
migration.files.append(migration_zip)
|
||||
response_data.append(migration)
|
||||
|
||||
return available
|
||||
migration.files.sort(key=operator.attrgetter("date"), reverse=True)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.post("/api/migration/nextcloud/{selection}/import/")
|
||||
def import_nextcloud_directory(selection: str):
|
||||
@router.post("/{type}/{file_name}/import")
|
||||
def import_nextcloud_directory(
|
||||
type: str, file_name: str, db: Session = Depends(generate_session)
|
||||
):
|
||||
""" Imports all the recipes in a given directory """
|
||||
|
||||
return nextcloud_migrate(selection)
|
||||
file_path = MIGRATION_DIR.joinpath(type, file_name)
|
||||
if type == "nextcloud":
|
||||
return nextcloud_migrate(db, file_path)
|
||||
elif type == "chowdown":
|
||||
return chowdow_migrate(db, file_path)
|
||||
else:
|
||||
return SnackResponse.error("Incorrect Migration Type Selected")
|
||||
|
||||
|
||||
@router.delete("/api/migration/{file_folder_name}/delete/")
|
||||
def delete_migration_data(file_folder_name: str):
|
||||
@router.delete("/{type}/{file_name}/delete")
|
||||
def delete_migration_data(type: str, file_name: str):
|
||||
""" Removes migration data from the file system """
|
||||
|
||||
remove_path = MIGRATION_DIR.joinpath(file_folder_name)
|
||||
remove_path = MIGRATION_DIR.joinpath(type, file_name)
|
||||
|
||||
if remove_path.is_file():
|
||||
remove_path.unlink()
|
||||
@@ -66,10 +65,12 @@ def delete_migration_data(file_folder_name: str):
|
||||
return SnackResponse.info(f"Migration Data Remove: {remove_path.absolute()}")
|
||||
|
||||
|
||||
@router.post("/api/migration/upload/")
|
||||
def upload_nextcloud_zipfile(archive: UploadFile = File(...)):
|
||||
@router.post("/{type}/upload")
|
||||
def upload_nextcloud_zipfile(type: str, archive: UploadFile = File(...)):
|
||||
""" Upload a .zip File to later be imported into Mealie """
|
||||
dest = MIGRATION_DIR.joinpath(archive.filename)
|
||||
dir = MIGRATION_DIR.joinpath(type)
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dir.joinpath(archive.filename)
|
||||
|
||||
with dest.open("wb") as buffer:
|
||||
shutil.copyfileobj(archive.file, buffer)
|
||||
|
||||
71
mealie/routes/recipe/all_recipe_routes.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from db.database import db
|
||||
from db.db_setup import generate_session
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from models.recipe_models import AllRecipeRequest
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
router = APIRouter(tags=["Query All Recipes"])
|
||||
|
||||
|
||||
@router.get("/api/recipes")
|
||||
def get_all_recipes(
|
||||
keys: Optional[List[str]] = Query(...),
|
||||
num: Optional[int] = 100,
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
"""
|
||||
Returns key data for all recipes based off the query paramters provided.
|
||||
For example, if slug, image, and name are provided you will recieve a list of
|
||||
recipes containing the slug, image, and name property. By default, responses
|
||||
are limited to 100.
|
||||
|
||||
At this time you can only query top level values:
|
||||
|
||||
- slug
|
||||
- name
|
||||
- description
|
||||
- image
|
||||
- recipeYield
|
||||
- totalTime
|
||||
- prepTime
|
||||
- performTime
|
||||
- rating
|
||||
- orgURL
|
||||
|
||||
**Note:** You may experience problems with with query parameters. As an alternative
|
||||
you may also use the post method and provide a body.
|
||||
See the *Post* method for more details.
|
||||
"""
|
||||
|
||||
return db.recipes.get_all_limit_columns(session, keys, limit=num)
|
||||
|
||||
|
||||
@router.post("/api/recipes")
|
||||
def get_all_recipes_post(
|
||||
body: AllRecipeRequest, session: Session = Depends(generate_session)
|
||||
):
|
||||
"""
|
||||
Returns key data for all recipes based off the body data provided.
|
||||
For example, if slug, image, and name are provided you will recieve a list of
|
||||
recipes containing the slug, image, and name property.
|
||||
|
||||
At this time you can only query top level values:
|
||||
|
||||
- slug
|
||||
- name
|
||||
- description
|
||||
- image
|
||||
- recipeYield
|
||||
- totalTime
|
||||
- prepTime
|
||||
- performTime
|
||||
- rating
|
||||
- orgURL
|
||||
|
||||
Refer to the body example for data formats.
|
||||
|
||||
"""
|
||||
|
||||
return db.recipes.get_all_limit_columns(session, body.properties, body.limit)
|
||||
35
mealie/routes/recipe/category_routes.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from db.database import db
|
||||
from db.db_setup import generate_session
|
||||
from fastapi import APIRouter, Depends
|
||||
from models.category_models import RecipeCategoryResponse
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/categories",
|
||||
tags=["Recipe Categories"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_all_recipe_categories(session: Session = Depends(generate_session)):
|
||||
""" Returns a list of available categories in the database """
|
||||
return db.categories.get_all_limit_columns(session, ["slug", "name"])
|
||||
|
||||
|
||||
@router.get("/{category}", response_model=RecipeCategoryResponse)
|
||||
def get_all_recipes_by_category(
|
||||
category: str, session: Session = Depends(generate_session)
|
||||
):
|
||||
""" Returns a list of recipes associated with the provided category. """
|
||||
return db.categories.get(session, category)
|
||||
|
||||
|
||||
@router.delete("/{category}")
|
||||
async def delete_recipe_category(
|
||||
category: str, session: Session = Depends(generate_session)
|
||||
):
|
||||
""" Removes a recipe category from the database. Deleting a
|
||||
category does not impact a recipe. The category will be removed
|
||||
from any recipes that contain it """
|
||||
|
||||
db.categories.delete(session, category)
|
||||
84
mealie/routes/recipe/recipe_crud_routes.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from db.db_setup import generate_session
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from models.recipe_models import RecipeURLIn
|
||||
from services.image_services import read_image, write_image
|
||||
from services.recipe_services import Recipe
|
||||
from services.scrape_services import create_from_url
|
||||
from sqlalchemy.orm.session import Session
|
||||
from utils.snackbar import SnackResponse
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/recipes",
|
||||
tags=["Recipe CRUD"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/create", status_code=201, response_model=str)
|
||||
def create_from_json(data: Recipe, db: Session = Depends(generate_session)) -> str:
|
||||
""" Takes in a JSON string and loads data into the database as a new entry"""
|
||||
new_recipe_slug = data.save_to_db(db)
|
||||
|
||||
return new_recipe_slug
|
||||
|
||||
|
||||
@router.post("/create-url", status_code=201, response_model=str)
|
||||
def parse_recipe_url(url: RecipeURLIn, db: Session = Depends(generate_session)):
|
||||
""" Takes in a URL and attempts to scrape data and load it into the database """
|
||||
|
||||
recipe = create_from_url(url.url)
|
||||
recipe.save_to_db(db)
|
||||
|
||||
return recipe.slug
|
||||
|
||||
|
||||
@router.get("/{recipe_slug}", response_model=Recipe)
|
||||
def get_recipe(recipe_slug: str, db: Session = Depends(generate_session)):
|
||||
""" Takes in a recipe slug, returns all data for a recipe """
|
||||
recipe = Recipe.get_by_slug(db, recipe_slug)
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
@router.put("/{recipe_slug}")
|
||||
def update_recipe(
|
||||
recipe_slug: str, data: Recipe, db: Session = Depends(generate_session)
|
||||
):
|
||||
""" Updates a recipe by existing slug and data. """
|
||||
|
||||
new_slug = data.update(db, recipe_slug)
|
||||
|
||||
return new_slug
|
||||
|
||||
|
||||
@router.delete("/{recipe_slug}")
|
||||
def delete_recipe(recipe_slug: str, db: Session = Depends(generate_session)):
|
||||
""" Deletes a recipe by slug """
|
||||
|
||||
try:
|
||||
Recipe.delete(db, recipe_slug)
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=SnackResponse.error("Unable to Delete Recipe")
|
||||
)
|
||||
|
||||
return SnackResponse.success("Recipe Deleted")
|
||||
|
||||
|
||||
@router.get("/{recipe_slug}/image")
|
||||
def get_recipe_img(recipe_slug: str):
|
||||
""" Takes in a recipe slug, returns the static image """
|
||||
recipe_image = read_image(recipe_slug)
|
||||
|
||||
return FileResponse(recipe_image)
|
||||
|
||||
|
||||
@router.put("/{recipe_slug}/image")
|
||||
def update_recipe_image(
|
||||
recipe_slug: str, image: bytes = File(...), extension: str = Form(...)
|
||||
):
|
||||
""" Removes an existing image and replaces it with the incoming file. """
|
||||
response = write_image(recipe_slug, image, extension)
|
||||
Recipe.update_image(recipe_slug, extension)
|
||||
|
||||
return response
|
||||
32
mealie/routes/recipe/tag_routes.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from db.database import db
|
||||
from db.db_setup import generate_session
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
router = APIRouter(tags=["Recipes"])
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/recipes/tags",
|
||||
tags=["Recipe Tags"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
async def get_all_recipe_tags(session: Session = Depends(generate_session)):
|
||||
""" Returns a list of available tags in the database """
|
||||
return db.tags.get_all_primary_keys(session)
|
||||
|
||||
|
||||
@router.get("/{tag}")
|
||||
def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)):
|
||||
""" Returns a list of recipes associated with the provided tag. """
|
||||
return db.tags.get(session, tag)
|
||||
|
||||
|
||||
@router.delete("/{tag}")
|
||||
async def delete_recipe_tag(tag: str, session: Session = Depends(generate_session)):
|
||||
"""Removes a recipe tag from the database. Deleting a
|
||||
tag does not impact a recipe. The tag will be removed
|
||||
from any recipes that contain it"""
|
||||
|
||||
db.tags.delete(session, tag)
|
||||
@@ -1,117 +0,0 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from models.recipe_models import AllRecipeRequest, RecipeURLIn
|
||||
from services.image_services import read_image, write_image
|
||||
from services.recipe_services import Recipe, read_requested_values
|
||||
from services.scrape_services import create_from_url
|
||||
from utils.snackbar import SnackResponse
|
||||
|
||||
router = APIRouter(tags=["Recipes"])
|
||||
|
||||
|
||||
@router.get("/api/all-recipes/", response_model=List[dict])
|
||||
def get_all_recipes(keys: Optional[List[str]] = Query(...), num: Optional[int] = 100):
|
||||
"""
|
||||
Returns key data for all recipes based off the query paramters provided.
|
||||
For example, if slug, image, and name are provided you will recieve a list of
|
||||
recipes containing the slug, image, and name property. By default, responses
|
||||
are limited to 100.
|
||||
|
||||
**Note:** You may experience problems with with query parameters. As an alternative
|
||||
you may also use the post method and provide a body.
|
||||
See the *Post* method for more details.
|
||||
"""
|
||||
|
||||
all_recipes = read_requested_values(keys, num)
|
||||
return all_recipes
|
||||
|
||||
|
||||
@router.post("/api/all-recipes/", response_model=List[dict])
|
||||
def get_all_recipes_post(body: AllRecipeRequest):
|
||||
"""
|
||||
Returns key data for all recipes based off the body data provided.
|
||||
For example, if slug, image, and name are provided you will recieve a list of
|
||||
recipes containing the slug, image, and name property.
|
||||
|
||||
Refer to the body example for data formats.
|
||||
|
||||
"""
|
||||
|
||||
all_recipes = read_requested_values(body.properties, body.limit)
|
||||
|
||||
return all_recipes
|
||||
|
||||
|
||||
@router.get("/api/recipe/{recipe_slug}/", response_model=Recipe)
|
||||
def get_recipe(recipe_slug: str):
|
||||
""" Takes in a recipe slug, returns all data for a recipe """
|
||||
recipe = Recipe.get_by_slug(recipe_slug)
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
@router.get("/api/recipe/image/{recipe_slug}/")
|
||||
def get_recipe_img(recipe_slug: str):
|
||||
""" Takes in a recipe slug, returns the static image """
|
||||
recipe_image = read_image(recipe_slug)
|
||||
|
||||
return FileResponse(recipe_image)
|
||||
|
||||
|
||||
# Recipe Creations
|
||||
@router.post(
|
||||
"/api/recipe/create-url/",
|
||||
tags=["Recipes"],
|
||||
status_code=201,
|
||||
response_model=str,
|
||||
)
|
||||
def parse_recipe_url(url: RecipeURLIn):
|
||||
""" Takes in a URL and attempts to scrape data and load it into the database """
|
||||
|
||||
slug = create_from_url(url.url)
|
||||
|
||||
return slug
|
||||
|
||||
|
||||
@router.post("/api/recipe/create/")
|
||||
def create_from_json(data: Recipe) -> str:
|
||||
""" Takes in a JSON string and loads data into the database as a new entry"""
|
||||
created_recipe = data.save_to_db()
|
||||
|
||||
return created_recipe
|
||||
|
||||
|
||||
@router.post("/api/recipe/{recipe_slug}/update/image/")
|
||||
def update_recipe_image(
|
||||
recipe_slug: str, image: bytes = File(...), extension: str = Form(...)
|
||||
):
|
||||
""" Removes an existing image and replaces it with the incoming file. """
|
||||
response = write_image(recipe_slug, image, extension)
|
||||
Recipe.update_image(recipe_slug, extension)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/api/recipe/{recipe_slug}/update/")
|
||||
def update_recipe(recipe_slug: str, data: Recipe):
|
||||
""" Updates a recipe by existing slug and data. """
|
||||
|
||||
new_slug = data.update(recipe_slug)
|
||||
|
||||
return new_slug
|
||||
|
||||
|
||||
@router.delete("/api/recipe/{recipe_slug}/delete/")
|
||||
def delete_recipe(recipe_slug: str):
|
||||
""" Deletes a recipe by slug """
|
||||
|
||||
try:
|
||||
Recipe.delete(recipe_slug)
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=SnackResponse.error("Unable to Delete Recipe")
|
||||
)
|
||||
|
||||
return SnackResponse.success("Recipe Deleted")
|
||||
@@ -1,30 +1,31 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from services.scheduler_services import post_webhooks
|
||||
from services.settings_services import SiteSettings, SiteTheme
|
||||
from utils.global_scheduler import scheduler
|
||||
from db.db_setup import generate_session
|
||||
from fastapi import APIRouter, Depends
|
||||
from services.settings_services import SiteSettings
|
||||
from sqlalchemy.orm.session import Session
|
||||
from utils.post_webhooks import post_webhooks
|
||||
from utils.snackbar import SnackResponse
|
||||
|
||||
router = APIRouter(tags=["Settings"])
|
||||
router = APIRouter(prefix="/api/site-settings", tags=["Settings"])
|
||||
|
||||
|
||||
@router.get("/api/site-settings/")
|
||||
def get_main_settings():
|
||||
@router.get("")
|
||||
def get_main_settings(db: Session = Depends(generate_session)):
|
||||
""" Returns basic site settings """
|
||||
|
||||
return SiteSettings.get_site_settings()
|
||||
return SiteSettings.get_site_settings(db)
|
||||
|
||||
|
||||
@router.post("/api/site-settings/webhooks/test/")
|
||||
@router.post("/webhooks/test")
|
||||
def test_webhooks():
|
||||
""" Run the function to test your webhooks """
|
||||
|
||||
return post_webhooks()
|
||||
|
||||
|
||||
@router.post("/api/site-settings/update/")
|
||||
def update_settings(data: SiteSettings):
|
||||
@router.put("")
|
||||
def update_settings(data: SiteSettings, db: Session = Depends(generate_session)):
|
||||
""" Returns Site Settings """
|
||||
data.update()
|
||||
data.update(db)
|
||||
# try:
|
||||
# data.update()
|
||||
# except:
|
||||
@@ -32,60 +33,7 @@ def update_settings(data: SiteSettings):
|
||||
# status_code=400, detail=SnackResponse.error("Unable to Save Settings")
|
||||
# )
|
||||
|
||||
scheduler.reschedule_webhooks()
|
||||
return SnackResponse.success("Settings Updated")
|
||||
|
||||
|
||||
@router.get("/api/site-settings/themes/", tags=["Themes"])
|
||||
def get_all_themes():
|
||||
""" Returns all site themes """
|
||||
|
||||
return SiteTheme.get_all()
|
||||
|
||||
|
||||
@router.get("/api/site-settings/themes/{theme_name}/", tags=["Themes"])
|
||||
def get_single_theme(theme_name: str):
|
||||
""" Returns a named theme """
|
||||
return SiteTheme.get_by_name(theme_name)
|
||||
|
||||
|
||||
@router.post("/api/site-settings/themes/create/", tags=["Themes"])
|
||||
def create_theme(data: SiteTheme):
|
||||
""" Creates a site color theme database entry """
|
||||
data.save_to_db()
|
||||
# try:
|
||||
# data.save_to_db()
|
||||
# except:
|
||||
# raise HTTPException(
|
||||
# status_code=400, detail=SnackResponse.error("Unable to Save Theme")
|
||||
# )
|
||||
|
||||
return SnackResponse.success("Theme Saved")
|
||||
|
||||
|
||||
@router.post("/api/site-settings/themes/{theme_name}/update/", tags=["Themes"])
|
||||
def update_theme(theme_name: str, data: SiteTheme):
|
||||
""" Update a theme database entry """
|
||||
data.update_document()
|
||||
|
||||
# try:
|
||||
# except:
|
||||
# raise HTTPException(
|
||||
# status_code=400, detail=SnackResponse.error("Unable to Update Theme")
|
||||
# )
|
||||
|
||||
return SnackResponse.success("Theme Updated")
|
||||
|
||||
|
||||
@router.delete("/api/site-settings/themes/{theme_name}/delete/", tags=["Themes"])
|
||||
def delete_theme(theme_name: str):
|
||||
""" Deletes theme from the database """
|
||||
SiteTheme.delete_theme(theme_name)
|
||||
# try:
|
||||
# SiteTheme.delete_theme(theme_name)
|
||||
# except:
|
||||
# raise HTTPException(
|
||||
# status_code=400, detail=SnackResponse.error("Unable to Delete Theme")
|
||||
# )
|
||||
|
||||
return SnackResponse.success("Theme Deleted")
|
||||
|
||||
64
mealie/routes/theme_routes.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from db.db_setup import generate_session
|
||||
from fastapi import APIRouter, Depends
|
||||
from services.settings_services import SiteTheme
|
||||
from sqlalchemy.orm.session import Session
|
||||
from utils.snackbar import SnackResponse
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["Themes"])
|
||||
|
||||
|
||||
@router.get("/themes")
|
||||
def get_all_themes(db: Session = Depends(generate_session)):
|
||||
""" Returns all site themes """
|
||||
|
||||
return SiteTheme.get_all(db)
|
||||
|
||||
|
||||
@router.post("/themes/create")
|
||||
def create_theme(data: SiteTheme, db: Session = Depends(generate_session)):
|
||||
""" Creates a site color theme database entry """
|
||||
data.save_to_db(db)
|
||||
# try:
|
||||
# data.save_to_db()
|
||||
# except:
|
||||
# raise HTTPException(
|
||||
# status_code=400, detail=SnackResponse.error("Unable to Save Theme")
|
||||
# )
|
||||
|
||||
return SnackResponse.success("Theme Saved")
|
||||
|
||||
|
||||
@router.get("/themes/{theme_name}")
|
||||
def get_single_theme(theme_name: str, db: Session = Depends(generate_session)):
|
||||
""" Returns a named theme """
|
||||
return SiteTheme.get_by_name(db, theme_name)
|
||||
|
||||
|
||||
@router.put("/themes/{theme_name}")
|
||||
def update_theme(
|
||||
theme_name: str, data: SiteTheme, db: Session = Depends(generate_session)
|
||||
):
|
||||
""" Update a theme database entry """
|
||||
data.update_document(db)
|
||||
|
||||
# try:
|
||||
# except:
|
||||
# raise HTTPException(
|
||||
# status_code=400, detail=SnackResponse.error("Unable to Update Theme")
|
||||
# )
|
||||
|
||||
return SnackResponse.success("Theme Updated")
|
||||
|
||||
|
||||
@router.delete("/themes/{theme_name}")
|
||||
def delete_theme(theme_name: str, db: Session = Depends(generate_session)):
|
||||
""" Deletes theme from the database """
|
||||
SiteTheme.delete_theme(db, theme_name)
|
||||
# try:
|
||||
# SiteTheme.delete_theme(theme_name)
|
||||
# except:
|
||||
# raise HTTPException(
|
||||
# status_code=400, detail=SnackResponse.error("Unable to Delete Theme")
|
||||
# )
|
||||
|
||||
return SnackResponse.success("Theme Deleted")
|
||||
@@ -3,16 +3,17 @@ import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app_config import BACKUP_DIR, IMG_DIR, TEMP_DIR, TEMPLATE_DIR
|
||||
from db.db_setup import create_session
|
||||
from jinja2 import Template
|
||||
from services.meal_services import MealPlan
|
||||
from services.recipe_services import Recipe
|
||||
from services.settings_services import SiteSettings, SiteTheme
|
||||
from app_config import BACKUP_DIR, IMG_DIR, TEMP_DIR, TEMPLATE_DIR
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
class ExportDatabase:
|
||||
def __init__(self, tag=None, templates=None) -> None:
|
||||
def __init__(self, session, tag=None, templates=None) -> None:
|
||||
"""Export a Mealie database. Export interacts directly with class objects and can be used
|
||||
with any supported backend database platform. By default tags are timestands, and no Jinja2 templates are rendered
|
||||
|
||||
@@ -26,6 +27,7 @@ class ExportDatabase:
|
||||
else:
|
||||
export_tag = datetime.now().strftime("%Y-%b-%d")
|
||||
|
||||
self.session = session
|
||||
self.main_dir = TEMP_DIR.joinpath(export_tag)
|
||||
self.img_dir = self.main_dir.joinpath("images")
|
||||
self.recipe_dir = self.main_dir.joinpath("recipes")
|
||||
@@ -54,7 +56,7 @@ class ExportDatabase:
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def export_recipes(self):
|
||||
all_recipes = Recipe.get_all()
|
||||
all_recipes = Recipe.get_all(self.session)
|
||||
|
||||
for recipe in all_recipes:
|
||||
logger.info(f"Backing Up Recipes: {recipe}")
|
||||
@@ -86,12 +88,12 @@ class ExportDatabase:
|
||||
shutil.copy(file, self.img_dir.joinpath(file.name))
|
||||
|
||||
def export_settings(self):
|
||||
all_settings = SiteSettings.get_site_settings()
|
||||
all_settings = SiteSettings.get_site_settings(self.session)
|
||||
out_file = self.settings_dir.joinpath("settings.json")
|
||||
ExportDatabase._write_json_file(all_settings.dict(), out_file)
|
||||
|
||||
def export_themes(self):
|
||||
all_themes = SiteTheme.get_all()
|
||||
all_themes = SiteTheme.get_all(self.session)
|
||||
if all_themes:
|
||||
all_themes = [x.dict() for x in all_themes]
|
||||
out_file = self.themes_dir.joinpath("themes.json")
|
||||
@@ -100,7 +102,7 @@ class ExportDatabase:
|
||||
def export_meals(
|
||||
self,
|
||||
): #! Problem Parseing Datetime Objects... May come back to this
|
||||
meal_plans = MealPlan.get_all()
|
||||
meal_plans = MealPlan.get_all(self.session)
|
||||
if meal_plans:
|
||||
meal_plans = [x.dict() for x in meal_plans]
|
||||
|
||||
@@ -123,15 +125,27 @@ class ExportDatabase:
|
||||
return str(zip_path.absolute()) + ".zip"
|
||||
|
||||
|
||||
def backup_all(tag=None, templates=None):
|
||||
db_export = ExportDatabase(tag=tag, templates=templates)
|
||||
def backup_all(
|
||||
session,
|
||||
tag=None,
|
||||
templates=None,
|
||||
export_recipes=True,
|
||||
export_settings=True,
|
||||
export_themes=True,
|
||||
):
|
||||
db_export = ExportDatabase(session=session, tag=tag, templates=templates)
|
||||
|
||||
if export_recipes:
|
||||
db_export.export_recipes()
|
||||
db_export.export_images()
|
||||
|
||||
if export_settings:
|
||||
db_export.export_settings()
|
||||
|
||||
if export_themes:
|
||||
db_export.export_themes()
|
||||
# db_export.export_meals()
|
||||
|
||||
db_export.export_recipes()
|
||||
db_export.export_images()
|
||||
db_export.export_settings()
|
||||
db_export.export_themes()
|
||||
db_export.export_meals()
|
||||
#
|
||||
return db_export.finish_export()
|
||||
|
||||
|
||||
@@ -143,5 +157,6 @@ def auto_backup_job():
|
||||
for template in TEMPLATE_DIR.iterdir():
|
||||
templates.append(template)
|
||||
|
||||
backup_all(tag="Auto", templates=templates)
|
||||
session = create_session()
|
||||
backup_all(session=session, tag="Auto", templates=templates)
|
||||
logger.info("Auto Backup Called")
|
||||
|
||||
@@ -7,12 +7,14 @@ from typing import List
|
||||
from app_config import BACKUP_DIR, IMG_DIR, TEMP_DIR
|
||||
from services.recipe_services import Recipe
|
||||
from services.settings_services import SiteSettings, SiteTheme
|
||||
from sqlalchemy.orm.session import Session
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
class ImportDatabase:
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
zip_archive: str,
|
||||
import_recipes: bool = True,
|
||||
import_settings: bool = True,
|
||||
@@ -33,7 +35,7 @@ class ImportDatabase:
|
||||
Raises:
|
||||
Exception: If the zip file does not exists an exception raise.
|
||||
"""
|
||||
|
||||
self.session = session
|
||||
self.archive = BACKUP_DIR.joinpath(zip_archive)
|
||||
self.imp_recipes = import_recipes
|
||||
self.imp_settings = import_settings
|
||||
@@ -75,10 +77,11 @@ class ImportDatabase:
|
||||
recipe_dict = ImportDatabase._recipe_migration(recipe_dict)
|
||||
try:
|
||||
recipe_obj = Recipe(**recipe_dict)
|
||||
recipe_obj.save_to_db()
|
||||
recipe_obj.save_to_db(self.session)
|
||||
successful_imports.append(recipe.stem)
|
||||
logger.info(f"Imported: {recipe.stem}")
|
||||
except:
|
||||
except Exception as inst:
|
||||
logger.error(inst)
|
||||
logger.info(f"Failed Import: {recipe.stem}")
|
||||
failed_imports.append(recipe.stem)
|
||||
|
||||
@@ -114,7 +117,7 @@ class ImportDatabase:
|
||||
for theme in themes:
|
||||
new_theme = SiteTheme(**theme)
|
||||
try:
|
||||
new_theme.save_to_db()
|
||||
new_theme.save_to_db(self.session)
|
||||
except:
|
||||
logger.info(f"Unable Import Theme {new_theme.name}")
|
||||
|
||||
@@ -126,7 +129,7 @@ class ImportDatabase:
|
||||
|
||||
settings = SiteSettings(**settings)
|
||||
|
||||
settings.update()
|
||||
settings.update(self.session)
|
||||
|
||||
def clean_up(self):
|
||||
shutil.rmtree(TEMP_DIR)
|
||||
|
||||
@@ -2,9 +2,7 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
IMG_DIR = CWD.parent.joinpath("data", "img")
|
||||
from app_config import IMG_DIR
|
||||
|
||||
|
||||
def read_image(recipe_slug: str) -> Path:
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import List, Optional
|
||||
|
||||
from db.database import db
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from services.recipe_services import Recipe
|
||||
|
||||
@@ -31,6 +32,7 @@ class Meal(BaseModel):
|
||||
|
||||
|
||||
class MealData(BaseModel):
|
||||
name: Optional[str]
|
||||
slug: str
|
||||
dateText: str
|
||||
|
||||
@@ -53,12 +55,12 @@ class MealPlan(BaseModel):
|
||||
}
|
||||
}
|
||||
|
||||
def process_meals(self):
|
||||
def process_meals(self, session: Session):
|
||||
meals = []
|
||||
for x, meal in enumerate(self.meals):
|
||||
|
||||
try:
|
||||
recipe = Recipe.get_by_slug(meal.slug)
|
||||
recipe = Recipe.get_by_slug(session, meal.slug)
|
||||
|
||||
meal_data = {
|
||||
"slug": recipe.slug,
|
||||
@@ -78,27 +80,29 @@ class MealPlan(BaseModel):
|
||||
|
||||
self.meals = meals
|
||||
|
||||
def save_to_db(self):
|
||||
db.meals.save_new(self.dict())
|
||||
def save_to_db(self, session: Session):
|
||||
db.meals.save_new(session, self.dict())
|
||||
|
||||
@staticmethod
|
||||
def get_all() -> List:
|
||||
def get_all(session: Session) -> List:
|
||||
|
||||
all_meals = [MealPlan(**x) for x in db.meals.get_all(order_by="startDate")]
|
||||
all_meals = [
|
||||
MealPlan(**x) for x in db.meals.get_all(session, order_by="startDate")
|
||||
]
|
||||
|
||||
return all_meals
|
||||
|
||||
def update(self, uid):
|
||||
db.meals.update(uid, self.dict())
|
||||
def update(self, session, uid):
|
||||
db.meals.update(session, uid, self.dict())
|
||||
|
||||
@staticmethod
|
||||
def delete(uid):
|
||||
db.meals.delete(uid)
|
||||
def delete(session, uid):
|
||||
db.meals.delete(session, uid)
|
||||
|
||||
@staticmethod
|
||||
def today() -> str:
|
||||
def today(session: Session) -> str:
|
||||
""" Returns the meal slug for Today """
|
||||
meal_plan = db.meals.get_all(limit=1, order_by="startDate")
|
||||
meal_plan = db.meals.get_all(session, limit=1, order_by="startDate")
|
||||
|
||||
meal_docs = [Meal(**meal) for meal in meal_plan["meals"]]
|
||||
|
||||
@@ -109,7 +113,7 @@ class MealPlan(BaseModel):
|
||||
return "No Meal Today"
|
||||
|
||||
@staticmethod
|
||||
def this_week():
|
||||
meal_plan = db.meals.get_all(limit=1, order_by="startDate")
|
||||
def this_week(session: Session):
|
||||
meal_plan = db.meals.get_all(session, limit=1, order_by="startDate")
|
||||
|
||||
return meal_plan
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import git
|
||||
import yaml
|
||||
from app_config import IMG_DIR, TEMP_DIR
|
||||
from services.recipe_services import Recipe
|
||||
from app_config import IMG_DIR
|
||||
from sqlalchemy.orm.session import Session
|
||||
from utils.unzip import unpack_zip
|
||||
|
||||
try:
|
||||
from yaml import CLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import Loader
|
||||
|
||||
TEMP_DIR = Path(__file__).parent.parent.parent.joinpath("temp")
|
||||
|
||||
|
||||
def pull_repo(repo):
|
||||
dest_dir = TEMP_DIR.joinpath("/migration/git_pull")
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
git.Git(dest_dir).clone(repo)
|
||||
|
||||
repo_name = repo.split("/")[-1]
|
||||
recipe_dir = dest_dir.joinpath(repo_name, "_recipes")
|
||||
image_dir = dest_dir.joinpath(repo_name, "images")
|
||||
|
||||
return recipe_dir, image_dir
|
||||
|
||||
|
||||
def read_chowdown_file(recipe_file: Path) -> Recipe:
|
||||
"""Parse through the yaml file to try and pull out the relavent information.
|
||||
@@ -75,25 +60,31 @@ def read_chowdown_file(recipe_file: Path) -> Recipe:
|
||||
return new_recipe
|
||||
|
||||
|
||||
def chowdown_migrate(repo):
|
||||
recipe_dir, image_dir = pull_repo(repo)
|
||||
def chowdown_migrate(session: Session, zip_file: Path):
|
||||
temp_dir = unpack_zip(zip_file)
|
||||
|
||||
failed_images = []
|
||||
for image in image_dir.iterdir():
|
||||
try:
|
||||
shutil.copy(image, IMG_DIR.joinpath(image.name))
|
||||
except:
|
||||
failed_images.append(image.name)
|
||||
with temp_dir as dir:
|
||||
image_dir = TEMP_DIR.joinpath(dir, zip_file.stem, "images")
|
||||
recipe_dir = TEMP_DIR.joinpath(dir, zip_file.stem, "_recipes")
|
||||
|
||||
failed_recipes = []
|
||||
for recipe in recipe_dir.glob("*.md"):
|
||||
try:
|
||||
new_recipe = read_chowdown_file(recipe)
|
||||
new_recipe.save_to_db()
|
||||
failed_recipes = []
|
||||
successful_recipes = []
|
||||
for recipe in recipe_dir.glob("*.md"):
|
||||
try:
|
||||
new_recipe = read_chowdown_file(recipe)
|
||||
new_recipe.save_to_db(session)
|
||||
successful_recipes.append(recipe.stem)
|
||||
except:
|
||||
failed_recipes.append(recipe.stem)
|
||||
|
||||
except:
|
||||
failed_recipes.append(recipe.name)
|
||||
failed_images = []
|
||||
for image in image_dir.iterdir():
|
||||
try:
|
||||
if not image.stem in failed_recipes:
|
||||
shutil.copy(image, IMG_DIR.joinpath(image.name))
|
||||
except:
|
||||
failed_images.append(image.name)
|
||||
|
||||
report = {"failedImages": failed_images, "failedRecipes": failed_recipes}
|
||||
report = {"successful": successful_recipes, "failed": failed_recipes}
|
||||
|
||||
return report
|
||||
|
||||
@@ -4,13 +4,11 @@ import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from app_config import IMG_DIR, MIGRATION_DIR, TEMP_DIR
|
||||
from services.recipe_services import Recipe
|
||||
from services.scrape_services import normalize_data, process_recipe_data
|
||||
from app_config import IMG_DIR, TEMP_DIR
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
MIGRTAION_DIR = CWD.parent.parent.joinpath("data", "migration")
|
||||
|
||||
|
||||
def process_selection(selection: Path) -> Path:
|
||||
if selection.is_dir():
|
||||
@@ -65,10 +63,10 @@ def cleanup():
|
||||
shutil.rmtree(TEMP_DIR)
|
||||
|
||||
|
||||
def migrate(selection: str):
|
||||
def migrate(session, selection: str):
|
||||
prep()
|
||||
MIGRTAION_DIR.mkdir(exist_ok=True)
|
||||
selection = MIGRTAION_DIR.joinpath(selection)
|
||||
MIGRATION_DIR.mkdir(exist_ok=True)
|
||||
selection = MIGRATION_DIR.joinpath(selection)
|
||||
|
||||
nextcloud_dir = process_selection(selection)
|
||||
|
||||
@@ -76,9 +74,10 @@ def migrate(selection: str):
|
||||
failed_imports = []
|
||||
for dir in nextcloud_dir.iterdir():
|
||||
if dir.is_dir():
|
||||
|
||||
try:
|
||||
recipe = import_recipes(dir)
|
||||
recipe.save_to_db()
|
||||
recipe.save_to_db(session)
|
||||
successful_imports.append(recipe.name)
|
||||
except:
|
||||
logging.error(f"Failed Nextcloud Import: {dir.name}")
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, List, Optional
|
||||
from db.database import db
|
||||
from pydantic import BaseModel, validator
|
||||
from slugify import slugify
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from services.image_services import delete_image
|
||||
|
||||
@@ -34,14 +35,13 @@ class Recipe(BaseModel):
|
||||
|
||||
# Mealie Specific
|
||||
slug: Optional[str] = ""
|
||||
categories: Optional[List[str]]
|
||||
tags: Optional[List[str]]
|
||||
categories: Optional[List[str]] = []
|
||||
tags: Optional[List[str]] = []
|
||||
dateAdded: Optional[datetime.date]
|
||||
notes: Optional[List[RecipeNote]]
|
||||
rating: Optional[int]
|
||||
notes: Optional[List[RecipeNote]] = []
|
||||
rating: Optional[int]
|
||||
orgURL: Optional[str]
|
||||
extras: Optional[dict]
|
||||
extras: Optional[dict] = {}
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
@@ -82,23 +82,14 @@ class Recipe(BaseModel):
|
||||
return slug
|
||||
|
||||
@classmethod
|
||||
def _unpack_doc(cls, document):
|
||||
document = json.loads(document.to_json())
|
||||
del document["_id"]
|
||||
|
||||
document["dateAdded"] = document["dateAdded"]["$date"]
|
||||
|
||||
return cls(**document)
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug: str):
|
||||
def get_by_slug(cls, session, slug: str):
|
||||
""" Returns a Recipe Object by Slug """
|
||||
|
||||
document = db.recipes.get(slug, "slug")
|
||||
document = db.recipes.get(session, slug, "slug")
|
||||
|
||||
return cls(**document)
|
||||
|
||||
def save_to_db(self) -> str:
|
||||
def save_to_db(self, session) -> str:
|
||||
recipe_dict = self.dict()
|
||||
|
||||
try:
|
||||
@@ -113,55 +104,37 @@ class Recipe(BaseModel):
|
||||
# except:
|
||||
# pass
|
||||
|
||||
recipe_doc = db.recipes.save_new(recipe_dict)
|
||||
recipe_doc = db.recipes.save_new(session, recipe_dict)
|
||||
recipe = Recipe(**recipe_doc)
|
||||
|
||||
return recipe.slug
|
||||
|
||||
@staticmethod
|
||||
def delete(recipe_slug: str) -> str:
|
||||
def delete(session: Session, recipe_slug: str) -> str:
|
||||
""" Removes the recipe from the database by slug """
|
||||
delete_image(recipe_slug)
|
||||
db.recipes.delete(recipe_slug)
|
||||
db.recipes.delete(session, recipe_slug)
|
||||
return "Document Deleted"
|
||||
|
||||
def update(self, recipe_slug: str):
|
||||
def update(self, session: Session, recipe_slug: str):
|
||||
""" Updates the recipe from the database by slug"""
|
||||
updated_slug = db.recipes.update(recipe_slug, self.dict())
|
||||
updated_slug = db.recipes.update(session, recipe_slug, self.dict())
|
||||
return updated_slug.get("slug")
|
||||
|
||||
@staticmethod
|
||||
def update_image(slug: str, extension: str):
|
||||
db.recipes.update_image(slug, extension)
|
||||
def update_image(slug: str, extension: str) -> str:
|
||||
"""A helper function to pass the new image name and extension
|
||||
into the database.
|
||||
|
||||
Args:
|
||||
slug (str): The current recipe slug
|
||||
extension (str): the file extension of the new image
|
||||
"""
|
||||
return db.recipes.update_image(slug, extension)
|
||||
|
||||
@staticmethod
|
||||
def get_all():
|
||||
return db.recipes.get_all()
|
||||
def get_all(session: Session):
|
||||
return db.recipes.get_all(session)
|
||||
|
||||
|
||||
def read_requested_values(keys: list, max_results: int = 0) -> List[dict]:
|
||||
"""
|
||||
Pass in a list of key values to be run against the database. If a match is found
|
||||
it is then added to a dictionary inside of a list. If a key does not exist the
|
||||
it will simply not be added to the return data.
|
||||
|
||||
Parameters:
|
||||
keys: list
|
||||
|
||||
Returns: returns a list of dicts containing recipe data
|
||||
|
||||
"""
|
||||
recipe_list = []
|
||||
for recipe in db.recipes.get_all(limit=max_results, order_by="dateAdded"):
|
||||
recipe_details = {}
|
||||
for key in keys:
|
||||
try:
|
||||
recipe_key = {key: recipe[key]}
|
||||
except:
|
||||
continue
|
||||
|
||||
recipe_details.update(recipe_key)
|
||||
|
||||
recipe_list.append(recipe_details)
|
||||
|
||||
return recipe_list
|
||||
|
||||
3
mealie/services/scheduler/global_scheduler.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
62
mealie/services/scheduler/scheduled_jobs.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from db.db_setup import create_session
|
||||
from services.backups.exports import auto_backup_job
|
||||
from services.scheduler.global_scheduler import scheduler
|
||||
from services.scheduler.scheduler_utils import Cron, cron_parser
|
||||
from services.settings_services import SiteSettings
|
||||
from utils.logger import logger
|
||||
from utils.post_webhooks import post_webhooks
|
||||
|
||||
|
||||
@scheduler.scheduled_job(trigger="interval", minutes=15)
|
||||
def update_webhook_schedule():
|
||||
"""
|
||||
A scheduled background job that runs every 15 minutes to
|
||||
poll the database for changes and reschedule the webhook time
|
||||
"""
|
||||
session = create_session()
|
||||
settings = SiteSettings.get_site_settings(session=session)
|
||||
time = cron_parser(settings.webhooks.webhookTime)
|
||||
job = JOB_STORE.get("webhooks")
|
||||
|
||||
scheduler.reschedule_job(
|
||||
job.scheduled_task.id,
|
||||
trigger="cron",
|
||||
hour=time.hours,
|
||||
minute=time.minutes,
|
||||
)
|
||||
|
||||
session.close()
|
||||
logger.info(scheduler.print_jobs())
|
||||
|
||||
|
||||
class ScheduledFunction:
|
||||
def __init__(
|
||||
self, scheduler: BackgroundScheduler, function, cron: Cron, name: str
|
||||
) -> None:
|
||||
self.scheduled_task = scheduler.add_job(
|
||||
function,
|
||||
trigger="cron",
|
||||
name=name,
|
||||
hour=cron.hours,
|
||||
minute=cron.minutes,
|
||||
max_instances=1,
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
logger.info("New Function Scheduled")
|
||||
logger.info(scheduler.print_jobs())
|
||||
|
||||
|
||||
logger.info("----INIT SCHEDULE OBJECT-----")
|
||||
|
||||
JOB_STORE = {
|
||||
"backup_job": ScheduledFunction(
|
||||
scheduler, auto_backup_job, Cron(hours=00, minutes=00), "backups"
|
||||
),
|
||||
"webhooks": ScheduledFunction(
|
||||
scheduler, post_webhooks, Cron(hours=00, minutes=00), "webhooks"
|
||||
),
|
||||
}
|
||||
|
||||
scheduler.start()
|
||||
10
mealie/services/scheduler/scheduler_utils.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import collections
|
||||
|
||||
Cron = collections.namedtuple("Cron", "hours minutes")
|
||||
|
||||
|
||||
def cron_parser(time_str: str) -> Cron:
|
||||
time = time_str.split(":")
|
||||
cron = Cron(hours=int(time[0]), minutes=int(time[1]))
|
||||
|
||||
return cron
|
||||
@@ -1,72 +0,0 @@
|
||||
import collections
|
||||
import json
|
||||
|
||||
import requests
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from utils.logger import logger
|
||||
|
||||
from services.backups.exports import auto_backup_job
|
||||
from services.meal_services import MealPlan
|
||||
from services.recipe_services import Recipe
|
||||
from services.settings_services import SiteSettings
|
||||
|
||||
Cron = collections.namedtuple("Cron", "hours minutes")
|
||||
|
||||
|
||||
def cron_parser(time_str: str) -> Cron:
|
||||
time = time_str.split(":")
|
||||
cron = Cron(hours=int(time[0]), minutes=int(time[1]))
|
||||
|
||||
return cron
|
||||
|
||||
|
||||
def post_webhooks():
|
||||
all_settings = SiteSettings.get_site_settings()
|
||||
|
||||
if all_settings.webhooks.enabled:
|
||||
todays_meal = Recipe.get_by_slug(MealPlan.today()).dict()
|
||||
urls = all_settings.webhooks.webhookURLs
|
||||
|
||||
for url in urls:
|
||||
requests.post(url, json.dumps(todays_meal, default=str))
|
||||
|
||||
|
||||
class Scheduler:
|
||||
def startup_scheduler(self):
|
||||
self.scheduler = BackgroundScheduler()
|
||||
logger.info("----INIT SCHEDULE OBJECT-----")
|
||||
self.scheduler.start()
|
||||
|
||||
self.scheduler.add_job(
|
||||
auto_backup_job, trigger="cron", hour="3", max_instances=1
|
||||
)
|
||||
settings = SiteSettings.get_site_settings()
|
||||
time = cron_parser(settings.webhooks.webhookTime)
|
||||
|
||||
self.webhook = self.scheduler.add_job(
|
||||
post_webhooks,
|
||||
trigger="cron",
|
||||
name="webhooks",
|
||||
hour=time.hours,
|
||||
minute=time.minutes,
|
||||
max_instances=1,
|
||||
)
|
||||
|
||||
logger.info(self.scheduler.print_jobs())
|
||||
|
||||
def reschedule_webhooks(self):
|
||||
"""
|
||||
Reads the site settings database entry to reschedule the webhooks task
|
||||
Called after each post to the webhooks endpoint.
|
||||
"""
|
||||
settings = SiteSettings.get_site_settings()
|
||||
time = cron_parser(settings.webhooks.webhookTime)
|
||||
|
||||
self.scheduler.reschedule_job(
|
||||
self.webhook.id,
|
||||
trigger="cron",
|
||||
hour=time.hours,
|
||||
minute=time.minutes,
|
||||
)
|
||||
|
||||
logger.info(self.scheduler.print_jobs())
|
||||
@@ -1,10 +1,12 @@
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
import extruct
|
||||
import requests
|
||||
import scrape_schema_recipe
|
||||
from app_config import DEBUG_DIR
|
||||
from slugify import slugify
|
||||
from utils.logger import logger
|
||||
from w3lib.html import get_base_url
|
||||
@@ -12,8 +14,13 @@ from w3lib.html import get_base_url
|
||||
from services.image_services import scrape_image
|
||||
from services.recipe_services import Recipe
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
TEMP_FILE = CWD.parent.joinpath("data", "debug", "last_recipe.json")
|
||||
TEMP_FILE = DEBUG_DIR.joinpath("last_recipe.json")
|
||||
|
||||
|
||||
def cleanhtml(raw_html):
|
||||
cleanr = re.compile("<.*?>")
|
||||
cleantext = re.sub(cleanr, "", raw_html)
|
||||
return cleantext
|
||||
|
||||
|
||||
def normalize_image_url(image) -> str:
|
||||
@@ -31,17 +38,19 @@ def normalize_instructions(instructions) -> List[dict]:
|
||||
# One long string split by (possibly multiple) new lines
|
||||
if type(instructions) == str:
|
||||
return [
|
||||
{"text": line.strip()} for line in filter(None, instructions.splitlines())
|
||||
{"text": normalize_instruction(line)}
|
||||
for line in instructions.splitlines()
|
||||
if line
|
||||
]
|
||||
|
||||
# Plain strings in a list
|
||||
elif type(instructions) == list and type(instructions[0]) == str:
|
||||
return [{"text": step.strip()} for step in instructions]
|
||||
return [{"text": normalize_instruction(step)} for step in instructions]
|
||||
|
||||
# Dictionaries (let's assume it's a HowToStep) in a list
|
||||
elif type(instructions) == list and type(instructions[0]) == dict:
|
||||
return [
|
||||
{"text": step["text"].strip()}
|
||||
{"text": normalize_instruction(step["text"])}
|
||||
for step in instructions
|
||||
if step["@type"] == "HowToStep"
|
||||
]
|
||||
@@ -50,6 +59,19 @@ def normalize_instructions(instructions) -> List[dict]:
|
||||
raise Exception(f"Unrecognised instruction format: {instructions}")
|
||||
|
||||
|
||||
def normalize_instruction(line) -> str:
|
||||
l = cleanhtml(line.strip())
|
||||
# Some sites erroneously escape their strings on multiple levels
|
||||
while not l == (l := html.unescape(l)):
|
||||
pass
|
||||
return l
|
||||
|
||||
|
||||
def normalize_ingredient(ingredients: list) -> str:
|
||||
|
||||
return [cleanhtml(html.unescape(ing)) for ing in ingredients]
|
||||
|
||||
|
||||
def normalize_yield(yld) -> str:
|
||||
if type(yld) == list:
|
||||
return yld[-1]
|
||||
@@ -66,9 +88,13 @@ def normalize_time(time_entry) -> str:
|
||||
|
||||
def normalize_data(recipe_data: dict) -> dict:
|
||||
recipe_data["totalTime"] = normalize_time(recipe_data.get("totalTime"))
|
||||
recipe_data["description"] = cleanhtml(recipe_data.get("description", ""))
|
||||
recipe_data["prepTime"] = normalize_time(recipe_data.get("prepTime"))
|
||||
recipe_data["performTime"] = normalize_time(recipe_data.get("performTime"))
|
||||
recipe_data["recipeYield"] = normalize_yield(recipe_data.get("recipeYield"))
|
||||
recipe_data["recipeIngredient"] = normalize_ingredient(
|
||||
recipe_data.get("recipeIngredient")
|
||||
)
|
||||
recipe_data["recipeInstructions"] = normalize_instructions(
|
||||
recipe_data["recipeInstructions"]
|
||||
)
|
||||
@@ -165,7 +191,7 @@ def process_recipe_url(url: str) -> dict:
|
||||
return new_recipe
|
||||
|
||||
|
||||
def create_from_url(url: str) -> dict:
|
||||
def create_from_url(url: str) -> Recipe:
|
||||
recipe_data = process_recipe_url(url)
|
||||
|
||||
with open(TEMP_FILE, "w") as f:
|
||||
@@ -173,4 +199,4 @@ def create_from_url(url: str) -> dict:
|
||||
|
||||
recipe = Recipe(**recipe_data)
|
||||
|
||||
return recipe.save_to_db()
|
||||
return recipe
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from db.database import db
|
||||
from db.db_setup import sql_exists
|
||||
from db.db_setup import create_session, sql_exists
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm.session import Session
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
@@ -29,22 +30,24 @@ class SiteSettings(BaseModel):
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_all():
|
||||
db.settings.get_all()
|
||||
def get_all(session: Session):
|
||||
db.settings.get_all(session)
|
||||
|
||||
@classmethod
|
||||
def get_site_settings(cls):
|
||||
def get_site_settings(cls, session: Session):
|
||||
try:
|
||||
document = db.settings.get("main")
|
||||
document = db.settings.get(session=session, match_value="main")
|
||||
except:
|
||||
webhooks = Webhooks()
|
||||
default_entry = SiteSettings(name="main", webhooks=webhooks)
|
||||
document = db.settings.save_new(default_entry.dict(), webhooks.dict())
|
||||
document = db.settings.save_new(
|
||||
session, default_entry.dict(), webhooks.dict()
|
||||
)
|
||||
|
||||
return cls(**document)
|
||||
|
||||
def update(self):
|
||||
db.settings.update("main", new_data=self.dict())
|
||||
def update(self, session: Session):
|
||||
db.settings.update(session, "main", new_data=self.dict())
|
||||
|
||||
|
||||
class Colors(BaseModel):
|
||||
@@ -78,16 +81,16 @@ class SiteTheme(BaseModel):
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_by_name(cls, theme_name):
|
||||
db_entry = db.themes.get(theme_name)
|
||||
def get_by_name(cls, session: Session, theme_name):
|
||||
db_entry = db.themes.get(session, theme_name)
|
||||
name = db_entry.get("name")
|
||||
colors = Colors(**db_entry.get("colors"))
|
||||
|
||||
return cls(name=name, colors=colors)
|
||||
|
||||
@staticmethod
|
||||
def get_all():
|
||||
all_themes = db.themes.get_all()
|
||||
def get_all(session: Session):
|
||||
all_themes = db.themes.get_all(session)
|
||||
for index, theme in enumerate(all_themes):
|
||||
name = theme.get("name")
|
||||
colors = Colors(**theme.get("colors"))
|
||||
@@ -96,16 +99,16 @@ class SiteTheme(BaseModel):
|
||||
|
||||
return all_themes
|
||||
|
||||
def save_to_db(self):
|
||||
db.themes.save_new(self.dict())
|
||||
def save_to_db(self, session: Session):
|
||||
db.themes.save_new(session, self.dict())
|
||||
|
||||
def update_document(self):
|
||||
db.themes.update(self.dict())
|
||||
def update_document(self, session: Session):
|
||||
db.themes.update(session, self.name, self.dict())
|
||||
|
||||
@staticmethod
|
||||
def delete_theme(theme_name: str) -> str:
|
||||
def delete_theme(session: Session, theme_name: str) -> str:
|
||||
""" Removes the theme by name """
|
||||
db.themes.delete(theme_name)
|
||||
db.themes.delete(session, theme_name)
|
||||
|
||||
|
||||
def default_theme_init():
|
||||
@@ -118,24 +121,27 @@ def default_theme_init():
|
||||
"warning": "#FF4081",
|
||||
"error": "#EF5350",
|
||||
}
|
||||
|
||||
session = create_session()
|
||||
try:
|
||||
SiteTheme.get_by_name("default")
|
||||
SiteTheme.get_by_name(session, "default")
|
||||
logger.info("Default theme exists... skipping generation")
|
||||
except:
|
||||
logger.info("Generating Default Theme")
|
||||
colors = Colors(**default_colors)
|
||||
default_theme = SiteTheme(name="default", colors=colors)
|
||||
default_theme.save_to_db()
|
||||
default_theme.save_to_db(session)
|
||||
|
||||
|
||||
def default_settings_init():
|
||||
session = create_session()
|
||||
try:
|
||||
document = db.settings.get("main")
|
||||
document = db.settings.get(session, "main")
|
||||
except:
|
||||
webhooks = Webhooks()
|
||||
default_entry = SiteSettings(name="main", webhooks=webhooks)
|
||||
document = db.settings.save_new(default_entry.dict(), webhooks.dict())
|
||||
document = db.settings.save_new(session, default_entry.dict(), webhooks.dict())
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
if not sql_exists:
|
||||
|
||||
@@ -1,2 +1,39 @@
|
||||
import db.db_setup
|
||||
from pathlib import Path
|
||||
|
||||
from app import app
|
||||
from app_config import SQLITE_DIR
|
||||
from db.db_setup import generate_session, sql_global_init
|
||||
from fastapi.testclient import TestClient
|
||||
from pytest import fixture
|
||||
|
||||
from tests.test_config import TEST_DATA
|
||||
|
||||
SQLITE_FILE = SQLITE_DIR.joinpath("test.db")
|
||||
SQLITE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
TestSessionLocal = sql_global_init(SQLITE_FILE, check_thread=False)
|
||||
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
db = TestSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
|
||||
@fixture(scope="session")
|
||||
def api_client():
|
||||
|
||||
app.dependency_overrides[generate_session] = override_get_db
|
||||
yield TestClient(app)
|
||||
|
||||
SQLITE_FILE.unlink()
|
||||
|
||||
|
||||
@fixture(scope="session")
|
||||
def test_image():
|
||||
return TEST_DATA.joinpath("test_image.jpg")
|
||||
|
||||
BIN
mealie/tests/data/migrations/chowdown/test_chowdown-gh-pages.zip
Normal file
BIN
mealie/tests/data/migrations/nextcloud/Air Fryer Shrimp/full.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"@context": "http:\/\/schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": "Air Fryer Shrimp",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Anna"
|
||||
},
|
||||
"description": "These Air Fryer Shrimp are plump, juicy and perfectly seasoned! This healthy dish is ready in just 8 minutes and requires pantry staples to make it.",
|
||||
"datePublished": "2020-07-13T16:48:25+00:00",
|
||||
"image": "https:\/\/www.crunchycreamysweet.com\/wp-content\/uploads\/2020\/07\/air-fryer-shrimp-A-480x270.jpg",
|
||||
"recipeYield": 4,
|
||||
"prepTime": "PT0H15M",
|
||||
"cookTime": "PT0H8M",
|
||||
"totalTime": "PT0H23M",
|
||||
"recipeIngredient": [
|
||||
"1 lb shrimp",
|
||||
"2 teaspoons olive oil",
|
||||
"\u00bd teaspoon garlic powder",
|
||||
"\u00bc teaspoon paprika",
|
||||
"\u00bd teaspoon Italian seasoning",
|
||||
"\u00bd teaspoon salt",
|
||||
"\u00bc teaspoon black pepper"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
"Cleaning the shrimp by removing shells and veins. Run under tap water, then pat dry with paper towel.",
|
||||
"Mix oil with seasoning in a small bowl.",
|
||||
"Brush shrimp with seasoning mixture on both sides.",
|
||||
"Arrange shrimp in air fryer basket or rack, in a single layer.",
|
||||
"Cook at 400 degrees F for 8 minutes (no need to turn them).",
|
||||
"Serve."
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "5",
|
||||
"ratingCount": "4"
|
||||
},
|
||||
"recipeCategory": "Main Course",
|
||||
"recipeCuisine": [
|
||||
"American"
|
||||
],
|
||||
"keywords": "air fryer shrimp",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "134 kcal",
|
||||
"carbohydrateContent": "1 g",
|
||||
"proteinContent": "23 g",
|
||||
"fatContent": "4 g",
|
||||
"saturatedFatContent": "1 g",
|
||||
"cholesterolContent": "286 mg",
|
||||
"sodiumContent": "1172 mg",
|
||||
"fiberContent": "1 g",
|
||||
"sugarContent": "1 g",
|
||||
"servingSize": "1 serving"
|
||||
},
|
||||
"@id": "https:\/\/www.crunchycreamysweet.com\/air-fryer-shrimp\/#recipe",
|
||||
"isPartOf": {
|
||||
"@id": "https:\/\/www.crunchycreamysweet.com\/air-fryer-shrimp\/#article"
|
||||
},
|
||||
"mainEntityOfPage": "https:\/\/www.crunchycreamysweet.com\/air-fryer-shrimp\/#webpage",
|
||||
"tool": [],
|
||||
"url": "https:\/\/www.crunchycreamysweet.com\/air-fryer-shrimp\/"
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"@context": "http:\/\/schema.org",
|
||||
"@type": "Recipe",
|
||||
"mainEntityOfPage": "https:\/\/www.allrecipes.com\/recipe\/8975\/chicken-parmigiana\/",
|
||||
"name": "Chicken Parmigiana",
|
||||
"image": "https:\/\/imagesvc.meredithcorp.io\/v3\/mm\/image?url=https%3A%2F%2Fimages.media-allrecipes.com%2Fuserphotos%2F10037.jpg",
|
||||
"datePublished": "1999-04-27T12:40:19.000Z",
|
||||
"description": "This is a very nice dinner for two. Serve it with your favorite pasta and tossed greens.",
|
||||
"prepTime": "PT0H30M",
|
||||
"cookTime": "PT1H0M",
|
||||
"totalTime": "PT1H30M",
|
||||
"recipeYield": 2,
|
||||
"recipeIngredient": [
|
||||
"1 egg, beaten",
|
||||
"2 ounces dry bread crumbs",
|
||||
"2 skinless, boneless chicken breast halves",
|
||||
"\u00be (16 ounce) jar spaghetti sauce",
|
||||
"2 ounces shredded mozzarella cheese",
|
||||
"\u00bc cup grated Parmesan cheese"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
"Preheat oven to 350 degrees F (175 degrees C). Lightly grease a medium baking sheet.\n",
|
||||
"Pour egg into a small shallow bowl. Place bread crumbs in a separate shallow bowl. Dip chicken into egg, then into the bread crumbs. Place coated chicken on the prepared baking sheet and bake in the preheated oven for 40 minutes, or until no longer pink and juices run clear.\n",
|
||||
"Pour 1\/2 of the spaghetti sauce into a 7x11 inch baking dish. Place chicken over sauce, and cover with remaining sauce. Sprinkle mozzarella and Parmesan cheeses on top and return to the preheated oven for 20 minutes.\n"
|
||||
],
|
||||
"recipeCategory": "World Cuisine Recipes",
|
||||
"recipeCuisine": [],
|
||||
"author": [
|
||||
{
|
||||
"@type": "Person",
|
||||
"name": "Candy"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": 4.580034423407917,
|
||||
"ratingCount": 1743,
|
||||
"itemReviewed": "Chicken Parmigiana",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
},
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "528.3 calories",
|
||||
"carbohydrateContent": "44.9 g",
|
||||
"cholesterolContent": "184.1 mg",
|
||||
"fatContent": "18.3 g",
|
||||
"fiberContent": "5.6 g",
|
||||
"proteinContent": "43.5 g",
|
||||
"saturatedFatContent": "7.6 g",
|
||||
"servingSize": null,
|
||||
"sodiumContent": "1309.5 mg",
|
||||
"sugarContent": "17.2 g",
|
||||
"transFatContent": null,
|
||||
"unsaturatedFatContent": null
|
||||
},
|
||||
"review": [
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2004-02-10T10:18:54.927Z",
|
||||
"reviewBody": "This is a DELICIOUS basic recipe. I have been doing a similar one for years. I also, prefer adding a few more spices TO THE BREAD CRUMBS,like basil, oregano, garlic powder, salt, fresh cracked pepper and onion powder, and a few TBSP of the parmensan cheese;not only ON IT later. For some reason these spices (added separately) are good, but we don't like with an pre-mix of \"Italian\"spice. It seems to taste a little \"soapy\". Not sure which spice does that to it.? Some suggested to \"double dip\" in bread crumbs;if you do, you should really LIKE a heavy battering. It was too thick for our tastes(esp. since you bake in the sauce; to me,the bottom gets a little mushy, and it just adds extra fat and calories). I also use a cookie cooling \"RACK\" SET ON TOP of a baking sheet, to bake the chicken on instead of just on the cookie sheet pan. It comes out much crisper; letting air get to the BOTTOM of the chicken,also. Also,I wait to spoon the SECOND 1\/2 of the sauce UNTIL SERVING, the chicken will stay crisper,(even with the cheese on top). Obviously, we like the chicken on the crisp side (but we don't want to deep fry).\r\nFor company, put the chicken (with just the cheese baked on top) ON TOP of a small mound of spaghetti and sauce,or any pasta; It makes for a delicious looking presentation. A side salad with some sort of CREAMY dressing seems to compliment the red sauce, and completes the meal wonderfully. We get cravings for this one about 2c a month!",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "somethingdifferentagain?!",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/342976\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2004-01-23T16:37:02.013Z",
|
||||
"reviewBody": "This was an extremely easy, very tasty recipe. As many others suggested, I only put sauce on the bottom of the chicken and then spooned a little over the top when serving. I think the recipe could be improved, though, by (1) pounding the chicken to a uniform thickness and (2) by spicing up the bread crumbs. I used Italian bread crumbs but next time will sprinkle pepper on the chicken before dredging through the crumbs, and I also plan to add more Italian seasoning and maybe a little parmesan to the crumbs. Both these steps, in my opinion, would take this from a really good recipe to an excellent dish!",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "JBAGNALL",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/642772\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2005-11-19T20:22:40.53Z",
|
||||
"reviewBody": "I BRINED my chicken in 4 cups water , 1\/2 cup kosher salt (1\/4 table salt) \u00bd cup sugar for 30 minutes. No need to brine if you are using quick frozen chicken that has been enhanced. Kosher chicken is prebrined. Brining=juicy chicken. Took brined chicken, cut off thin edges, pounded out & shook chicken w\/flour (preflouring allows bread crumbs to stick) in a Ziploc-letting floured chicken sit for 5 min. I heated 6 TBS vegetable oil till it shimmered & then added 2 TBS butter to my pan, reserving half of this mixture for my second batch. Bread crumb mixture: I use \u00bd cup seasoned bread crumbs(same as 2 ounces), \u00bd cup grated parmesan( double what recipe calls for), 1tsp. Mrs. Dash Garlic and Herb, \u00bd tsp. garlic powder, \u00bd tsp, onion powder, \u00bd tsp. Italian seasoning & a pinch of pepper. Took pre-floured chicken, coated it with egg mixture, then dipped in bread crumbs & FRIED the chicken to a medium golden brown. Shook some parmesan on them right away when done frying to absorb any oil. Side-by side I plated plain spaghetti noodles & cutlets, w\/2 TBSP sauce on cutlet & desired amount of sauce on pasta, covered in cheese & baked each individual plate till cheese melted, serving them straight out of the oven. \r\nThe reviews on this were probably the best I have ever gotten, I used to work in an Italian Restaurant owned by NY Italians & have picked up some techniques. My Fettuccine Alfredo used to be my husband favorite dish, after last night he told me he has a new favorite. \r\n",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "KC MARTEL",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/526291\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2003-10-22T15:32:26.607Z",
|
||||
"reviewBody": "After several Chicken Parm recipes THIS is THE ONE:-) I've finally found one that we all love! It's simple and it's darned good:-) I will definately make this recipe again and again; thanks so much:-)",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "STARCHILD1166",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/736533\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2003-11-14T16:55:26.39Z",
|
||||
"reviewBody": "This chicken was so easy to make and turned out excellent! Used Best Marinara Sauce Yet (found here as well)instead of regular spaghetti sauce. This added even more flavor.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Alison",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/516223\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2003-01-23T04:38:19.873Z",
|
||||
"reviewBody": "I REALLY liked this recipe. I made my own spaghetti sauce and used parmesan reggiano. I also skipped dipping the breasts in egg as I thought it was unnecessary and it was. Cooking temp. and time are accurate. Even my fussy fiance liked this. I'll definitely make this again.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "CSANDST1",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/115553\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2003-08-05T20:26:00.81Z",
|
||||
"reviewBody": "Wow! This was really tasty and simple. Something quick to make when you can't spend too much time figuring out what's for dinner. Also great on a toasted roll\/hero as a sandwich. I varied the recipe a little by adding some parmesan cheese (big cheese lover that I am!), garlic powder, onion powder and some salt into the bread crumbs and then mixing it up before breading the chicken with it. Also added a little salt to the beaten egg to make sure the chicken wouldn't end up bland, but that's just my preference. In response to the one reviewer who wanted thicker breading, what I did was double dip the chicken - coat first with the bread crumbs, then dip into the beaten egg and re-coat with breadcrumbs before actually baking (this would require some more breadcrumbs and probably another egg). Excellent recipe! =]",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "LIZCHAO74",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/511187\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2003-07-23T07:53:37.18Z",
|
||||
"reviewBody": "Wonderful chicken recipe! I have made this several times. One night we were craving it and I didn't have any bottled spaghetti sauce. I poured a 14 ounce can of tomato sauce in a microwave bowl added 2t Italian Seasoning and 1t of garlic powder cooked on high for 6 minutes and ended up with a rich thick sauce for the chicken.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "MAGGIE MCGUIRE",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/392086\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2008-06-10T21:54:38.893Z",
|
||||
"reviewBody": "This is gonna be one of those it\u2019s a good recipe when you completely change it reviews. I did originally follow the recipe and the chicken tasted like it had been in breaded in cardboard. It just was not appetizing. However there is a great breaded chicken recipe on this site, garlic chicken. Made this simple and easy and oh so TASTY. I got great reviews. Here is what I did. Took \u00bc cup olive oil with 3 cloves garlic crushed and heated in microwave for 30 sec. Then coated the chicken in the oil and dipped in a mixture of \u00bd Italian seasoned bread crumbs and \u00bd parmesan cheese (double coat if u like thick breading). Cooked in oven at 325 for 20min (on a foil covered cookie sheet to make clean up easy). Set them in a casserole dish on top of about \u00bd a jar of spaghetti sauce for 3 chicken breast. Covered the breast with slices of mozzarella cheese and baked for another 20-25 minutes. Top with parmesan cheese. This turned out really really yummy and smells sooo good while it\u2019s cooking. ",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "ANGEL.9",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/218599\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2006-02-02T19:05:24.607Z",
|
||||
"reviewBody": "Check out \"Tomato Chicken Parmesan\" on this site for a truly fabulous chicken parm recipe. Every time I make that one people say its the best chicken parm they every had. No matter what kind you make though always pound your chicken breasts it will help immensely keeping the chicken tender and moist.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 3
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "MomSavedbyGrace",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/1366670\/"
|
||||
}
|
||||
}
|
||||
],
|
||||
"video": {
|
||||
"@context": "http:\/\/schema.org",
|
||||
"@type": "VideoObject",
|
||||
"name": "Chicken Parmigiana",
|
||||
"description": "Make this quick and easy version of chicken Parmigiana.",
|
||||
"uploadDate": "2012-05-23T22:01:40.476Z",
|
||||
"duration": "PT2M18.43S",
|
||||
"thumbnailUrl": "https:\/\/imagesvc.meredithcorp.io\/v3\/mm\/image?url=https%3A%2F%2Fcf-images.us-east-1.prod.boltdns.net%2Fv1%2Fstatic%2F1033249144001%2F15c9e37d-979a-4c2c-a35d-fc3f436b0047%2F6b7f7749-9989-4707-971e-8578e60c0670%2F160x90%2Fmatch%2Fimage.jpg",
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Allrecipes",
|
||||
"url": "https:\/\/www.allrecipes.com",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https:\/\/www.allrecipes.com\/img\/logo.png",
|
||||
"width": 209,
|
||||
"height": 60
|
||||
},
|
||||
"sameAs": [
|
||||
"https:\/\/www.facebook.com\/allrecipes",
|
||||
"https:\/\/twitter.com\/Allrecipes",
|
||||
"https:\/\/www.pinterest.com\/allrecipes\/",
|
||||
"https:\/\/www.instagram.com\/allrecipes\/"
|
||||
]
|
||||
},
|
||||
"embedUrl": "https:\/\/players.brightcove.net\/1033249144001\/default_default\/index.html?videoId=1653498713001"
|
||||
},
|
||||
"keywords": "",
|
||||
"tool": [],
|
||||
"url": "https:\/\/www.allrecipes.com\/recipe\/8975\/chicken-parmigiana\/"
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"@context": "http:\/\/schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": "Skillet Shepherd's Pie",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Joanna Cismaru"
|
||||
},
|
||||
"description": "This Skillet Shepherd's Pie recipe, also known as cottage pie, is loaded with flavorful beef and veggies, topped with fluffy and creamy mashed potatoes, then baked to perfection!",
|
||||
"datePublished": "2019-03-16T20:15:47+00:00",
|
||||
"image": "https:\/\/www.jocooks.com\/wp-content\/uploads\/2016\/12\/skillet-shepherds-pie-1-2-480x270.jpg",
|
||||
"video": {
|
||||
"name": "Skillet Shepherd's Pie",
|
||||
"description": "This skillet shepherd\u2019s pie is loaded with flavorful beef and veggies then topped with fluffy and creamy mashed potatoes, then baked to perfection!",
|
||||
"thumbnailUrl": "https:\/\/content.jwplatform.com\/thumbs\/HGr48vds-720.jpg",
|
||||
"contentUrl": "https:\/\/content.jwplatform.com\/videos\/HGr48vds.mp4",
|
||||
"uploadDate": "2018-03-08T16:13:05.000Z",
|
||||
"@type": "VideoObject"
|
||||
},
|
||||
"recipeYield": 1,
|
||||
"prepTime": "PT0H15M",
|
||||
"cookTime": "PT1H10M",
|
||||
"totalTime": "PT1H25M",
|
||||
"recipeIngredient": [
|
||||
"1 tbsp olive oil",
|
||||
"1 1\/4 lb ground beef (lean)",
|
||||
"1\/2 tsp salt (or to taste)",
|
||||
"1\/2 tsp pepper (or to taste)",
|
||||
"1 large onion (chopped)",
|
||||
"1 clove garlic (minced)",
|
||||
"1\/2 tsp red pepper flakes",
|
||||
"2 tbsp Worcestershire sauce",
|
||||
"1.9 oz onion soup mix (I used Knorr, 55g pkg)",
|
||||
"1 cup beef broth (low sodium)",
|
||||
"2 cups frozen veggies (I used mix of peas, carrots, green beans and corn)",
|
||||
"6 large potatoes (peeled and cut into cubes)",
|
||||
"4 tbsp butter (softened)",
|
||||
"2\/3 cup milk",
|
||||
"1\/4 cup Parmesan cheese",
|
||||
"1\/2 tsp salt (or to taste)",
|
||||
"1\/2 tsp white pepper (or to taste)",
|
||||
"1 tbsp parsley (fresh, for garnish)"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
"Boil the potatoes: Start by first cooking the potatoes in boiling water for about 15 minutes or until fork tender. While the potatoes are cooking, you can prepare the meat mixture.",
|
||||
"Prepare the meat mixture: Heat the oil in a large skillet over medium heat. Add the ground beef to the skillet, season it with the salt and pepper and cook it for abut 5 minutes or until it's no longer pink, breaking it up as you go along.",
|
||||
"Add the onion and garlic and cook for 3 more minutes until the onion softens and becomes translucent. Add the pepper flakes, Worcestershire sauce, onion soup mix, beef broth and stir. Stir in the frozen veggies and cook for a couple more minutes. Set aside.",
|
||||
"Preheat the oven 350 F degrees.",
|
||||
"Prepare the mashed potatoes: Drain the potatoes then add them to a large bowl. Add in the butter and using a potato masher, mash until smooth. Add the milk, Parmesan cheese, salt pepper and mash a bit a more until smooth.",
|
||||
"Finish assembling the shepherd's pie: Spread the potatoes over the meat and smooth with a spoon. Take a fork and rough up the top a bit and garnish with a bit of parsley.",
|
||||
"Bake: Place the skillet on a baking sheet, then place it in the oven and bake for 40 minutes until golden brown on top.",
|
||||
"Garnish with more parsley and pepper and serve warm."
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.48",
|
||||
"ratingCount": "505"
|
||||
},
|
||||
"recipeCategory": "Main Course",
|
||||
"recipeCuisine": [
|
||||
"American"
|
||||
],
|
||||
"keywords": "cottage pie,shepherd's pie,skillet shepherd's pie",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "252 kcal",
|
||||
"carbohydrateContent": "14 g",
|
||||
"proteinContent": "19 g",
|
||||
"fatContent": "12 g",
|
||||
"saturatedFatContent": "6 g",
|
||||
"cholesterolContent": "63 mg",
|
||||
"sodiumContent": "1165 mg",
|
||||
"fiberContent": "2 g",
|
||||
"sugarContent": "2 g",
|
||||
"servingSize": "1 serving"
|
||||
},
|
||||
"@id": "https:\/\/www.jocooks.com\/recipes\/skillet-shepherds-pie\/#recipe",
|
||||
"isPartOf": {
|
||||
"@id": "https:\/\/www.jocooks.com\/recipes\/skillet-shepherds-pie\/#article"
|
||||
},
|
||||
"mainEntityOfPage": "https:\/\/www.jocooks.com\/recipes\/skillet-shepherds-pie\/#webpage",
|
||||
"url": "https:\/\/www.jocooks.com\/recipes\/skillet-shepherds-pie\/",
|
||||
"id": "4485",
|
||||
"dateCreated": "0",
|
||||
"dateModified": "1607461134",
|
||||
"printImage": "false",
|
||||
"imageUrl": "\/nextcloud\/index.php\/apps\/cookbook\/recipes\/4485\/image?size=full",
|
||||
"tool": []
|
||||
}
|
||||
BIN
mealie/tests/data/migrations/nextcloud/nextcloud.zip
Normal file
500
mealie/tests/data/scraper/html-raw/healthy_pasta_bake_60759.html
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Recipe",
|
||||
"image": "https://img.chefkoch-cdn.de/rezepte/2235331358009600/bilder/864648/crop-960x540/pizza-knoblauch-champignon-paprika-vegan.jpg",
|
||||
"recipeCategory": "Gem\u00fcse",
|
||||
"recipeIngredient": [
|
||||
"300 g Weizenmehl (Type 550)",
|
||||
"1 Pck. Trockenhefe",
|
||||
"1 TL Salz",
|
||||
"170 ml Wasser",
|
||||
"6 EL \u00d6l (Knoblauch\u00f6l oder Oliven\u00f6l)",
|
||||
"1 EL Tomatenmark",
|
||||
"n. B. Knoblauch , gew\u00fcrfelt",
|
||||
"2 Spitzpaprika oder Gem\u00fcsepaprika, rot",
|
||||
"250 g Champignons",
|
||||
"1 Zwiebel(n)",
|
||||
" Salz und Pfeffer",
|
||||
" Kr\u00e4uter , italienische, frisch oder getrocknet",
|
||||
" Harissa"
|
||||
],
|
||||
"name": "Pizza Knoblauch Champignon Paprika - vegan",
|
||||
"description": "Pizza Knoblauch Champignon Paprika - vegan - f\u00fcr Nicht-Veganer nat\u00fcrlich mit K\u00e4se zu belegen. \u00dcber 51 Bewertungen und f\u00fcr raffiniert befunden. Mit \u25ba Portionsrechner \u25ba Kochbuch \u25ba Video-Tipps!",
|
||||
"recipeInstructions": "Die Zutaten f\u00fcr den Teig verkneten und ca. 40 Minuten an einem warmen Ort gehen lassen. In der Zwischenzeit eine beliebige Anzahl Knoblauchzehen fein w\u00fcrfeln (ich bedecke die Pizza nahezu fl\u00e4chendeckend), die Zwiebel ebenfalls w\u00fcrfeln, Paprika und Champignons klein schneiden. Das \u00d6l mit Tomatenmark, Salz und Pfeffer vermischen. \r\n\r\nDen fertigen Teig ausrollen und auf ein Blech legen (ich benutze eine Pflaumenkuchen-Backform). Die \u00d6lmischung mit einem Backpinsel gleichm\u00e4\u00dfig auf dem Teig verteilen, danach mit dem Knoblauch, den Champignons, der Paprika und den Zwiebeln belegen. \r\n\r\nNun die Pizza mit Salz, Pfeffer, Kr\u00e4utern und Harissa kr\u00e4ftig w\u00fcrzen und bei 250\u00b0C ca. 10 - 15 Minuten backen. Der Teig ist als Grundteig zu betrachten und l\u00e4sst sich nat\u00fcrlich mit allem M\u00f6glichen an Gem\u00fcse belegen.",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "healing21"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Chefkoch.de"
|
||||
},
|
||||
"datePublished": "2013-01-14",
|
||||
"prepTime": "P0DT0H20M",
|
||||
"cookTime": "P0DT0H15M",
|
||||
"totalTime": "P0DT1H15M",
|
||||
"recipeYield": "2 Portion(en)",
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingCount": 51,
|
||||
"ratingValue": 4.57,
|
||||
"reviewCount": 34,
|
||||
"worstRating": 0,
|
||||
"bestRating": 5
|
||||
},
|
||||
"keywords": [
|
||||
"Gem\u00fcse",
|
||||
"Hauptspeise",
|
||||
"Backen",
|
||||
"Vegetarisch",
|
||||
"einfach",
|
||||
"Vegan",
|
||||
"Pizza",
|
||||
"Pilze"
|
||||
],
|
||||
"reviews": [
|
||||
{
|
||||
"@type": "Review",
|
||||
"reviewBody": " Sehr gutes Basis Rezept!\n\nHab noch Salami, Kochschinken und K\u00e4se dazu gemacht, sonst schmeckt es ja nach nichts. \n\nErgebnis: 1. Klasse! Sehr fein! ",
|
||||
"datePublished": "2020-04-21",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "eierkopp1824"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"reviewBody": "Hallo,\r\nhabe den Teig gut zwei Stunden gehen lassen und dann wie im Rezept angegeben weiter verarbeitet. Da ich noch einige Schinkenw\u00fcrfel und etwas Fetak\u00e4se im K\u00fchlschrank hatte, wurden diese ebenfalls auf dem Belag verteilt. Ich habe die Pizza auf der untersten Schiene im Backofen gebacken. Der Boden ist nach dem Backen sch\u00f6n knusprig. Es hat mir und meinem Mitesser sehr gut geschmeckt.\r\nLG von Sternek\u00f6chin2011",
|
||||
"datePublished": "2020-03-10",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Sternek\u00f6chin2011"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"reviewBody": "Echt f\u00fcr mich die leckerste Pizza auf der Welt! Auch bei meiner Familie kommt sie super an und \u00fcberlebt nicht lange. :)\nDen Belag kann man ja variieren wie man will. ",
|
||||
"datePublished": "2020-02-20",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Leo090800"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"reviewBody": "Beste Pizza, die ich je gegessen habe! Sooo lecker! Habe f\u00fcr den Teig Dinkelvollkornmehl genommen und den Belag noch mit ein paar Chiliflocken verfeinert. ",
|
||||
"datePublished": "2018-04-15",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Sunny_Eyes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"reviewBody": "Der Teig ist super, ebenso wie die Sauce! Habe anstelle von normalem Salz Basilikumsalz in den Teig gegeben, das gibt dem ganzen einen besonderen Geschmack.Statt Paprika und Knobi habe ich K\u00e4se und Rucola hinzuef\u00fcgt, den Salat erst nach dem Backen. Die wird sicherlich nochmal gemacht! Da ich nur eine Pizza gemacht habe, habe ich den restlichen Teig eingefroren. Foto ist unterwegs!",
|
||||
"datePublished": "2018-02-14",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Chiqryn"
|
||||
}
|
||||
}
|
||||
],
|
||||
"url": "https://www.chefkoch.de/rezepte/2235331358009600/Pizza-Knoblauch-Champignon-Paprika-vegan.html"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"@context": "http://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "The Best Homemade Salsa Recipe",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Sommer Collier"
|
||||
},
|
||||
"description": "How To Make Delicious Salsa: Secrets of making the Best Homemade Salsa Recipe! This restaurant style salsa recipe is loaded with flavor, has an amazing texture, and a secret ingredient.",
|
||||
"datePublished": "2020-02-01T00:00:30+00:00",
|
||||
"image": [
|
||||
"https://www.aspicyperspective.com/wp-content/uploads/2019/02/the-best-homemade-salsa-recipe-100.jpg",
|
||||
"https://www.aspicyperspective.com/wp-content/uploads/2019/02/the-best-homemade-salsa-recipe-100-500x500.jpg",
|
||||
"https://www.aspicyperspective.com/wp-content/uploads/2019/02/the-best-homemade-salsa-recipe-100-500x375.jpg",
|
||||
"https://www.aspicyperspective.com/wp-content/uploads/2019/02/the-best-homemade-salsa-recipe-100-480x270.jpg"
|
||||
],
|
||||
"video": {
|
||||
"name": "The Best Homemade Salsa Recipe",
|
||||
"description": "We\u2019re sharing our secrets for making The Best Homemade Salsa Recipe we\u2019ve ever tried. Healthy, fresh, and easy to adjust!",
|
||||
"thumbnailUrl": "https://content.jwplatform.com/thumbs/rPi8NdK6-720.jpg",
|
||||
"contentUrl": "https://content.jwplatform.com/videos/rPi8NdK6.mp4",
|
||||
"uploadDate": "2017-03-22T16:24:09.000Z",
|
||||
"@type": "VideoObject"
|
||||
},
|
||||
"recipeYield": [
|
||||
"20",
|
||||
"20 (5 cups)"
|
||||
],
|
||||
"prepTime": "PT5M",
|
||||
"totalTime": "PT5M",
|
||||
"recipeIngredient": [
|
||||
"4 ripe tomatoes, (cored and quartered)",
|
||||
"1 red onion, (peeled and quartered)",
|
||||
"3 garlic cloves, (peeled)",
|
||||
"3 jalapenos, (stemmed and seeded (you can\u00a0substitute 1-2 habanero or serrano peppers.))",
|
||||
"1/3 cup fresh cilantro",
|
||||
"3 tablespoons fresh lime juice",
|
||||
"2-3 teaspoons ground cumin",
|
||||
"2-3 teaspoons sugar ((optional))",
|
||||
"1 1/2 teaspoons salt",
|
||||
"15 ounces crushed San Marzano tomatoes ((1 can))",
|
||||
"4.5 ounces diced green chiles, (mild, medium, or hot (1 can))"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Place the fresh tomatoes, onion, garlic, peppers, cilantro, lime juice, 2 teaspoons cumin, 2 teaspoons sugar (if using), and salt in a food processor. Pulse until the contents are fine and well blended.",
|
||||
"name": "Place the fresh tomatoes, onion, garlic, peppers, cilantro, lime juice, 2 teaspoons cumin, 2 teaspoons sugar (if using), and salt in a food processor. Pulse until the contents are fine and well blended.",
|
||||
"url": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/#wprm-recipe-61842-step-0-0"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Pour in the crushed tomatoes and green chiles. Puree until mostly smooth. Taste, then add more cumin and sugar if desired. Refrigerate until ready to serve.",
|
||||
"name": "Pour in the crushed tomatoes and green chiles. Puree until mostly smooth. Taste, then add more cumin and sugar if desired. Refrigerate until ready to serve.",
|
||||
"url": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/#wprm-recipe-61842-step-0-1"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.98",
|
||||
"ratingCount": "201"
|
||||
},
|
||||
"recipeCategory": [
|
||||
"Appetizer",
|
||||
"Snack"
|
||||
],
|
||||
"recipeCuisine": [
|
||||
"American",
|
||||
"Mexican"
|
||||
],
|
||||
"keywords": "Homemade Salsa, Salsa, The Best Salsa Recipe",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"servingSize": "0.25 cup",
|
||||
"calories": "19 kcal",
|
||||
"carbohydrateContent": "4 g",
|
||||
"sodiumContent": "230 mg",
|
||||
"sugarContent": "2 g"
|
||||
},
|
||||
"@id": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/#recipe",
|
||||
"isPartOf": {
|
||||
"@id": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/#article"
|
||||
},
|
||||
"mainEntityOfPage": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/#webpage",
|
||||
"url": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"image": "https://www.cookingforkeeps.com/wp-content/uploads/2013/02/done-1.jpg",
|
||||
"aggregateRating": {
|
||||
"properties": {
|
||||
"ratingValue": "4.0",
|
||||
"ratingCount": "2"
|
||||
},
|
||||
"@type": "AggregateRating"
|
||||
},
|
||||
"name": "Blue Cheese Stuffed Turkey Meatballs with Raspberry Balsamic Glaze",
|
||||
"author": "Nicole-Cooking for Keeps",
|
||||
"recipeYield": "Makes 18-22 meatballs depending on size",
|
||||
"recipeInstructions": [
|
||||
"For the meatballs: Roll the blue cheese in small balls about the diameter of a dime. Freeze for 30 minutes. Preheat oven to 375 degrees. Mix the remaining ingredients together, until just combined. Roll sausage mixture into small balls. Place the blue cheese in the middle, enclosing with meat. Bake on a silt pad until golden brown and cooked through, about 25 min, turning halfway through to ensure even browning.",
|
||||
"For the Dipping Sauce:",
|
||||
"Combine all ingredients together in small sauce pan over medium high heat. Bring to a boil and then reduce heat and simmer about five minutes. Coat meatballs in sauce. Serve."
|
||||
],
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Recipe",
|
||||
"url": "https://www.cookingforkeeps.com/blue-cheese-stuffed-turkey-meatballs-with-raspberry-balsamic-glaze-2/",
|
||||
"recipeIngredient": [
|
||||
"Sausage Bites",
|
||||
"3 oz creamy gorgonzola cheese",
|
||||
"1 lb turkey Italian sausage (schmicas) with fennel seed",
|
||||
"\u00bd cup Italian style bread crumbs",
|
||||
"\u00bd onion grated",
|
||||
"1 egg white",
|
||||
"Salt to taste",
|
||||
"Dipping Sauce:",
|
||||
"\u00bd cup raspberry preserves",
|
||||
"\u215b cup balsamic vinegar",
|
||||
"3 teaspoons Dijon mustard",
|
||||
"Pinch of red pepper",
|
||||
"Pinch of Salt"
|
||||
]
|
||||
}
|
||||
132
mealie/tests/data/scraper/recipes-raw/bon_appetit.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Recipe",
|
||||
"articleBody": "Atlanta pastry chef Claudia Martinez\u2019s family has been making what Martinez describes as meaty Venezuelan tamales around the holidays for generations. In the Martinez household, every family member has a task: Claudia\u2019s dad or grandmother always prepares the guiso, the tender shredded chicken and beef stew that comprises the bulk of the filling. One person slicks scoops of vibrant orange achiote-stained masa dough onto banana leaves, then passes them around the table to get filled. Claudia\u2019s grandma adds a spoonful of guiso; Claudia adds olives and capers; her sister adds a few raisins. Finally, each hallaca gets wrapped up in the fragrant leaves and tied with twine like a tiny present, ready to boil for a late Christmas Eve dinner. The Martinez family usually makes 100 at a time; this scaled-down version of their recipe makes just under 20, enough for a big dinner plus leftovers you can freeze for another day. If you find yourself with leftover masa and stew, do as the Martinezes do: Make arepas with guiso and fried eggs for breakfast on Christmas Day. (If you\u2019re in Atlanta in the days leading up to Christmas Eve, pick up hallacas at Caf\u00e9 Claudia, the pop-up Martinez runs out of the Hotel Clermont.)\nBanana leaves give a floral and grassy flavor to the hallacas, you can buy them either fresh or frozen at Latin and Asian markets. You can use parchment paper instead, but the outcome won\u2019t be as complex.",
|
||||
"alternativeHeadline": "The Venezuelan holiday dish that Atlanta pastry chef Claudia Martinez\u2019s family has been making for generations.",
|
||||
"dateModified": "2021-01-02 12:09:30.443000",
|
||||
"datePublished": "2020-12-01 07:00:00",
|
||||
"keywords": [
|
||||
"recipes",
|
||||
"holiday 2020",
|
||||
"new years eve",
|
||||
"olive oil",
|
||||
"beef",
|
||||
"chicken recipes",
|
||||
"kosher salt",
|
||||
"tomato",
|
||||
"garlic",
|
||||
"tomato paste",
|
||||
"onion",
|
||||
"bell pepper",
|
||||
"green onion scallion",
|
||||
"cilantro",
|
||||
"brown sugar",
|
||||
"cornmeal",
|
||||
"capers",
|
||||
"olive",
|
||||
"raisin",
|
||||
"web"
|
||||
],
|
||||
"thumbnailUrl": "https://assets.bonappetit.com/photos/5fb4407993a08c9bf97163f7/1:1/w_1125,h_1125,c_limit/1220-Hallacas.jpg",
|
||||
"publisher": {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "Bon App\u00e9tit",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://www.bonappetit.com/verso/static/bon-appetit/assets/logo-seo.328de564b950e3d5d1fbe3e42f065290ca1d3844.png",
|
||||
"width": "479px",
|
||||
"height": "100px"
|
||||
},
|
||||
"url": "https://www.bonappetit.com"
|
||||
},
|
||||
"isPartOf": {
|
||||
"@type": [
|
||||
"CreativeWork",
|
||||
"Product"
|
||||
],
|
||||
"name": "Bon App\u00e9tit"
|
||||
},
|
||||
"isAccessibleForFree": true,
|
||||
"author": [
|
||||
{
|
||||
"@type": "Person",
|
||||
"name": "Claudia Martinez",
|
||||
"sameAs": "https://bon-appetit.com/contributor/claudia-martinez/"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": 5,
|
||||
"ratingCount": 22
|
||||
},
|
||||
"description": "The Venezuelan holiday dish that Atlanta pastry chef Claudia Martinez\u2019s family has been making for generations.",
|
||||
"image": "hallacas.jpg",
|
||||
"name": "Hallacas",
|
||||
"recipeIngredient": [
|
||||
"1\u00bd cups extra-virgin olive oil",
|
||||
"3 Tbsp. plus 1\u00bd tsp. achiote (annatto) seeds",
|
||||
"2\u00bd lb. boneless beef chuck roast",
|
||||
"2\u00bd lb. skinless, boneless chicken breasts",
|
||||
"1 Tbsp. Diamond Crystal or 1\u00be tsp. Morton kosher salt, plus more",
|
||||
"3 medium tomatoes, coarsely chopped",
|
||||
"3 garlic cloves",
|
||||
"1 6-oz. can tomato paste",
|
||||
"1 medium onion, chopped",
|
||||
"1 large red bell pepper, seeds and ribs removed, coarsely chopped",
|
||||
"1 large green bell pepper, seeds and ribs removed, coarsely chopped",
|
||||
"1 bunch scallions, coarsely chopped",
|
||||
"1 bunch cilantro, coarsely chopped",
|
||||
"\u00bc cup (packed) light brown sugar",
|
||||
"1 1-kg package P.A.N. precooked cornmeal",
|
||||
"2 Tbsp. Diamond Crystal or 1 Tbsp. plus \u00bd tsp. kosher salt",
|
||||
"3 1-lb. packages fresh or frozen, thawed banana or plantain leaves",
|
||||
"\u00bc cup extra-virgin olive oil",
|
||||
"\u00bd cup drained capers",
|
||||
"\u00bd cup pitted green olives",
|
||||
"\u00bd cup raisins"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Cook oil and achiote seeds in a small saucepan over medium-low heat until oil turns deep orange, about 10 minutes. Strain into a heatproof jar and let cool. Measure out \u00bd cup achiote oil for making filling; set remaining 1 cup oil aside for making dough."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Bring beef, chicken, 1 Tbsp. Diamond Crystal or 1\u00be tsp. Morton kosher salt, and 12 cups water to a boil in a large pot over medium-high heat. Reduce heat to medium-low and let simmer until cooked through, about 30 minutes. Transfer beef and chicken to a cutting board and let sit until cool enough to handle. Pour 8 cups cooking liquid into a heatproof pitcher or large measuring glass; set aside. Discard any extra liquid."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Cut beef and chicken into \u2153\" cubes; place back into pot (cooking the meat before you chop it means that you can cut the pieces finer and more evenly). Blend tomatoes, garlic, and tomato paste in a blender until smooth; scrape pur\u00e9e into pot with meat. Blend onion, red and green bell peppers, scallions, cilantro, and \u00bd cup reserved cooking liquid in blender until smooth and add to pot. Add brown sugar and \u00bd cup reserved achiote oil. Pour in remaining 7\u00bd cups reserved cooking liquid. Bring to a boil, then reduce heat to medium-low and simmer until meat is tender and liquid is slightly reduced, about 40 minutes. Drain meat in a colander, season lightly with salt, and let cool."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Meanwhile, mix cornmeal, salt, reserved 1 cup achiote oil, and 8 cups water in a large bowl with your hands until dough is smooth, spreadable, and no large lumps remain, 5\u20137 minutes. Press a sheet of plastic wrap or parchment paper directly onto surface of dough; let rest at least 30 minutes or up to 1 hour."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Wash and pat banana leaves dry. Carefully remove any center stems with kitchen shears, avoiding breaking through the leaf, then cut into 14x10\" rectangles. Mix oil and 1 cup water in a medium bowl (it needs to be big enough to dip your hands into). This will help to keep the dough from sticking to your hands. Working one at a time, place a banana leaf on a surface so the veins in the leaves run horizontally. Dipping your hands in oil mixture as you work, place \u00be cup dough in center of leaf and spread out with your fingers into a \u215b\"-thick rectangle, leaving a 1\" border near the vertical edges and a space on both horizontal edges. Place \u00be cup guiso into center of dough. Top with 5 capers, 2 olives, and 8 raisins."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Take top and bottom edges of leaf and bring up toward each other so edges of dough meet and enclose filling. Pull both sides of banana leaf together snugly toward the upper edge of hallaca to seal and fold over toward you to make a tube. Fold remaining 2 side ends toward the center to make a small package."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Place package, fold side down, on another banana leaf and wrap up again. Wrap once more in a third leaf to hold everything together, then tie closed with kitchen twine. (Make sure package is compact, the leaves are not ripped, and hallaca is not leaking.) Repeat with remaining dough, filling, and banana leaves."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Place as many hallacas as will fit into a clean large pot, pour in water to cover, and bring to a boil. Reduce heat and simmer, turning hallacas halfway through, until plumped and firm, about 35 minutes. Repeat with remaining hallacas.\nDo ahead: Hallacas can be made 1 week ahead. Let cool, then cover and chill, or freeze up to 3 months. To reheat, cook in a pot of simmering water (make sure hallacas are submerged), partially covered, until warmed through, 10\u201315 minutes if chilled, 25\u201330 minutes if frozen."
|
||||
}
|
||||
],
|
||||
"recipeYield": "Makes about 18",
|
||||
"url": "https://www.bonappetit.com/recipe/hallacas",
|
||||
"slug": "hallacas",
|
||||
"orgURL": "https://www.bonappetit.com/recipe/hallacas",
|
||||
"categories": [],
|
||||
"tags": [],
|
||||
"dateAdded": null,
|
||||
"notes": [],
|
||||
"extras": []
|
||||
}
|
||||
33
mealie/tests/data/scraper/recipes-raw/chunky-apple-cake.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"url": "https://www.deliaonline.com/recipes/seasons/what-should-you-be-cooking-in-november/chunky-apple-cake",
|
||||
"author": "Delia Smith",
|
||||
"image": "https://www.deliaonline.com/sites/default/files/quick_media/cakes-chunky-apple-cake.jpg",
|
||||
"name": "Chunky Apple Cake",
|
||||
"description": "Apples are superb in cakes, so in the autumn when there are lots of windfalls around, why not make a few of these and freeze them.",
|
||||
"recipeCuisine": "General",
|
||||
"recipeCategory": [
|
||||
"Apples",
|
||||
"Afternoon Tea",
|
||||
"Cake Recipes",
|
||||
"Autumn",
|
||||
"Life in the Freezer"
|
||||
],
|
||||
"keywords": "Apples, Afternoon Tea, Cake Recipes, Autumn, Life in the Freezer, Delia, Delia Smith",
|
||||
"recipeInstructions": "Begin by sifting the flour, baking powder and spices into a roomy mixing bowl, lifting the sieve quite high to give the flour a good airing as it goes down.\n\nNext chop the apples into small dice (with or without peel, just as you like). Then place them in a bowl and toss them with one tablespoon of the sieved flour mixture. Then add the eggs, butter and sugar to the rest of the flour, and using an electric hand whisk, combine them for about 1 minute until you have a smooth creamy consistency. After that fold in the grated orange zest, mixed peel and diced apple. If the mixture seems a little dry, add a tablespoon of milk. Now spoon the cake mix into the prepared tin and level it off with the back of a spoon.\n\nThen bake near the centre of the oven for about one hour or until the cake feels springy in the centre when lightly pressed with a fingertip and just shows signs of shrinking away from the edge of the tin. Cool in the tin for 10 minutes before turning out onto a wire rack. This looks nice dusted with sifted icing sugar just before serving. Store in an airtight tin.\n\nYou can watch more of Delia's cake recipes being made in our Cookery School Videos on the right.",
|
||||
"recipeIngredient": [
|
||||
"225g self-raising flour",
|
||||
"1 rounded teaspoon baking powder",
|
||||
"1 level teaspoon mixed spice",
|
||||
"\u00bd level teaspoon ground cinnamon",
|
||||
"3 Bramley apples (about 550g)",
|
||||
"2 large eggs, beaten",
|
||||
"75g spreadable butter",
|
||||
"175g light brown soft sugar",
|
||||
"grated zest of 1 large orange",
|
||||
"1 tablespoon chopped mixed peel",
|
||||
"1 tablespoon milk (if needed)",
|
||||
"little icing sugar"
|
||||
],
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Recipe"
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"@context": "http://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Dairy-Free Impossible Pumpkin Pie",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Kare for Kitchen Treaty"
|
||||
},
|
||||
"description": "This crustless pumpkin pie might just be the\u00a0easiest\u00a0you'll ever make. Simply blend the ingredients together, pour into your pie pan, and bake!",
|
||||
"datePublished": "2017-11-10T16:12:06+00:00",
|
||||
"image": [
|
||||
"https://www.kitchentreaty.com/wp-content/uploads/2017/11/dairy-free-impossible-pumpkin-pie-8.jpg"
|
||||
],
|
||||
"recipeYield": [
|
||||
"8"
|
||||
],
|
||||
"prepTime": "PT10M",
|
||||
"cookTime": "PT45M",
|
||||
"totalTime": "PT55M",
|
||||
"recipeIngredient": [
|
||||
"1 (15-ounce) can coconut milk (I recommend full-fat for a richer pie, but lite also works)",
|
||||
"1 cup pumpkin puree",
|
||||
"4 large eggs",
|
||||
"1/2 cup granulated sugar",
|
||||
"1 tablespoon pure vanilla extract",
|
||||
"1/2 cup all-purpose flour (or your favorite cup-for-cup gluten-free flour blend*)",
|
||||
"1 teaspoon baking powder",
|
||||
"1 tablespoon pumpkin pie spice",
|
||||
"1/2 teaspoon fine-grain sea salt or table salt",
|
||||
"Coconut whipped cream (for serving**)"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Preheat oven to 375 degrees Fahrenheit and position rack in the middle of the oven. Spray a 9- or 10-inch pie pan with baking spray or oil the pan with coconut oil or vegan butter.",
|
||||
"name": "Preheat oven to 375 degrees Fahrenheit and position rack in the middle of the oven. Spray a 9- or 10-inch pie pan with baking spray or oil the pan with coconut oil or vegan butter.",
|
||||
"url": "https://www.kitchentreaty.com/dairy-free-impossible-pumpkin-pie/#wprm-recipe-32856-step-0-0"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Add the coconut milk, pumpkin, eggs, sugar, and vanilla to the pitcher of a blender. Blend until combined, about 20 seconds. Add the flour, baking powder, pumpkin pie spice, and salt. Blend again until well-combined, another 20\u00a0seconds.",
|
||||
"name": "Add the coconut milk, pumpkin, eggs, sugar, and vanilla to the pitcher of a blender. Blend until combined, about 20 seconds. Add the flour, baking powder, pumpkin pie spice, and salt. Blend again until well-combined, another 20\u00a0seconds.",
|
||||
"url": "https://www.kitchentreaty.com/dairy-free-impossible-pumpkin-pie/#wprm-recipe-32856-step-0-1"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Pour filling into the pie plate. The mixture will be fairly runny. Carefully transfer to the preheated oven.",
|
||||
"name": "Pour filling into the pie plate. The mixture will be fairly runny. Carefully transfer to the preheated oven.",
|
||||
"url": "https://www.kitchentreaty.com/dairy-free-impossible-pumpkin-pie/#wprm-recipe-32856-step-0-2"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Bake\u00a0until the middle just barely jiggles, 40-50 minutes. I like to check the middle by giving the pie pan a little nudge, and if it seems like it's no longer liquid, I'll pull the pie out and insert a butter knife about halfway between the center and the edge. If the knife comes out relatively clean - no runny pie filling - it's\u00a0done!",
|
||||
"name": "Bake\u00a0until the middle just barely jiggles, 40-50 minutes. I like to check the middle by giving the pie pan a little nudge, and if it seems like it's no longer liquid, I'll pull the pie out and insert a butter knife about halfway between the center and the edge. If the knife comes out relatively clean - no runny pie filling - it's\u00a0done!",
|
||||
"url": "https://www.kitchentreaty.com/dairy-free-impossible-pumpkin-pie/#wprm-recipe-32856-step-0-3"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Place on a cooling rack and let cool, about 1 hour. Transfer to refrigerator to completely\u00a0cool, at least one more hour (or up to 3 days in advance).",
|
||||
"name": "Place on a cooling rack and let cool, about 1 hour. Transfer to refrigerator to completely\u00a0cool, at least one more hour (or up to 3 days in advance).",
|
||||
"url": "https://www.kitchentreaty.com/dairy-free-impossible-pumpkin-pie/#wprm-recipe-32856-step-0-4"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "If desired, top pie with dollops of coconut whipped cream. Or simply cut slices, transfer to a plate, and top\u00a0individual servings with the whipped cream.",
|
||||
"name": "If desired, top pie with dollops of coconut whipped cream. Or simply cut slices, transfer to a plate, and top\u00a0individual servings with the whipped cream.",
|
||||
"url": "https://www.kitchentreaty.com/dairy-free-impossible-pumpkin-pie/#wprm-recipe-32856-step-0-5"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Keeps in the refrigerator for 3-4 days. I suggest covering the completely cooled pie with plastic wrap if not serving right away.",
|
||||
"name": "Keeps in the refrigerator for 3-4 days. I suggest covering the completely cooled pie with plastic wrap if not serving right away.",
|
||||
"url": "https://www.kitchentreaty.com/dairy-free-impossible-pumpkin-pie/#wprm-recipe-32856-step-0-6"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "5",
|
||||
"ratingCount": "4"
|
||||
},
|
||||
"recipeCategory": [
|
||||
"Dessert"
|
||||
],
|
||||
"recipeCuisine": [
|
||||
"American"
|
||||
],
|
||||
"keywords": "pie",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "231 kcal",
|
||||
"sugarContent": "14 g",
|
||||
"sodiumContent": "239 mg",
|
||||
"fatContent": "14 g",
|
||||
"saturatedFatContent": "11 g",
|
||||
"carbohydrateContent": "23 g",
|
||||
"fiberContent": "1 g",
|
||||
"proteinContent": "5 g",
|
||||
"cholesterolContent": "82 mg",
|
||||
"servingSize": "1 serving"
|
||||
},
|
||||
"url": "https://www.kitchentreaty.com/dairy-free-impossible-pumpkin-pie/"
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"@context": "http://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "How to Make Instant Pot Spaghetti",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Karlynn Johnston"
|
||||
},
|
||||
"description": "This Instant Pot Spaghetti recipe is literally the best one out there and it's thanks to one ( or two!) secret ingredients!",
|
||||
"datePublished": "2020-09-15T13:00:52+00:00",
|
||||
"image": [
|
||||
"https://www.thekitchenmagpie.com/wp-content/uploads/images/2018/02/instantpotspaghetti.jpg",
|
||||
"https://www.thekitchenmagpie.com/wp-content/uploads/images/2018/02/instantpotspaghetti-500x500.jpg",
|
||||
"https://www.thekitchenmagpie.com/wp-content/uploads/images/2018/02/instantpotspaghetti-500x375.jpg",
|
||||
"https://www.thekitchenmagpie.com/wp-content/uploads/images/2018/02/instantpotspaghetti-480x270.jpg"
|
||||
],
|
||||
"recipeYield": [
|
||||
"4"
|
||||
],
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT7M",
|
||||
"totalTime": "PT22M",
|
||||
"recipeIngredient": [
|
||||
"1 tablespoon olive oil",
|
||||
"1 cup white onion (diced)",
|
||||
"1 tablespoon fresh minced garlic",
|
||||
"1 pound lean ground beef",
|
||||
"2 teaspoons Italian seasoning mix",
|
||||
"one 16 ounce package uncooked white flour spaghetti noodles (cooking time for al dente needs to be 9-10 minutes! )",
|
||||
"one 750 millilitre jar of 4 cheese spaghetti sauce",
|
||||
"one 15 ounce can diced tomatoes",
|
||||
"3 cups weak beef broth (divided)",
|
||||
"1/2 teaspoon salt (( to taste))",
|
||||
"1/2 teaspoon black pepper",
|
||||
"1/2 teaspoon white sugar (to cut the acidity of the tomatoes )"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Press the \"saute\" button on your Instant Pot. Add in the olive oil and heat. Once it's heated, add in the white onion. Saute until the onion is soft and translucent. Add in the garlic and fry for 2-3 minutes.",
|
||||
"name": "Press the \"saute\" button on your Instant Pot. Add in the olive oil and heat. Once it's heated, add in the white onion. Saute until the onion is soft and translucent. Add in the garlic and fry for 2-3 minutes.",
|
||||
"url": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#wprm-recipe-44488-step-0-0",
|
||||
"image": "https://www.thekitchenmagpie.com/wp-content/uploads/images/2018/02/fryinggroundbeefandonionsinaninsantpot.jpg"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Add in the ground beef and fry , stirring constantly, until it's no longer pink. Press the Cancel button to turn off the Instant Pot heating element. Drain the fat (keeping some for flavour if wanted).",
|
||||
"name": "Add in the ground beef and fry , stirring constantly, until it's no longer pink. Press the Cancel button to turn off the Instant Pot heating element. Drain the fat (keeping some for flavour if wanted).",
|
||||
"url": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#wprm-recipe-44488-step-0-1"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Add in 1 cup of the beef broth, mixing it in with the ground beef on the bottom.",
|
||||
"name": "Add in 1 cup of the beef broth, mixing it in with the ground beef on the bottom.",
|
||||
"url": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#wprm-recipe-44488-step-0-2"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Break the spaghetti noodles in half. Place in random, different criss-cross patterns on top of the beef/ beef broth mixture. You are trying to created space between the noodles to try and prevent sticking.",
|
||||
"name": "Break the spaghetti noodles in half. Place in random, different criss-cross patterns on top of the beef/ beef broth mixture. You are trying to created space between the noodles to try and prevent sticking.",
|
||||
"url": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#wprm-recipe-44488-step-0-3",
|
||||
"image": "https://www.thekitchenmagpie.com/wp-content/uploads/images/2018/02/howtobreakspaghettinoodlesfortheinstantpot.jpg"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "In a large bowl or large glass spouted measuring glass, combine the remaining beef broth, tomatoes, 4 cheese sauce, Italian seasoning, salt, pepper and dash of white sugar. ",
|
||||
"name": "In a large bowl or large glass spouted measuring glass, combine the remaining beef broth, tomatoes, 4 cheese sauce, Italian seasoning, salt, pepper and dash of white sugar. ",
|
||||
"url": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#wprm-recipe-44488-step-0-4"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Pout the liquid mixture on top of the pasta, around the sides, making sure you coat everything. Take a wooden spoon and gently push down on the spaghetti noodles, making sure that they are all underneath the liquid.",
|
||||
"name": "Pout the liquid mixture on top of the pasta, around the sides, making sure you coat everything. Take a wooden spoon and gently push down on the spaghetti noodles, making sure that they are all underneath the liquid.",
|
||||
"url": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#wprm-recipe-44488-step-0-5"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Press the Manual Button ( you are going to use high pressure) and set for 7 minutes. Listen to make sure that it seals.",
|
||||
"name": "Press the Manual Button ( you are going to use high pressure) and set for 7 minutes. Listen to make sure that it seals.",
|
||||
"url": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#wprm-recipe-44488-step-0-6"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "When it's done, release the valve manually ( see the link in my suggestions in the post above). Stir the spaghetti, breaking up any noodles that stuck together. Let it sit for a few minutes, soaking up the extra liquid.",
|
||||
"name": "When it's done, release the valve manually ( see the link in my suggestions in the post above). Stir the spaghetti, breaking up any noodles that stuck together. Let it sit for a few minutes, soaking up the extra liquid.",
|
||||
"url": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#wprm-recipe-44488-step-0-7",
|
||||
"image": "https://www.thekitchenmagpie.com/wp-content/uploads/images/2018/02/instantpotspaghetti3.jpg"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "5",
|
||||
"ratingCount": "15"
|
||||
},
|
||||
"recipeCategory": [
|
||||
"supper"
|
||||
],
|
||||
"recipeCuisine": [
|
||||
"American"
|
||||
],
|
||||
"keywords": "Instant Pot Spaghetti",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "222 kcal",
|
||||
"carbohydrateContent": "5 g",
|
||||
"proteinContent": "27 g",
|
||||
"fatContent": "9 g",
|
||||
"saturatedFatContent": "3 g",
|
||||
"cholesterolContent": "70 mg",
|
||||
"sodiumContent": "699 mg",
|
||||
"fiberContent": "1 g",
|
||||
"sugarContent": "2 g",
|
||||
"servingSize": "1 serving"
|
||||
},
|
||||
"@id": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#recipe",
|
||||
"isPartOf": {
|
||||
"@id": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#article"
|
||||
},
|
||||
"mainEntityOfPage": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/#webpage",
|
||||
"url": "https://www.thekitchenmagpie.com/how-to-make-instant-pot-spaghetti/"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"@context": "http://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Instant Pot Chicken and Potatoes",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Tiffany"
|
||||
},
|
||||
"description": "This is hands down the BEST Instant Pot Chicken and Potatoes recipe you'll ever try. Juicy ranch-seasoned chicken breast and parmesan potatoes cooked in 30 minutes in your pressure cooker - it doesn't get easier than this! ",
|
||||
"datePublished": "2018-10-26T07:00:51+00:00",
|
||||
"image": [
|
||||
"https://www.lecremedelacrumb.com/wp-content/uploads/2018/10/instant-pot-chicken-potatoes-2.jpg",
|
||||
"https://www.lecremedelacrumb.com/wp-content/uploads/2018/10/instant-pot-chicken-potatoes-2-500x500.jpg",
|
||||
"https://www.lecremedelacrumb.com/wp-content/uploads/2018/10/instant-pot-chicken-potatoes-2-500x375.jpg",
|
||||
"https://www.lecremedelacrumb.com/wp-content/uploads/2018/10/instant-pot-chicken-potatoes-2-480x270.jpg"
|
||||
],
|
||||
"recipeYield": [
|
||||
"4",
|
||||
"4 people"
|
||||
],
|
||||
"prepTime": "PT10M",
|
||||
"cookTime": "PT15M",
|
||||
"totalTime": "PT40M",
|
||||
"recipeIngredient": [
|
||||
"4 boneless skinless chicken breasts",
|
||||
"2 pounds baby red or gold potatoes",
|
||||
"3 tablespoons olive oil",
|
||||
"1 1/2 teaspoons salt (or to taste)",
|
||||
"1/2 teaspoon pepper (or to taste)",
|
||||
"1 teaspoon garlic powder",
|
||||
"1 teaspoon dried thyme",
|
||||
"1/2 teaspoon dried basil",
|
||||
"1/2 teaspoon dried oregano",
|
||||
"2 tablespoons + 2 teaspoons dry Ranch seasoning (divided)",
|
||||
"1 cup chicken broth",
|
||||
"3 tablespoons grated parmesan cheese"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "In a large bowl toss chicken and potatoes in the olive oil, then season with salt and pepper. Stir together garlic powder, thyme, basil, oregano, and 2 tablespoons of the Ranch seasoning. Sprinkle over the chicken and potatoes, tossing to distribute the ingredients as evenly as possible. ",
|
||||
"name": "In a large bowl toss chicken and potatoes in the olive oil, then season with salt and pepper. Stir together garlic powder, thyme, basil, oregano, and 2 tablespoons of the Ranch seasoning. Sprinkle over the chicken and potatoes, tossing to distribute the ingredients as evenly as possible. ",
|
||||
"url": "https://www.lecremedelacrumb.com/instant-pot-chicken-and-potatoes/#wprm-recipe-22284-step-0-0"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Add chicken broth to the instant pot/pressure cooker, then place chicken in the broth, and top with the potatoes. Place the lid on in the locked position and turn the vent to the sealed position. Set pressure cooker to \"pressure cook\" for 15 minutes.",
|
||||
"name": "Add chicken broth to the instant pot/pressure cooker, then place chicken in the broth, and top with the potatoes. Place the lid on in the locked position and turn the vent to the sealed position. Set pressure cooker to \"pressure cook\" for 15 minutes.",
|
||||
"url": "https://www.lecremedelacrumb.com/instant-pot-chicken-and-potatoes/#wprm-recipe-22284-step-0-1"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Once the cook time is finished, do a \"quick release\" by turning the vent to the venting position. Once float valve has dropped, remove the lid. Drain the pressure cooker or use a slotted spoon to transfer chicken and potatoes to a large platter. ",
|
||||
"name": "Once the cook time is finished, do a \"quick release\" by turning the vent to the venting position. Once float valve has dropped, remove the lid. Drain the pressure cooker or use a slotted spoon to transfer chicken and potatoes to a large platter. ",
|
||||
"url": "https://www.lecremedelacrumb.com/instant-pot-chicken-and-potatoes/#wprm-recipe-22284-step-0-2"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Sprinkle with Ranch seasoning and parmesan cheese and garnish with chopped thyme or parsley if desired before serving. ",
|
||||
"name": "Sprinkle with Ranch seasoning and parmesan cheese and garnish with chopped thyme or parsley if desired before serving. ",
|
||||
"url": "https://www.lecremedelacrumb.com/instant-pot-chicken-and-potatoes/#wprm-recipe-22284-step-0-3"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.76",
|
||||
"ratingCount": "225"
|
||||
},
|
||||
"recipeCategory": [
|
||||
"Main Course"
|
||||
],
|
||||
"recipeCuisine": [
|
||||
"American"
|
||||
],
|
||||
"keywords": "Chicken, healthy, instant pot, mashed potatoes, pressure cooker, ranch",
|
||||
"@id": "https://www.lecremedelacrumb.com/instant-pot-chicken-and-potatoes/#recipe",
|
||||
"isPartOf": {
|
||||
"@id": "https://www.lecremedelacrumb.com/instant-pot-chicken-and-potatoes/#article"
|
||||
},
|
||||
"mainEntityOfPage": "https://www.lecremedelacrumb.com/instant-pot-chicken-and-potatoes/#webpage",
|
||||
"url": "https://www.lecremedelacrumb.com/instant-pot-chicken-and-potatoes/"
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"@context": "http://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "How to make Instant Pot Kerala Vegetable Stew",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Dhwani"
|
||||
},
|
||||
"description": "Instant Pot Kerala Vegetable Stew - A complete Comfort and Satisfying food that can be made in a fraction of the time. Veg Stew is Vegetarian / Vegan Instant Pot Recipe with lots of vegetables in coconut milk based hearty sauce that will change your life.",
|
||||
"datePublished": "2019-01-16T19:25:09+00:00",
|
||||
"image": [
|
||||
"https://cdn.cookingcarnival.com/wp-content/uploads/2019/01/Instant-Pot-Kerala-Vegetable-Stew-2.jpg",
|
||||
"https://cdn.cookingcarnival.com/wp-content/uploads/2019/01/Instant-Pot-Kerala-Vegetable-Stew-2-500x500.jpg",
|
||||
"https://cdn.cookingcarnival.com/wp-content/uploads/2019/01/Instant-Pot-Kerala-Vegetable-Stew-2-500x375.jpg",
|
||||
"https://cdn.cookingcarnival.com/wp-content/uploads/2019/01/Instant-Pot-Kerala-Vegetable-Stew-2-480x270.jpg"
|
||||
],
|
||||
"video": {
|
||||
"name": "Instant Pot Kerala Vegetable Stew | Vegan stew recipe | vegetable Stew recipe in instant Pot",
|
||||
"description": "Instant Pot Kerala Vegetable Stew - A complete Comfort and Satisfying food that can be made in a fraction of the time. Veg Stew is Vegetarian / Vegan Instant Pot Recipe with lots of vegetables in coconut milk based hearty sauce that will change your life.\n\nDetailed Recipe of Instant pot Kerela vegetable stew https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/\nOfficial Facebook Page: https://www.facebook.com/cookingcarnival\n\nPinterest: https://www.pinterest.com/cookingcarnival/\n\nTwitter: https://twitter.com/carnivalcooking\n\nGoogle Plus: https://plus.google.com/+Cookingcarnival\n\nInstagram: https://www.instagram.com/cookingcarnival",
|
||||
"uploadDate": "2019-01-17T19:46:14+00:00",
|
||||
"duration": "PT2M17S",
|
||||
"thumbnailUrl": "https://i.ytimg.com/vi/pej98AtiBWE/hqdefault.jpg",
|
||||
"contentUrl": "https://youtu.be/pej98AtiBWE",
|
||||
"embedUrl": "https://www.youtube.com/embed/pej98AtiBWE?feature=oembed",
|
||||
"@type": "VideoObject"
|
||||
},
|
||||
"recipeYield": [
|
||||
"4",
|
||||
"4 people"
|
||||
],
|
||||
"prepTime": "PT10M",
|
||||
"cookTime": "PT10M",
|
||||
"totalTime": "PT20M",
|
||||
"recipeIngredient": [
|
||||
"2 cups - Cauliflower florets",
|
||||
"1 cup - Chopped carrots",
|
||||
"1 1/2 cup - Bell Peppers (chopped)",
|
||||
"2 cups - Potatoes (Chopped)",
|
||||
"3/4 cup - Chopped Onions",
|
||||
"1 cup - Green beans (Chopped)",
|
||||
"1 tsp - Ginger paste",
|
||||
"1/2 tsp - Chili ((adust according to your liking) )",
|
||||
"1 tsp - Garlic paste",
|
||||
"2 - Cardamom Pods (See Notes)",
|
||||
"1 inch - Cinnamon stick",
|
||||
"3 - Cloves",
|
||||
"20 Pieces - Whole Cashew Nuts",
|
||||
"1 cup - Coconut milk (See Notes)",
|
||||
"Salt to taste",
|
||||
"1/2 tsp - White Pepper Powder (see notes)",
|
||||
"2 tbsp - Oil (See Notes)",
|
||||
"2 cups - Water"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Turn on saute button of your IP.",
|
||||
"name": "Turn on saute button of your IP.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-0"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Heat oil in a pot, add cardamom pods, cinnamon stick, and cloves.",
|
||||
"name": "Heat oil in a pot, add cardamom pods, cinnamon stick, and cloves.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-1"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Now add ginger, garlic, chili and onions. Saute for few seconds.",
|
||||
"name": "Now add ginger, garlic, chili and onions. Saute for few seconds.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-2"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Add all the vegetables, salt, white pepper powder and water. Mix well.",
|
||||
"name": "Add all the vegetables, salt, white pepper powder and water. Mix well.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-3"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Cover your Instant pot with locking lid.",
|
||||
"name": "Cover your Instant pot with locking lid.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-4"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Turn off IP.",
|
||||
"name": "Turn off IP.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-5"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Press the manual or pressure cook button. Cook on high pressure for 3 minutes with pressure valve in the sealing position.",
|
||||
"name": "Press the manual or pressure cook button. Cook on high pressure for 3 minutes with pressure valve in the sealing position.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-6"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Meanwhile, take whole cashew and coconut milk in a blender jar and blend them well in to a smooth paste. Keep it aside.",
|
||||
"name": "Meanwhile, take whole cashew and coconut milk in a blender jar and blend them well in to a smooth paste. Keep it aside.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-7"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Once IP beeps and when you see LO:00, turn off your IP and quick release the pressure.",
|
||||
"name": "Once IP beeps and when you see LO:00, turn off your IP and quick release the pressure.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-8"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "10. Open the Instant Pot, add prepared cashew-coconut paste. Stir well.",
|
||||
"name": "10. Open the Instant Pot, add prepared cashew-coconut paste. Stir well.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-9"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "11. Turn on saute button and cook it for 1 to 2 more minutes, until everything well combined.",
|
||||
"name": "11. Turn on saute button and cook it for 1 to 2 more minutes, until everything well combined.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-10"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "12. Switch off the IP.",
|
||||
"name": "12. Switch off the IP.",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-11"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "13. Instant Pot Kerala Vegetable Stew is ready. Enjoy!!",
|
||||
"name": "13. Instant Pot Kerala Vegetable Stew is ready. Enjoy!!",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#wprm-recipe-8126-step-0-12"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.88",
|
||||
"ratingCount": "8"
|
||||
},
|
||||
"recipeCategory": [
|
||||
"Main Course",
|
||||
"Soups and Stew"
|
||||
],
|
||||
"recipeCuisine": [
|
||||
"American",
|
||||
"Indian"
|
||||
],
|
||||
"keywords": "Easy vegan instant pot recipe, Instant pot, Vegetable Stew",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"servingSize": "1 person",
|
||||
"calories": "201 kcal",
|
||||
"carbohydrateContent": "24 g",
|
||||
"proteinContent": "4 g",
|
||||
"fatContent": "10 g",
|
||||
"saturatedFatContent": "1 g",
|
||||
"sodiumContent": "56 mg",
|
||||
"fiberContent": "5 g",
|
||||
"sugarContent": "9 g"
|
||||
},
|
||||
"@id": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#recipe",
|
||||
"isPartOf": {
|
||||
"@id": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#article"
|
||||
},
|
||||
"mainEntityOfPage": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/#webpage",
|
||||
"url": "https://www.cookingcarnival.com/instant-pot-kerala-vegetable-stew/"
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"@context": "http://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Jalape\u00f1o Popper Dip",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Michelle"
|
||||
},
|
||||
"description": "Jalapeno Popper Dip is creamy, cheesy and has just the perfect amount of kick. Great appetizer for your next party or watching the big game!",
|
||||
"datePublished": "2016-02-22T00:01:37+00:00",
|
||||
"image": [
|
||||
"https://www.browneyedbaker.com/wp-content/uploads/2011/08/jalapeno-popper-dip-6-600.jpg"
|
||||
],
|
||||
"recipeYield": [
|
||||
"10",
|
||||
"10 to 12 servings"
|
||||
],
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT30M",
|
||||
"totalTime": "PT45M",
|
||||
"recipeIngredient": [
|
||||
"16 ounces cream cheese (at room temperature)",
|
||||
"1 cup mayonnaise",
|
||||
"8 pieces of bacon (cooked and chopped)",
|
||||
"6 jalape\u00f1os (seeded and minced (if you can't get fresh, substitute a 4-ounce can diced jalape\u00f1o peppers, drained))",
|
||||
"2 cloves garlic (minced)",
|
||||
"\u00bd teaspoon cumin",
|
||||
"6 ounces cheddar cheese (shredded (about 1\u00bd cups))",
|
||||
"1 cup panko breadcrumbs",
|
||||
"1 cup grated Parmesan cheese",
|
||||
"4 tablespoons unsalted butter, melted"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Preheat oven to 375 degrees F.",
|
||||
"name": "Preheat oven to 375 degrees F.",
|
||||
"url": "https://www.browneyedbaker.com/jalapeno-popper-dip/#wprm-recipe-44993-step-0-0"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Combine the cream cheese, mayonnaise, bacon, jalapenos, garlic, cumin and cheddar cheese in a mixing bowl. Transfer the mixture into 2-quart baking dish.",
|
||||
"name": "Combine the cream cheese, mayonnaise, bacon, jalapenos, garlic, cumin and cheddar cheese in a mixing bowl. Transfer the mixture into 2-quart baking dish.",
|
||||
"url": "https://www.browneyedbaker.com/jalapeno-popper-dip/#wprm-recipe-44993-step-0-1"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Combine the panko breadcrumbs, Parmesan cheese and melted butter in a small bowl, tossing with a fork until the mixture is evenly moistened. Sprinkle evenly over the cream cheese mixture.",
|
||||
"name": "Combine the panko breadcrumbs, Parmesan cheese and melted butter in a small bowl, tossing with a fork until the mixture is evenly moistened. Sprinkle evenly over the cream cheese mixture.",
|
||||
"url": "https://www.browneyedbaker.com/jalapeno-popper-dip/#wprm-recipe-44993-step-0-2"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Bake in the preheated oven for 25 to 30 minutes, until the top is golden brown and the dip is bubbling. Let rest for 5 minutes before serving. Serve with your favorite tortilla chips, crackers, vegetables, etc.",
|
||||
"name": "Bake in the preheated oven for 25 to 30 minutes, until the top is golden brown and the dip is bubbling. Let rest for 5 minutes before serving. Serve with your favorite tortilla chips, crackers, vegetables, etc.",
|
||||
"url": "https://www.browneyedbaker.com/jalapeno-popper-dip/#wprm-recipe-44993-step-0-3"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.34",
|
||||
"ratingCount": "15"
|
||||
},
|
||||
"recipeCategory": [
|
||||
"Appetizer"
|
||||
],
|
||||
"recipeCuisine": [
|
||||
"American"
|
||||
],
|
||||
"keywords": "cheese dip, game day food, party food",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "560 kcal",
|
||||
"carbohydrateContent": "7 g",
|
||||
"proteinContent": "14 g",
|
||||
"fatContent": "52 g",
|
||||
"saturatedFatContent": "21 g",
|
||||
"cholesterolContent": "109 mg",
|
||||
"sodiumContent": "707 mg",
|
||||
"sugarContent": "2 g",
|
||||
"servingSize": "1 serving"
|
||||
},
|
||||
"@id": "https://www.browneyedbaker.com/jalapeno-popper-dip/#recipe",
|
||||
"isPartOf": {
|
||||
"@id": "https://www.browneyedbaker.com/jalapeno-popper-dip/#article"
|
||||
},
|
||||
"mainEntityOfPage": "https://www.browneyedbaker.com/jalapeno-popper-dip/#webpage",
|
||||
"url": "https://www.browneyedbaker.com/jalapeno-popper-dip/"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Recipe",
|
||||
"aggregateRating": {
|
||||
"ratingCount": 3,
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Justine Pattison"
|
||||
},
|
||||
"cookTime": "PT10M",
|
||||
"description": "Microwave jacket sweet potatoes make a wonderfully quick and easy meal. Take your pick of these three delicious fillings, or make all of them! The veggie chilli makes enough for four portions, great for lunch tomorrow. The smoked mackerel and pea fillings each make enough for two portions. \r\n\r\nThis recipe was tested using a 900W microwave oven. If your oven has more or fewer watts, you will need to adjust the cooking time.\r\n",
|
||||
"image": [
|
||||
"https://food-images.files.bbci.co.uk/food/recipes/microwave_sweet_potatoes_04783_16x9.jpg"
|
||||
],
|
||||
"keywords": "quick, jacket potato dinners, microwave recipes , quick and cheap dinners, quick delicious lunches, easy family dinners, lunch, student food, Jacket potato, sweet potato, peas, egg free, gluten free, nut free, pregnancy friendly",
|
||||
"name": "Microwave jacket sweet potato ",
|
||||
"prepTime": "PT30M",
|
||||
"recipeCategory": "Main course",
|
||||
"recipeIngredient": [
|
||||
"2 sweet potatoes, washed and dried",
|
||||
"75g/2\u00bdoz smoked mackerel, skinned and roughly mashed with a fork",
|
||||
"3 tbsp half-fat cr\u00e8me fra\u00eeche or soured cream",
|
||||
"2 spring onions, trimmed and thinly sliced",
|
||||
"\u00bd unwaxed lemon, finely grated zest only",
|
||||
"freshly ground black pepper ",
|
||||
"100g/3\u00bdoz frozen peas",
|
||||
"100g/3\u00bdoz feta ",
|
||||
"2 tbsp plain yoghurt",
|
||||
"1 tbsp finely chopped fresh mint",
|
||||
"freshly ground black pepper ",
|
||||
"\u00bd red pepper, deseeded and diced",
|
||||
"400g tin kidney beans in chilli sauce",
|
||||
"198g tin sweetcorn in water",
|
||||
"1 tbsp fresh lime juice",
|
||||
"50g/1\u00beoz mature Cheddar, coarsely grated",
|
||||
"4 tbsp soured cream",
|
||||
"fresh coriander, to garnish",
|
||||
"1 lime, cut into wedges, to serve"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
"Prick the sweet potatoes two or three times with a fork and put on a microwaveable plate. Microwave on high for 5\u20136 minutes for one potato or 7\u20138 minutes for two. Test the potatoes are soft by inserting a skewer through the middle, it should slide in easily. If the potatoes remain a little hard, cook for longer, testing again every 30 seconds. Divide the potatoes between plates, make a cross in the centre and open to fill.",
|
||||
"To make the smoked mackerel filling, mix all the ingredients together and season with lots of black pepper.",
|
||||
"To make the pea and feta filling, microwave the peas on high for 2\u20133 minutes, until thawed and just warm. Mash them with a fork, until well broken up, then mix in the feta, yoghurt and mint. Season with lots of black pepper.",
|
||||
"To make the veggie chilli filling, put the red pepper in a large microwavable bowl and cook on high for 1\u00bd\u20132 minutes, until soft. Add the beans and sweetcorn in its water, stir well and microwave on high for 4\u20135 minutes, until hot. Stir in the lime juice and mix well. Spoon into the cooked sweet potatoes and top with the cheese. Microwave for 1\u20132 minutes, until the cheese melts. Top with the soured cream, coriander and lime wedges. "
|
||||
],
|
||||
"recipeYield": "Serves 2",
|
||||
"suitableForDiet": [
|
||||
"http://schema.org/GlutenFreeDiet"
|
||||
],
|
||||
"url": "https://www.bbc.co.uk/food/recipes/microwave_sweet_potatoes_04783"
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
{
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Recipe",
|
||||
"mainEntityOfPage": "http://www.eatingwell.com/recipe/249961/moroccan-skirt-steak-with-roasted-pepper-couscous/",
|
||||
"name": "Moroccan Skirt Steak with Roasted Pepper Couscous",
|
||||
"image": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://imagesvc.meredithcorp.io/v3/mm/image?url=https%3A%2F%2Fstatic.onecms.io%2Fwp-content%2Fuploads%2Fsites%2F44%2F2019%2F08%2F26231251%2F3757257.jpg",
|
||||
"width": 960,
|
||||
"height": 960
|
||||
},
|
||||
"datePublished": "2016-06-03T04:27:31.000Z",
|
||||
"description": "Thin cuts of beef, such as skirt steak or sirloin steak, cook very quickly when seared in a hot skillet--just right for a busy weeknight. We love how the spicy Moroccan flavors on the steak complement the sweet, roasted pepper-studded couscous. Serve with: Arugula salad and a glass of Pinot Noir.",
|
||||
"prepTime": null,
|
||||
"cookTime": null,
|
||||
"totalTime": "P0DT0H35M",
|
||||
"recipeIngredient": [
|
||||
"2 medium bell peppers",
|
||||
"1 teaspoon ground cumin",
|
||||
"1 teaspoon ground coriander",
|
||||
"\u00be teaspoon salt",
|
||||
"\u00bd teaspoon ground turmeric",
|
||||
"\u00bd teaspoon ground cinnamon",
|
||||
"\u00bd teaspoon freshly ground pepper",
|
||||
"1 whole lemon, plus more lemon wedges for garnish",
|
||||
"1 tablespoon 1 teaspoon plus 1 tablespoon extra-virgin olive oil, divided",
|
||||
"\u2154 cup whole-wheat couscous",
|
||||
"1 pound 1 pound skirt steak (see Note) or sirloin steak, 3/4 to 1 inch thick, trimmed",
|
||||
"2 tablespoons chopped green olives"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Position rack in upper third of oven; preheat broiler.\n"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Place bell peppers on a baking sheet and roast under the broiler, turning every 5 minutes, until charred and softened, 10 to 15 minutes. Transfer to a clean cutting board; when cool enough to handle, chop the peppers into bite-size pieces.\n"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Meanwhile, combine cumin, coriander, salt, turmeric, cinnamon and pepper in a small bowl. Grate 1/2 teaspoon zest from the lemon. Juice the lemon into a 1-cup measure and add enough water to make 1 cup. Pour into a small saucepan and add the lemon zest, 1 teaspoon of the spice mixture and 1 teaspoon olive oil. Bring to a boil. Stir in couscous, cover, remove from heat and let stand.\n"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Heat the remaining 1 tablespoon oil in a large skillet (preferably cast-iron) over medium heat until shimmering (but not smoking). Rub the remaining spice mixture on both sides of steak. Cook the steak 2 to 3 minutes per side for medium-rare. Let rest on the cutting board for 5 minutes. Stir olives and the peppers into the couscous. Thinly slice the steak and serve with the couscous and lemon wedges, if desired.\n"
|
||||
}
|
||||
],
|
||||
"recipeCategory": [
|
||||
"Healthy Recipes",
|
||||
"Healthy Ingredient Recipes",
|
||||
"Healthy Meat & Poultry Recipes",
|
||||
"Healthy Beef Recipes",
|
||||
"Healthy Steak Recipes",
|
||||
"Healthy New York Strip Steak Recipes"
|
||||
],
|
||||
"recipeCuisine": [],
|
||||
"author": [
|
||||
{
|
||||
"@type": "Person",
|
||||
"name": "EatingWell Test Kitchen"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": 4.538461538461538,
|
||||
"ratingCount": 13,
|
||||
"itemReviewed": "Moroccan Skirt Steak with Roasted Pepper Couscous",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
},
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "453.7 calories",
|
||||
"carbohydrateContent": "36 g",
|
||||
"cholesterolContent": "96.4 mg",
|
||||
"fatContent": "18.4 g",
|
||||
"fiberContent": "6.5 g",
|
||||
"proteinContent": "36.4 g",
|
||||
"saturatedFatContent": "5.1 g",
|
||||
"servingSize": null,
|
||||
"sodiumContent": "663.3 mg",
|
||||
"sugarContent": null,
|
||||
"transFatContent": null,
|
||||
"unsaturatedFatContent": null
|
||||
},
|
||||
"review": [
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2011-10-30T21:53:57Z",
|
||||
"reviewBody": "Wow! This steak was fabulous. Full of flavor even my kids liked it. The spices for the steak rub get added to the cous cous. Along with the sweet roasted peppers it was so delicious.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "shari_martinez@sbcglobal.net",
|
||||
"image": null,
|
||||
"sameAs": "https://www.allrecipes.com/cook/18308949/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2011-10-30T02:40:11Z",
|
||||
"reviewBody": "Not as well received as I had hoped The leftovers were good as a steak salad with honey-mustard seasoning although Kaja did not like the seasoned meat that way. I ate some couscous on my salad too. Leftover steak is unheard of at our house yet there it was. Offerred the kids a choice of unspiced steak (three takers) and Rice-a-Roni (four takers) Austin & I liked the steak Pros: Easy I liked it Cons: Nobody liked the couscous except me",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 3
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Ellen",
|
||||
"image": null,
|
||||
"sameAs": "https://www.allrecipes.com/cook/19797391/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2011-10-30T03:02:54Z",
|
||||
"reviewBody": "tasty and easy weeknight meal Initially I was a little leery of using cinnamon to season the meat but it actually turned out very good. The seasoning had a tasty and light flavor. I used sirloin because the skirt steak at the store looked very fatty. The couscous (although I didn't add the olives because I'm not a huge fan) was also very good and I enjoyed the sweetness of the bell pepper. I used the lemon garnish to squeeze over the couscous and meat and think it made it. I would definitely make this again! Pros: Tasty and easy weeknight meal",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Kat Y",
|
||||
"image": null,
|
||||
"sameAs": "https://www.allrecipes.com/cook/2343563/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2011-10-30T17:25:23Z",
|
||||
"reviewBody": "This was really good. It was definitely a change from what we're used to but in a good way. I had to use skillet steak because I couldn't find any skirt steak but it worked just fine. I also used the grill for the peppers and steak because my broiler is a bit questionable. Other than that I made it as stated and my husband and I really enjoyed it. A great out of the ordinary quick meal.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "EatingWell User",
|
||||
"image": null,
|
||||
"sameAs": "https://www.allrecipes.com/cook/eatingwelluser/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2011-10-30T03:41:53Z",
|
||||
"reviewBody": "I love this recipe so much that I schedule it my meal planning as often as possible. The flavors with this cut of meat are just wonderful. I can't eat grains so I pair it with fennel. Perfect!",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "EatingWell User",
|
||||
"image": null,
|
||||
"sameAs": "https://www.allrecipes.com/cook/eatingwelluser/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2013-02-20T17:33:48Z",
|
||||
"reviewBody": "Great starting point! I like this one a lot but I would recommend the version of it on funnytummycafe.com. I've not been really adventurous with new flavors but this I will repeat! Pros: Wonderful flavors in the rub Cons: The couscous was TOO lemony",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 3
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "EatingWell User",
|
||||
"image": null,
|
||||
"sameAs": "https://www.allrecipes.com/cook/eatingwelluser/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2011-10-30T15:06:11Z",
|
||||
"reviewBody": "I made this with chicken instead of steak and it was great! My husband has not liked couscous in the past but he really liked this version. Will definitely make again!",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "EatingWell User",
|
||||
"image": null,
|
||||
"sameAs": "https://www.allrecipes.com/cook/eatingwelluser/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2011-10-30T12:15:53Z",
|
||||
"reviewBody": "WOW! Blew me away this was a great meal! Tons of flavor really quick and very filling! I served brown rice instead of couscous because that's what I had on hand. The husband never knew the difference! I can always tell when something is a real winner with him because he goes back for seconds (we're both on diets). I used one red and one green bell pepper and served a spring mix salad with tomato and feta on the side. Big bravo to the EatingWell kitchen! This will definitely be made again in my house!",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "EatingWell User",
|
||||
"image": null,
|
||||
"sameAs": "https://www.allrecipes.com/cook/eatingwelluser/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2011-10-30T15:12:50Z",
|
||||
"reviewBody": "Quick & easy to prepare with mild spiced flavor. The rub produces a great crust on the steak. I felt the lemon flavor dominated the cous cous though to be fair I did seem to have a particularly juicy lemon so that may be why. My husband and I both thought that the leftover steak would be superb sliced thinly in a sandwich with a mint/yogurt dressing.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "EatingWell User",
|
||||
"image": null,
|
||||
"sameAs": "https://www.allrecipes.com/cook/eatingwelluser/"
|
||||
}
|
||||
}
|
||||
],
|
||||
"url": "http://www.eatingwell.com/recipe/249961/moroccan-skirt-steak-with-roasted-pepper-couscous/"
|
||||
}
|
||||
BIN
mealie/tests/data/test_image.jpg
Normal file
|
After Width: | Height: | Size: 88 KiB |
20
mealie/tests/test_config.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from pathlib import Path
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
|
||||
|
||||
TEST_DIR = CWD
|
||||
TEST_DATA = CWD.joinpath("data")
|
||||
|
||||
# Scraper
|
||||
TEST_RAW_HTML = TEST_DATA.joinpath("scraper", "html-raw")
|
||||
TEST_RAW_RECIPES = TEST_DATA.joinpath("scraper", "recipes-raw")
|
||||
|
||||
# Migrations
|
||||
TEST_CHOWDOWN_DIR = TEST_DATA.joinpath("migrations", "chowdown")
|
||||
TEST_NEXTCLOUD_DIR = TEST_DATA.joinpath("migrations", "nextcloud")
|
||||
|
||||
# Routes
|
||||
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
@@ -1,17 +1,15 @@
|
||||
from pathlib import Path
|
||||
from app_config import TEMP_DIR
|
||||
import pytest
|
||||
from app_config import TEMP_DIR
|
||||
from services.image_services import IMG_DIR
|
||||
from services.migrations.nextcloud import (
|
||||
cleanup,
|
||||
import_recipes,
|
||||
prep,
|
||||
process_selection,
|
||||
)
|
||||
from services.migrations.nextcloud import (cleanup, import_recipes, prep,
|
||||
process_selection)
|
||||
from services.recipe_services import Recipe
|
||||
from tests.test_config import TEST_NEXTCLOUD_DIR
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
NEXTCLOUD_DIR = CWD.parent.joinpath("data", "nextcloud_recipes")
|
||||
TEST_NEXTCLOUD_DIR
|
||||
TEMP_NEXTCLOUD = TEMP_DIR.joinpath("nextcloud")
|
||||
|
||||
|
||||
@@ -21,7 +19,7 @@ TEMP_NEXTCLOUD = TEMP_DIR.joinpath("nextcloud")
|
||||
)
|
||||
def test_zip_extraction(file_name: str, final_path: Path):
|
||||
prep()
|
||||
zip = NEXTCLOUD_DIR.joinpath(file_name)
|
||||
zip = TEST_NEXTCLOUD_DIR.joinpath(file_name)
|
||||
dir = process_selection(zip)
|
||||
|
||||
assert dir == final_path
|
||||
@@ -32,9 +30,9 @@ def test_zip_extraction(file_name: str, final_path: Path):
|
||||
@pytest.mark.parametrize(
|
||||
"recipe_dir",
|
||||
[
|
||||
NEXTCLOUD_DIR.joinpath("Air Fryer Shrimp"),
|
||||
NEXTCLOUD_DIR.joinpath("Chicken Parmigiana"),
|
||||
NEXTCLOUD_DIR.joinpath("Skillet Shepherd's Pie"),
|
||||
TEST_NEXTCLOUD_DIR.joinpath("Air Fryer Shrimp"),
|
||||
TEST_NEXTCLOUD_DIR.joinpath("Chicken Parmigiana"),
|
||||
TEST_NEXTCLOUD_DIR.joinpath("Skillet Shepherd's Pie"),
|
||||
],
|
||||
)
|
||||
def test_nextcloud_migration(recipe_dir: Path):
|
||||
|
||||
116
mealie/tests/test_routes/test_meal_routes.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from tests.test_routes.utils.routes_data import recipe_test_data
|
||||
from tests.utils.routes import (
|
||||
MEALPLAN_ALL,
|
||||
MEALPLAN_CREATE,
|
||||
MEALPLAN_PREFIX,
|
||||
RECIPES_CREATE_URL,
|
||||
RECIPES_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
def get_meal_plan_template(first=None, second=None):
|
||||
return {
|
||||
"startDate": "2021-01-18",
|
||||
"endDate": "2021-01-19",
|
||||
"meals": [
|
||||
{
|
||||
"slug": first,
|
||||
"date": "2021-1-17",
|
||||
"dateText": "Monday, January 18, 2021",
|
||||
},
|
||||
{
|
||||
"slug": second,
|
||||
"date": "2021-1-18",
|
||||
"dateText": "Tueday, January 19, 2021",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
## Meal Routes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slug_1(api_client):
|
||||
# Slug 1
|
||||
|
||||
slug_1 = api_client.post(RECIPES_CREATE_URL, json={"url": recipe_test_data[0].url})
|
||||
slug_1 = json.loads(slug_1.content)
|
||||
|
||||
yield slug_1
|
||||
|
||||
api_client.delete(RECIPES_PREFIX + "/" + slug_1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slug_2(api_client):
|
||||
# Slug 2
|
||||
slug_2 = api_client.post(RECIPES_CREATE_URL, json={"url": recipe_test_data[1].url})
|
||||
slug_2 = json.loads(slug_2.content)
|
||||
|
||||
yield slug_2
|
||||
|
||||
api_client.delete(RECIPES_PREFIX + "/" + slug_2)
|
||||
|
||||
|
||||
def test_create_mealplan(api_client, slug_1, slug_2):
|
||||
meal_plan = get_meal_plan_template()
|
||||
meal_plan["meals"][0]["slug"] = slug_1
|
||||
meal_plan["meals"][1]["slug"] = slug_2
|
||||
|
||||
response = api_client.post(MEALPLAN_CREATE, json=meal_plan)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_read_mealplan(api_client, slug_1, slug_2):
|
||||
response = api_client.get(MEALPLAN_ALL)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
meal_plan = get_meal_plan_template(slug_1, slug_2)
|
||||
|
||||
new_meal_plan = json.loads(response.text)
|
||||
meals = new_meal_plan[0]["meals"]
|
||||
|
||||
assert meals[0]["slug"] == meal_plan["meals"][0]["slug"]
|
||||
assert meals[1]["slug"] == meal_plan["meals"][1]["slug"]
|
||||
|
||||
|
||||
def test_update_mealplan(api_client, slug_1, slug_2):
|
||||
|
||||
response = api_client.get(MEALPLAN_ALL)
|
||||
|
||||
existing_mealplan = json.loads(response.text)
|
||||
existing_mealplan = existing_mealplan[0]
|
||||
|
||||
## Swap
|
||||
plan_uid = existing_mealplan.get("uid")
|
||||
existing_mealplan["meals"][0]["slug"] = slug_2
|
||||
existing_mealplan["meals"][1]["slug"] = slug_1
|
||||
|
||||
response = api_client.put(f"{MEALPLAN_PREFIX}/{plan_uid}", json=existing_mealplan)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
response = api_client.get(MEALPLAN_ALL)
|
||||
existing_mealplan = json.loads(response.text)
|
||||
existing_mealplan = existing_mealplan[0]
|
||||
|
||||
assert existing_mealplan["meals"][0]["slug"] == slug_2
|
||||
assert existing_mealplan["meals"][1]["slug"] == slug_1
|
||||
|
||||
|
||||
def test_delete_mealplan(api_client):
|
||||
response = api_client.get(MEALPLAN_ALL)
|
||||
|
||||
assert response.status_code == 200
|
||||
existing_mealplan = json.loads(response.text)
|
||||
existing_mealplan = existing_mealplan[0]
|
||||
|
||||
plan_uid = existing_mealplan.get("uid")
|
||||
response = api_client.delete(f"{MEALPLAN_PREFIX}/{plan_uid}")
|
||||
|
||||
assert response.status_code == 200
|
||||
103
mealie/tests/test_routes/test_migration_routes.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from app_config import MIGRATION_DIR
|
||||
from tests.test_config import TEST_CHOWDOWN_DIR, TEST_NEXTCLOUD_DIR
|
||||
from tests.utils.routes import MIGRATIONS_PREFIX, RECIPES_PREFIX
|
||||
|
||||
|
||||
### Chowdown
|
||||
@pytest.fixture(scope="session")
|
||||
def chowdown_zip():
|
||||
zip = TEST_CHOWDOWN_DIR.joinpath("test_chowdown-gh-pages.zip")
|
||||
|
||||
zip_copy = TEST_CHOWDOWN_DIR.joinpath("chowdown-gh-pages.zip")
|
||||
|
||||
shutil.copy(zip, zip_copy)
|
||||
|
||||
yield zip_copy
|
||||
|
||||
zip_copy.unlink()
|
||||
|
||||
|
||||
def test_upload_chowdown_zip(api_client, chowdown_zip):
|
||||
|
||||
response = api_client.post(
|
||||
f"{MIGRATIONS_PREFIX}/chowdown/upload",
|
||||
files={"archive": chowdown_zip.open("rb")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert MIGRATION_DIR.joinpath("chowdown", chowdown_zip.name).is_file()
|
||||
|
||||
|
||||
def test_import_chowdown_directory(api_client, chowdown_zip):
|
||||
selection = chowdown_zip.name
|
||||
response = api_client.post(f"{MIGRATIONS_PREFIX}/chowdown/{selection}/import")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
report = json.loads(response.content)
|
||||
assert report["failed"] == []
|
||||
|
||||
expected_slug = "roasted-okra"
|
||||
response = api_client.get(f"{RECIPES_PREFIX}/{expected_slug}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_delete_chowdown_migration_data(api_client, chowdown_zip):
|
||||
selection = chowdown_zip.name
|
||||
response = api_client.delete(f"{MIGRATIONS_PREFIX}/chowdown/{selection}/delete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not MIGRATION_DIR.joinpath(chowdown_zip.name).is_file()
|
||||
|
||||
|
||||
### Nextcloud
|
||||
@pytest.fixture(scope="session")
|
||||
def nextcloud_zip():
|
||||
zip = TEST_NEXTCLOUD_DIR.joinpath("nextcloud.zip")
|
||||
|
||||
zip_copy = TEST_NEXTCLOUD_DIR.joinpath("new_nextcloud.zip")
|
||||
|
||||
shutil.copy(zip, zip_copy)
|
||||
|
||||
yield zip_copy
|
||||
|
||||
zip_copy.unlink()
|
||||
|
||||
|
||||
def test_upload_nextcloud_zip(api_client, nextcloud_zip):
|
||||
|
||||
response = api_client.post(
|
||||
f"{MIGRATIONS_PREFIX}/nextcloud/upload",
|
||||
files={"archive": nextcloud_zip.open("rb")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert MIGRATION_DIR.joinpath("nextcloud", nextcloud_zip.name).is_file()
|
||||
|
||||
|
||||
def test_import_nextcloud_directory(api_client, nextcloud_zip):
|
||||
selection = nextcloud_zip.name
|
||||
response = api_client.post(f"{MIGRATIONS_PREFIX}/nextcloud/{selection}/import")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
report = json.loads(response.content)
|
||||
assert report["failed"] == []
|
||||
|
||||
expected_slug = "air-fryer-shrimp"
|
||||
response = api_client.get(f"{RECIPES_PREFIX}/{expected_slug}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_delete__nextcloud_migration_data(api_client, nextcloud_zip):
|
||||
selection = nextcloud_zip.name
|
||||
response = api_client.delete(f"{MIGRATIONS_PREFIX}/nextcloud/{selection}/delete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not MIGRATION_DIR.joinpath(nextcloud_zip.name).is_file()
|
||||
106
mealie/tests/test_routes/test_recipe_routes.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from slugify import slugify
|
||||
from tests.test_routes.utils.routes_data import (RecipeTestData, raw_recipe,
|
||||
raw_recipe_no_image,
|
||||
recipe_test_data)
|
||||
from tests.utils.routes import (RECIPES_ALL, RECIPES_CREATE,
|
||||
RECIPES_CREATE_URL, RECIPES_PREFIX)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
||||
def test_create_by_url(api_client, recipe_data: RecipeTestData):
|
||||
response = api_client.post(RECIPES_CREATE_URL, json={"url": recipe_data.url})
|
||||
assert response.status_code == 201
|
||||
assert json.loads(response.text) == recipe_data.expected_slug
|
||||
|
||||
|
||||
def test_create_by_json(api_client):
|
||||
response = api_client.post(RECIPES_CREATE, json=raw_recipe)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert json.loads(response.text) == "banana-bread"
|
||||
|
||||
|
||||
def test_create_no_image(api_client):
|
||||
response = api_client.post(RECIPES_CREATE, json=raw_recipe_no_image)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert json.loads(response.text) == "banana-bread-no-image"
|
||||
|
||||
|
||||
# def test_upload_image(api_client, test_image):
|
||||
# data = {"image": test_image.open("rb").read(), "extension": "jpg"}
|
||||
|
||||
# response = api_client.post(
|
||||
# "{RECIPES_PREFIX}banana-bread-no-image/update/image/", files=data
|
||||
# )
|
||||
|
||||
# assert response.status_code == 200
|
||||
|
||||
# response = api_client.get("{RECIPES_PREFIX}banana-bread-no-image/update/image/")
|
||||
|
||||
|
||||
def test_read_all_post(api_client):
|
||||
response = api_client.post(
|
||||
RECIPES_ALL, json={"properties": ["slug", "description", "rating"]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
||||
def test_read_update(api_client, recipe_data):
|
||||
response = api_client.get(f"{RECIPES_PREFIX}/{recipe_data.expected_slug}")
|
||||
assert response.status_code == 200
|
||||
|
||||
recipe = json.loads(response.content)
|
||||
|
||||
test_notes = [
|
||||
{"title": "My Test Title1", "text": "My Test Text1"},
|
||||
{"title": "My Test Title2", "text": "My Test Text2"},
|
||||
]
|
||||
recipe["notes"] = test_notes
|
||||
|
||||
test_categories = ["one", "two", "three"]
|
||||
recipe["categories"] = test_categories
|
||||
|
||||
response = api_client.put(
|
||||
f"{RECIPES_PREFIX}/{recipe_data.expected_slug}", json=recipe
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.text) == recipe_data.expected_slug
|
||||
|
||||
response = api_client.get(f"{RECIPES_PREFIX}/{recipe_data.expected_slug}")
|
||||
|
||||
recipe = json.loads(response.content)
|
||||
|
||||
assert recipe["notes"] == test_notes
|
||||
assert recipe["categories"].sort() == test_categories.sort()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
||||
def test_rename(api_client, recipe_data):
|
||||
response = api_client.get(f"{RECIPES_PREFIX}/{recipe_data.expected_slug}")
|
||||
assert response.status_code == 200
|
||||
|
||||
recipe = json.loads(response.content)
|
||||
new_name = recipe.get("name") + "-rename"
|
||||
new_slug = slugify(new_name)
|
||||
recipe["name"] = new_name
|
||||
|
||||
response = api_client.put(
|
||||
f"{RECIPES_PREFIX}/{recipe_data.expected_slug}", json=recipe
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.text) == new_slug
|
||||
|
||||
recipe_data.expected_slug = new_slug
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
||||
def test_delete(api_client, recipe_data):
|
||||
response = api_client.delete(f"{RECIPES_PREFIX}/{recipe_data.expected_slug}")
|
||||
assert response.status_code == 200
|
||||
112
mealie/tests/test_routes/test_settings_routes.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from tests.utils.routes import (
|
||||
SETTINGS_PREFIX,
|
||||
SETTINGS_UPDATE,
|
||||
THEMES_CREATE,
|
||||
THEMES_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def default_settings():
|
||||
return {
|
||||
"name": "main",
|
||||
"webhooks": {"webhookTime": "00:00", "webhookURLs": [], "enabled": False},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def default_theme(api_client):
|
||||
|
||||
default_theme = {
|
||||
"name": "default",
|
||||
"colors": {
|
||||
"primary": "#E58325",
|
||||
"accent": "#00457A",
|
||||
"secondary": "#973542",
|
||||
"success": "#5AB1BB",
|
||||
"info": "#4990BA",
|
||||
"warning": "#FF4081",
|
||||
"error": "#EF5350",
|
||||
},
|
||||
}
|
||||
api_client.post(THEMES_CREATE, json=default_theme)
|
||||
|
||||
return default_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def new_theme():
|
||||
return {
|
||||
"name": "myTestTheme",
|
||||
"colors": {
|
||||
"primary": "#E58325",
|
||||
"accent": "#00457A",
|
||||
"secondary": "#973542",
|
||||
"success": "#5AB1BB",
|
||||
"info": "#4990BA",
|
||||
"warning": "#FF4081",
|
||||
"error": "#EF5350",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_default_settings(api_client, default_settings):
|
||||
response = api_client.get(SETTINGS_PREFIX)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert json.loads(response.content) == default_settings
|
||||
|
||||
|
||||
def test_update_settings(api_client, default_settings):
|
||||
default_settings["webhooks"]["webhookURLs"] = [
|
||||
"https://test1.url.com",
|
||||
"https://test2.url.com",
|
||||
"https://test3.url.com",
|
||||
]
|
||||
|
||||
response = api_client.put(SETTINGS_UPDATE, json=default_settings)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
response = api_client.get(SETTINGS_PREFIX)
|
||||
assert json.loads(response.content) == default_settings
|
||||
|
||||
|
||||
def test_default_theme(api_client, default_theme):
|
||||
response = api_client.get(f"{THEMES_PREFIX}/default")
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.content) == default_theme
|
||||
|
||||
|
||||
def test_create_theme(api_client, new_theme):
|
||||
|
||||
response = api_client.post(THEMES_CREATE, json=new_theme)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = api_client.get(f"{THEMES_PREFIX}/{new_theme.get('name')}")
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.content) == new_theme
|
||||
|
||||
|
||||
def test_read_all_themes(api_client, default_theme, new_theme):
|
||||
response = api_client.get(THEMES_PREFIX)
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.content) == [default_theme, new_theme]
|
||||
|
||||
|
||||
def test_read_theme(api_client, default_theme, new_theme):
|
||||
for theme in [default_theme, new_theme]:
|
||||
response = api_client.get(f"{THEMES_PREFIX}/{theme.get('name')}")
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.content) == theme
|
||||
|
||||
|
||||
def test_delete_theme(api_client, default_theme, new_theme):
|
||||
for theme in [default_theme, new_theme]:
|
||||
response = api_client.delete(f"{THEMES_PREFIX}/{theme.get('name')}")
|
||||
|
||||
assert response.status_code == 200
|
||||
3
mealie/tests/test_routes/test_tags_categories.py
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
111
mealie/tests/test_routes/utils/routes_data.py
Normal file
@@ -0,0 +1,111 @@
|
||||
class RecipeTestData:
|
||||
def __init__(self, url, expected_slug) -> None:
|
||||
self.url: str = url
|
||||
self.expected_slug: str = expected_slug
|
||||
|
||||
|
||||
recipe_test_data = [
|
||||
RecipeTestData(
|
||||
url="https://www.bonappetit.com/recipe/rustic-shrimp-toasts",
|
||||
expected_slug="rustic-shrimp-toasts",
|
||||
),
|
||||
RecipeTestData(
|
||||
url="https://www.allrecipes.com/recipe/282905/honey-garlic-shrimp/",
|
||||
expected_slug="honey-garlic-shrimp",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
raw_recipe = {
|
||||
"name": "Banana Bread",
|
||||
"description": "From Angie's mom",
|
||||
"image": "banana-bread.jpg",
|
||||
"recipeYield": "",
|
||||
"recipeIngredient": [
|
||||
"4 bananas",
|
||||
"1/2 cup butter",
|
||||
"1/2 cup sugar",
|
||||
"2 eggs",
|
||||
"2 cups flour",
|
||||
"1/2 tsp baking soda",
|
||||
"1 tsp baking powder",
|
||||
"pinch salt",
|
||||
"1/4 cup nuts (we like pecans)",
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "Beat the eggs, then cream with the butter and sugar",
|
||||
"text": "Beat the eggs, then cream with the butter and sugar",
|
||||
},
|
||||
{
|
||||
"@type": "Mix in bananas, then flour, baking soda/powder, salt, and nuts",
|
||||
"text": "Mix in bananas, then flour, baking soda/powder, salt, and nuts",
|
||||
},
|
||||
{
|
||||
"@type": "Add to greased and floured pan",
|
||||
"text": "Add to greased and floured pan",
|
||||
},
|
||||
{
|
||||
"@type": "Bake until brown/cracked, toothpick comes out clean",
|
||||
"text": "Bake until brown/cracked, toothpick comes out clean",
|
||||
},
|
||||
],
|
||||
"totalTime": "None",
|
||||
"prepTime": None,
|
||||
"performTime": None,
|
||||
"slug": "",
|
||||
"categories": [],
|
||||
"tags": ["breakfast", " baking"],
|
||||
"dateAdded": "2021-01-12",
|
||||
"notes": [],
|
||||
"rating": 0,
|
||||
"orgURL": None,
|
||||
"extras": {},
|
||||
}
|
||||
|
||||
raw_recipe_no_image = {
|
||||
"name": "Banana Bread No Image",
|
||||
"description": "From Angie's mom",
|
||||
"image": "",
|
||||
"recipeYield": "",
|
||||
"recipeIngredient": [
|
||||
"4 bananas",
|
||||
"1/2 cup butter",
|
||||
"1/2 cup sugar",
|
||||
"2 eggs",
|
||||
"2 cups flour",
|
||||
"1/2 tsp baking soda",
|
||||
"1 tsp baking powder",
|
||||
"pinch salt",
|
||||
"1/4 cup nuts (we like pecans)",
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "Beat the eggs, then cream with the butter and sugar",
|
||||
"text": "Beat the eggs, then cream with the butter and sugar",
|
||||
},
|
||||
{
|
||||
"@type": "Mix in bananas, then flour, baking soda/powder, salt, and nuts",
|
||||
"text": "Mix in bananas, then flour, baking soda/powder, salt, and nuts",
|
||||
},
|
||||
{
|
||||
"@type": "Add to greased and floured pan",
|
||||
"text": "Add to greased and floured pan",
|
||||
},
|
||||
{
|
||||
"@type": "Bake until brown/cracked, toothpick comes out clean",
|
||||
"text": "Bake until brown/cracked, toothpick comes out clean",
|
||||
},
|
||||
],
|
||||
"totalTime": "None",
|
||||
"prepTime": None,
|
||||
"performTime": None,
|
||||
"slug": "",
|
||||
"categories": [],
|
||||
"tags": ["breakfast", " baking"],
|
||||
"dateAdded": "2021-01-12",
|
||||
"notes": [],
|
||||
"rating": 0,
|
||||
"orgURL": None,
|
||||
"extras": {},
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"@context": "http:\/\/schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": "Air Fryer Shrimp",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Anna"
|
||||
},
|
||||
"description": "These Air Fryer Shrimp are plump, juicy and perfectly seasoned! This healthy dish is ready in just 8 minutes and requires pantry staples to make it.",
|
||||
"datePublished": "2020-07-13T16:48:25+00:00",
|
||||
"image": "https:\/\/www.crunchycreamysweet.com\/wp-content\/uploads\/2020\/07\/air-fryer-shrimp-A-480x270.jpg",
|
||||
"recipeYield": 4,
|
||||
"prepTime": "PT0H15M",
|
||||
"cookTime": "PT0H8M",
|
||||
"totalTime": "PT0H23M",
|
||||
"recipeIngredient": [
|
||||
"1 lb shrimp",
|
||||
"2 teaspoons olive oil",
|
||||
"\u00bd teaspoon garlic powder",
|
||||
"\u00bc teaspoon paprika",
|
||||
"\u00bd teaspoon Italian seasoning",
|
||||
"\u00bd teaspoon salt",
|
||||
"\u00bc teaspoon black pepper"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
"Cleaning the shrimp by removing shells and veins. Run under tap water, then pat dry with paper towel.",
|
||||
"Mix oil with seasoning in a small bowl.",
|
||||
"Brush shrimp with seasoning mixture on both sides.",
|
||||
"Arrange shrimp in air fryer basket or rack, in a single layer.",
|
||||
"Cook at 400 degrees F for 8 minutes (no need to turn them).",
|
||||
"Serve."
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "5",
|
||||
"ratingCount": "4"
|
||||
},
|
||||
"recipeCategory": "Main Course",
|
||||
"recipeCuisine": [
|
||||
"American"
|
||||
],
|
||||
"keywords": "air fryer shrimp",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "134 kcal",
|
||||
"carbohydrateContent": "1 g",
|
||||
"proteinContent": "23 g",
|
||||
"fatContent": "4 g",
|
||||
"saturatedFatContent": "1 g",
|
||||
"cholesterolContent": "286 mg",
|
||||
"sodiumContent": "1172 mg",
|
||||
"fiberContent": "1 g",
|
||||
"sugarContent": "1 g",
|
||||
"servingSize": "1 serving"
|
||||
},
|
||||
"@id": "https:\/\/www.crunchycreamysweet.com\/air-fryer-shrimp\/#recipe",
|
||||
"isPartOf": {
|
||||
"@id": "https:\/\/www.crunchycreamysweet.com\/air-fryer-shrimp\/#article"
|
||||
},
|
||||
"mainEntityOfPage": "https:\/\/www.crunchycreamysweet.com\/air-fryer-shrimp\/#webpage",
|
||||
"tool": [],
|
||||
"url": "https:\/\/www.crunchycreamysweet.com\/air-fryer-shrimp\/"
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"@context": "http:\/\/schema.org",
|
||||
"@type": "Recipe",
|
||||
"mainEntityOfPage": "https:\/\/www.allrecipes.com\/recipe\/8975\/chicken-parmigiana\/",
|
||||
"name": "Chicken Parmigiana",
|
||||
"image": "https:\/\/imagesvc.meredithcorp.io\/v3\/mm\/image?url=https%3A%2F%2Fimages.media-allrecipes.com%2Fuserphotos%2F10037.jpg",
|
||||
"datePublished": "1999-04-27T12:40:19.000Z",
|
||||
"description": "This is a very nice dinner for two. Serve it with your favorite pasta and tossed greens.",
|
||||
"prepTime": "PT0H30M",
|
||||
"cookTime": "PT1H0M",
|
||||
"totalTime": "PT1H30M",
|
||||
"recipeYield": 2,
|
||||
"recipeIngredient": [
|
||||
"1 egg, beaten",
|
||||
"2 ounces dry bread crumbs",
|
||||
"2 skinless, boneless chicken breast halves",
|
||||
"\u00be (16 ounce) jar spaghetti sauce",
|
||||
"2 ounces shredded mozzarella cheese",
|
||||
"\u00bc cup grated Parmesan cheese"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
"Preheat oven to 350 degrees F (175 degrees C). Lightly grease a medium baking sheet.\n",
|
||||
"Pour egg into a small shallow bowl. Place bread crumbs in a separate shallow bowl. Dip chicken into egg, then into the bread crumbs. Place coated chicken on the prepared baking sheet and bake in the preheated oven for 40 minutes, or until no longer pink and juices run clear.\n",
|
||||
"Pour 1\/2 of the spaghetti sauce into a 7x11 inch baking dish. Place chicken over sauce, and cover with remaining sauce. Sprinkle mozzarella and Parmesan cheeses on top and return to the preheated oven for 20 minutes.\n"
|
||||
],
|
||||
"recipeCategory": "World Cuisine Recipes",
|
||||
"recipeCuisine": [],
|
||||
"author": [
|
||||
{
|
||||
"@type": "Person",
|
||||
"name": "Candy"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": 4.580034423407917,
|
||||
"ratingCount": 1743,
|
||||
"itemReviewed": "Chicken Parmigiana",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
},
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "528.3 calories",
|
||||
"carbohydrateContent": "44.9 g",
|
||||
"cholesterolContent": "184.1 mg",
|
||||
"fatContent": "18.3 g",
|
||||
"fiberContent": "5.6 g",
|
||||
"proteinContent": "43.5 g",
|
||||
"saturatedFatContent": "7.6 g",
|
||||
"servingSize": null,
|
||||
"sodiumContent": "1309.5 mg",
|
||||
"sugarContent": "17.2 g",
|
||||
"transFatContent": null,
|
||||
"unsaturatedFatContent": null
|
||||
},
|
||||
"review": [
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2004-02-10T10:18:54.927Z",
|
||||
"reviewBody": "This is a DELICIOUS basic recipe. I have been doing a similar one for years. I also, prefer adding a few more spices TO THE BREAD CRUMBS,like basil, oregano, garlic powder, salt, fresh cracked pepper and onion powder, and a few TBSP of the parmensan cheese;not only ON IT later. For some reason these spices (added separately) are good, but we don't like with an pre-mix of \"Italian\"spice. It seems to taste a little \"soapy\". Not sure which spice does that to it.? Some suggested to \"double dip\" in bread crumbs;if you do, you should really LIKE a heavy battering. It was too thick for our tastes(esp. since you bake in the sauce; to me,the bottom gets a little mushy, and it just adds extra fat and calories). I also use a cookie cooling \"RACK\" SET ON TOP of a baking sheet, to bake the chicken on instead of just on the cookie sheet pan. It comes out much crisper; letting air get to the BOTTOM of the chicken,also. Also,I wait to spoon the SECOND 1\/2 of the sauce UNTIL SERVING, the chicken will stay crisper,(even with the cheese on top). Obviously, we like the chicken on the crisp side (but we don't want to deep fry).\r\nFor company, put the chicken (with just the cheese baked on top) ON TOP of a small mound of spaghetti and sauce,or any pasta; It makes for a delicious looking presentation. A side salad with some sort of CREAMY dressing seems to compliment the red sauce, and completes the meal wonderfully. We get cravings for this one about 2c a month!",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "somethingdifferentagain?!",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/342976\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2004-01-23T16:37:02.013Z",
|
||||
"reviewBody": "This was an extremely easy, very tasty recipe. As many others suggested, I only put sauce on the bottom of the chicken and then spooned a little over the top when serving. I think the recipe could be improved, though, by (1) pounding the chicken to a uniform thickness and (2) by spicing up the bread crumbs. I used Italian bread crumbs but next time will sprinkle pepper on the chicken before dredging through the crumbs, and I also plan to add more Italian seasoning and maybe a little parmesan to the crumbs. Both these steps, in my opinion, would take this from a really good recipe to an excellent dish!",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "JBAGNALL",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/642772\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2005-11-19T20:22:40.53Z",
|
||||
"reviewBody": "I BRINED my chicken in 4 cups water , 1\/2 cup kosher salt (1\/4 table salt) \u00bd cup sugar for 30 minutes. No need to brine if you are using quick frozen chicken that has been enhanced. Kosher chicken is prebrined. Brining=juicy chicken. Took brined chicken, cut off thin edges, pounded out & shook chicken w\/flour (preflouring allows bread crumbs to stick) in a Ziploc-letting floured chicken sit for 5 min. I heated 6 TBS vegetable oil till it shimmered & then added 2 TBS butter to my pan, reserving half of this mixture for my second batch. Bread crumb mixture: I use \u00bd cup seasoned bread crumbs(same as 2 ounces), \u00bd cup grated parmesan( double what recipe calls for), 1tsp. Mrs. Dash Garlic and Herb, \u00bd tsp. garlic powder, \u00bd tsp, onion powder, \u00bd tsp. Italian seasoning & a pinch of pepper. Took pre-floured chicken, coated it with egg mixture, then dipped in bread crumbs & FRIED the chicken to a medium golden brown. Shook some parmesan on them right away when done frying to absorb any oil. Side-by side I plated plain spaghetti noodles & cutlets, w\/2 TBSP sauce on cutlet & desired amount of sauce on pasta, covered in cheese & baked each individual plate till cheese melted, serving them straight out of the oven. \r\nThe reviews on this were probably the best I have ever gotten, I used to work in an Italian Restaurant owned by NY Italians & have picked up some techniques. My Fettuccine Alfredo used to be my husband favorite dish, after last night he told me he has a new favorite. \r\n",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "KC MARTEL",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/526291\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2003-10-22T15:32:26.607Z",
|
||||
"reviewBody": "After several Chicken Parm recipes THIS is THE ONE:-) I've finally found one that we all love! It's simple and it's darned good:-) I will definately make this recipe again and again; thanks so much:-)",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "STARCHILD1166",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/736533\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2003-11-14T16:55:26.39Z",
|
||||
"reviewBody": "This chicken was so easy to make and turned out excellent! Used Best Marinara Sauce Yet (found here as well)instead of regular spaghetti sauce. This added even more flavor.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Alison",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/516223\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2003-01-23T04:38:19.873Z",
|
||||
"reviewBody": "I REALLY liked this recipe. I made my own spaghetti sauce and used parmesan reggiano. I also skipped dipping the breasts in egg as I thought it was unnecessary and it was. Cooking temp. and time are accurate. Even my fussy fiance liked this. I'll definitely make this again.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "CSANDST1",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/115553\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2003-08-05T20:26:00.81Z",
|
||||
"reviewBody": "Wow! This was really tasty and simple. Something quick to make when you can't spend too much time figuring out what's for dinner. Also great on a toasted roll\/hero as a sandwich. I varied the recipe a little by adding some parmesan cheese (big cheese lover that I am!), garlic powder, onion powder and some salt into the bread crumbs and then mixing it up before breading the chicken with it. Also added a little salt to the beaten egg to make sure the chicken wouldn't end up bland, but that's just my preference. In response to the one reviewer who wanted thicker breading, what I did was double dip the chicken - coat first with the bread crumbs, then dip into the beaten egg and re-coat with breadcrumbs before actually baking (this would require some more breadcrumbs and probably another egg). Excellent recipe! =]",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "LIZCHAO74",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/511187\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2003-07-23T07:53:37.18Z",
|
||||
"reviewBody": "Wonderful chicken recipe! I have made this several times. One night we were craving it and I didn't have any bottled spaghetti sauce. I poured a 14 ounce can of tomato sauce in a microwave bowl added 2t Italian Seasoning and 1t of garlic powder cooked on high for 6 minutes and ended up with a rich thick sauce for the chicken.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "MAGGIE MCGUIRE",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/392086\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2008-06-10T21:54:38.893Z",
|
||||
"reviewBody": "This is gonna be one of those it\u2019s a good recipe when you completely change it reviews. I did originally follow the recipe and the chicken tasted like it had been in breaded in cardboard. It just was not appetizing. However there is a great breaded chicken recipe on this site, garlic chicken. Made this simple and easy and oh so TASTY. I got great reviews. Here is what I did. Took \u00bc cup olive oil with 3 cloves garlic crushed and heated in microwave for 30 sec. Then coated the chicken in the oil and dipped in a mixture of \u00bd Italian seasoned bread crumbs and \u00bd parmesan cheese (double coat if u like thick breading). Cooked in oven at 325 for 20min (on a foil covered cookie sheet to make clean up easy). Set them in a casserole dish on top of about \u00bd a jar of spaghetti sauce for 3 chicken breast. Covered the breast with slices of mozzarella cheese and baked for another 20-25 minutes. Top with parmesan cheese. This turned out really really yummy and smells sooo good while it\u2019s cooking. ",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 4
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "ANGEL.9",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/218599\/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"datePublished": "2006-02-02T19:05:24.607Z",
|
||||
"reviewBody": "Check out \"Tomato Chicken Parmesan\" on this site for a truly fabulous chicken parm recipe. Every time I make that one people say its the best chicken parm they every had. No matter what kind you make though always pound your chicken breasts it will help immensely keeping the chicken tender and moist.",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"worstRating": "1",
|
||||
"bestRating": "5",
|
||||
"ratingValue": 3
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "MomSavedbyGrace",
|
||||
"image": null,
|
||||
"sameAs": "https:\/\/www.allrecipes.com\/cook\/1366670\/"
|
||||
}
|
||||
}
|
||||
],
|
||||
"video": {
|
||||
"@context": "http:\/\/schema.org",
|
||||
"@type": "VideoObject",
|
||||
"name": "Chicken Parmigiana",
|
||||
"description": "Make this quick and easy version of chicken Parmigiana.",
|
||||
"uploadDate": "2012-05-23T22:01:40.476Z",
|
||||
"duration": "PT2M18.43S",
|
||||
"thumbnailUrl": "https:\/\/imagesvc.meredithcorp.io\/v3\/mm\/image?url=https%3A%2F%2Fcf-images.us-east-1.prod.boltdns.net%2Fv1%2Fstatic%2F1033249144001%2F15c9e37d-979a-4c2c-a35d-fc3f436b0047%2F6b7f7749-9989-4707-971e-8578e60c0670%2F160x90%2Fmatch%2Fimage.jpg",
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Allrecipes",
|
||||
"url": "https:\/\/www.allrecipes.com",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https:\/\/www.allrecipes.com\/img\/logo.png",
|
||||
"width": 209,
|
||||
"height": 60
|
||||
},
|
||||
"sameAs": [
|
||||
"https:\/\/www.facebook.com\/allrecipes",
|
||||
"https:\/\/twitter.com\/Allrecipes",
|
||||
"https:\/\/www.pinterest.com\/allrecipes\/",
|
||||
"https:\/\/www.instagram.com\/allrecipes\/"
|
||||
]
|
||||
},
|
||||
"embedUrl": "https:\/\/players.brightcove.net\/1033249144001\/default_default\/index.html?videoId=1653498713001"
|
||||
},
|
||||
"keywords": "",
|
||||
"tool": [],
|
||||
"url": "https:\/\/www.allrecipes.com\/recipe\/8975\/chicken-parmigiana\/"
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"@context": "http:\/\/schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": "Skillet Shepherd's Pie",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Joanna Cismaru"
|
||||
},
|
||||
"description": "This Skillet Shepherd's Pie recipe, also known as cottage pie, is loaded with flavorful beef and veggies, topped with fluffy and creamy mashed potatoes, then baked to perfection!",
|
||||
"datePublished": "2019-03-16T20:15:47+00:00",
|
||||
"image": "https:\/\/www.jocooks.com\/wp-content\/uploads\/2016\/12\/skillet-shepherds-pie-1-2-480x270.jpg",
|
||||
"video": {
|
||||
"name": "Skillet Shepherd's Pie",
|
||||
"description": "This skillet shepherd\u2019s pie is loaded with flavorful beef and veggies then topped with fluffy and creamy mashed potatoes, then baked to perfection!",
|
||||
"thumbnailUrl": "https:\/\/content.jwplatform.com\/thumbs\/HGr48vds-720.jpg",
|
||||
"contentUrl": "https:\/\/content.jwplatform.com\/videos\/HGr48vds.mp4",
|
||||
"uploadDate": "2018-03-08T16:13:05.000Z",
|
||||
"@type": "VideoObject"
|
||||
},
|
||||
"recipeYield": 1,
|
||||
"prepTime": "PT0H15M",
|
||||
"cookTime": "PT1H10M",
|
||||
"totalTime": "PT1H25M",
|
||||
"recipeIngredient": [
|
||||
"1 tbsp olive oil",
|
||||
"1 1\/4 lb ground beef (lean)",
|
||||
"1\/2 tsp salt (or to taste)",
|
||||
"1\/2 tsp pepper (or to taste)",
|
||||
"1 large onion (chopped)",
|
||||
"1 clove garlic (minced)",
|
||||
"1\/2 tsp red pepper flakes",
|
||||
"2 tbsp Worcestershire sauce",
|
||||
"1.9 oz onion soup mix (I used Knorr, 55g pkg)",
|
||||
"1 cup beef broth (low sodium)",
|
||||
"2 cups frozen veggies (I used mix of peas, carrots, green beans and corn)",
|
||||
"6 large potatoes (peeled and cut into cubes)",
|
||||
"4 tbsp butter (softened)",
|
||||
"2\/3 cup milk",
|
||||
"1\/4 cup Parmesan cheese",
|
||||
"1\/2 tsp salt (or to taste)",
|
||||
"1\/2 tsp white pepper (or to taste)",
|
||||
"1 tbsp parsley (fresh, for garnish)"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
"Boil the potatoes: Start by first cooking the potatoes in boiling water for about 15 minutes or until fork tender. While the potatoes are cooking, you can prepare the meat mixture.",
|
||||
"Prepare the meat mixture: Heat the oil in a large skillet over medium heat. Add the ground beef to the skillet, season it with the salt and pepper and cook it for abut 5 minutes or until it's no longer pink, breaking it up as you go along.",
|
||||
"Add the onion and garlic and cook for 3 more minutes until the onion softens and becomes translucent. Add the pepper flakes, Worcestershire sauce, onion soup mix, beef broth and stir. Stir in the frozen veggies and cook for a couple more minutes. Set aside.",
|
||||
"Preheat the oven 350 F degrees.",
|
||||
"Prepare the mashed potatoes: Drain the potatoes then add them to a large bowl. Add in the butter and using a potato masher, mash until smooth. Add the milk, Parmesan cheese, salt pepper and mash a bit a more until smooth.",
|
||||
"Finish assembling the shepherd's pie: Spread the potatoes over the meat and smooth with a spoon. Take a fork and rough up the top a bit and garnish with a bit of parsley.",
|
||||
"Bake: Place the skillet on a baking sheet, then place it in the oven and bake for 40 minutes until golden brown on top.",
|
||||
"Garnish with more parsley and pepper and serve warm."
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.48",
|
||||
"ratingCount": "505"
|
||||
},
|
||||
"recipeCategory": "Main Course",
|
||||
"recipeCuisine": [
|
||||
"American"
|
||||
],
|
||||
"keywords": "cottage pie,shepherd's pie,skillet shepherd's pie",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "252 kcal",
|
||||
"carbohydrateContent": "14 g",
|
||||
"proteinContent": "19 g",
|
||||
"fatContent": "12 g",
|
||||
"saturatedFatContent": "6 g",
|
||||
"cholesterolContent": "63 mg",
|
||||
"sodiumContent": "1165 mg",
|
||||
"fiberContent": "2 g",
|
||||
"sugarContent": "2 g",
|
||||
"servingSize": "1 serving"
|
||||
},
|
||||
"@id": "https:\/\/www.jocooks.com\/recipes\/skillet-shepherds-pie\/#recipe",
|
||||
"isPartOf": {
|
||||
"@id": "https:\/\/www.jocooks.com\/recipes\/skillet-shepherds-pie\/#article"
|
||||
},
|
||||
"mainEntityOfPage": "https:\/\/www.jocooks.com\/recipes\/skillet-shepherds-pie\/#webpage",
|
||||
"url": "https:\/\/www.jocooks.com\/recipes\/skillet-shepherds-pie\/",
|
||||
"id": "4485",
|
||||
"dateCreated": "0",
|
||||
"dateModified": "1607461134",
|
||||
"printImage": "false",
|
||||
"imageUrl": "\/nextcloud\/index.php\/apps\/cookbook\/recipes\/4485\/image?size=full",
|
||||
"tool": []
|
||||
}
|
||||
41
mealie/tests/test_services/test_migrations/test_nextcloud.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from pathlib import Path
|
||||
from app_config import TEMP_DIR
|
||||
import pytest
|
||||
from app_config import TEMP_DIR
|
||||
from services.image_services import IMG_DIR
|
||||
from services.migrations.nextcloud import (cleanup, import_recipes, prep,
|
||||
process_selection)
|
||||
from services.recipe_services import Recipe
|
||||
from tests.test_config import TEST_NEXTCLOUD_DIR
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
TEST_NEXTCLOUD_DIR
|
||||
TEMP_NEXTCLOUD = TEMP_DIR.joinpath("nextcloud")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_name,final_path",
|
||||
[("nextcloud.zip", TEMP_NEXTCLOUD)],
|
||||
)
|
||||
def test_zip_extraction(file_name: str, final_path: Path):
|
||||
prep()
|
||||
zip = TEST_NEXTCLOUD_DIR.joinpath(file_name)
|
||||
dir = process_selection(zip)
|
||||
|
||||
assert dir == final_path
|
||||
cleanup()
|
||||
assert dir.exists() == False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"recipe_dir",
|
||||
[
|
||||
TEST_NEXTCLOUD_DIR.joinpath("Air Fryer Shrimp"),
|
||||
TEST_NEXTCLOUD_DIR.joinpath("Chicken Parmigiana"),
|
||||
TEST_NEXTCLOUD_DIR.joinpath("Skillet Shepherd's Pie"),
|
||||
],
|
||||
)
|
||||
def test_nextcloud_migration(recipe_dir: Path):
|
||||
recipe = import_recipes(recipe_dir)
|
||||
assert isinstance(recipe, Recipe)
|
||||
IMG_DIR.joinpath(recipe.image).unlink(missing_ok=True)
|
||||
BIN
mealie/tests/test_services/test_scraper/data/nextcloud.zip
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Recipe",
|
||||
"image": "https://img.chefkoch-cdn.de/rezepte/2235331358009600/bilder/864648/crop-960x540/pizza-knoblauch-champignon-paprika-vegan.jpg",
|
||||
"recipeCategory": "Gem\u00fcse",
|
||||
"recipeIngredient": [
|
||||
"300 g Weizenmehl (Type 550)",
|
||||
"1 Pck. Trockenhefe",
|
||||
"1 TL Salz",
|
||||
"170 ml Wasser",
|
||||
"6 EL \u00d6l (Knoblauch\u00f6l oder Oliven\u00f6l)",
|
||||
"1 EL Tomatenmark",
|
||||
"n. B. Knoblauch , gew\u00fcrfelt",
|
||||
"2 Spitzpaprika oder Gem\u00fcsepaprika, rot",
|
||||
"250 g Champignons",
|
||||
"1 Zwiebel(n)",
|
||||
" Salz und Pfeffer",
|
||||
" Kr\u00e4uter , italienische, frisch oder getrocknet",
|
||||
" Harissa"
|
||||
],
|
||||
"name": "Pizza Knoblauch Champignon Paprika - vegan",
|
||||
"description": "Pizza Knoblauch Champignon Paprika - vegan - f\u00fcr Nicht-Veganer nat\u00fcrlich mit K\u00e4se zu belegen. \u00dcber 51 Bewertungen und f\u00fcr raffiniert befunden. Mit \u25ba Portionsrechner \u25ba Kochbuch \u25ba Video-Tipps!",
|
||||
"recipeInstructions": "Die Zutaten f\u00fcr den Teig verkneten und ca. 40 Minuten an einem warmen Ort gehen lassen. In der Zwischenzeit eine beliebige Anzahl Knoblauchzehen fein w\u00fcrfeln (ich bedecke die Pizza nahezu fl\u00e4chendeckend), die Zwiebel ebenfalls w\u00fcrfeln, Paprika und Champignons klein schneiden. Das \u00d6l mit Tomatenmark, Salz und Pfeffer vermischen. \r\n\r\nDen fertigen Teig ausrollen und auf ein Blech legen (ich benutze eine Pflaumenkuchen-Backform). Die \u00d6lmischung mit einem Backpinsel gleichm\u00e4\u00dfig auf dem Teig verteilen, danach mit dem Knoblauch, den Champignons, der Paprika und den Zwiebeln belegen. \r\n\r\nNun die Pizza mit Salz, Pfeffer, Kr\u00e4utern und Harissa kr\u00e4ftig w\u00fcrzen und bei 250\u00b0C ca. 10 - 15 Minuten backen. Der Teig ist als Grundteig zu betrachten und l\u00e4sst sich nat\u00fcrlich mit allem M\u00f6glichen an Gem\u00fcse belegen.",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "healing21"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Chefkoch.de"
|
||||
},
|
||||
"datePublished": "2013-01-14",
|
||||
"prepTime": "P0DT0H20M",
|
||||
"cookTime": "P0DT0H15M",
|
||||
"totalTime": "P0DT1H15M",
|
||||
"recipeYield": "2 Portion(en)",
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingCount": 51,
|
||||
"ratingValue": 4.57,
|
||||
"reviewCount": 34,
|
||||
"worstRating": 0,
|
||||
"bestRating": 5
|
||||
},
|
||||
"keywords": [
|
||||
"Gem\u00fcse",
|
||||
"Hauptspeise",
|
||||
"Backen",
|
||||
"Vegetarisch",
|
||||
"einfach",
|
||||
"Vegan",
|
||||
"Pizza",
|
||||
"Pilze"
|
||||
],
|
||||
"reviews": [
|
||||
{
|
||||
"@type": "Review",
|
||||
"reviewBody": " Sehr gutes Basis Rezept!\n\nHab noch Salami, Kochschinken und K\u00e4se dazu gemacht, sonst schmeckt es ja nach nichts. \n\nErgebnis: 1. Klasse! Sehr fein! ",
|
||||
"datePublished": "2020-04-21",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "eierkopp1824"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"reviewBody": "Hallo,\r\nhabe den Teig gut zwei Stunden gehen lassen und dann wie im Rezept angegeben weiter verarbeitet. Da ich noch einige Schinkenw\u00fcrfel und etwas Fetak\u00e4se im K\u00fchlschrank hatte, wurden diese ebenfalls auf dem Belag verteilt. Ich habe die Pizza auf der untersten Schiene im Backofen gebacken. Der Boden ist nach dem Backen sch\u00f6n knusprig. Es hat mir und meinem Mitesser sehr gut geschmeckt.\r\nLG von Sternek\u00f6chin2011",
|
||||
"datePublished": "2020-03-10",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Sternek\u00f6chin2011"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"reviewBody": "Echt f\u00fcr mich die leckerste Pizza auf der Welt! Auch bei meiner Familie kommt sie super an und \u00fcberlebt nicht lange. :)\nDen Belag kann man ja variieren wie man will. ",
|
||||
"datePublished": "2020-02-20",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Leo090800"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"reviewBody": "Beste Pizza, die ich je gegessen habe! Sooo lecker! Habe f\u00fcr den Teig Dinkelvollkornmehl genommen und den Belag noch mit ein paar Chiliflocken verfeinert. ",
|
||||
"datePublished": "2018-04-15",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Sunny_Eyes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Review",
|
||||
"reviewBody": "Der Teig ist super, ebenso wie die Sauce! Habe anstelle von normalem Salz Basilikumsalz in den Teig gegeben, das gibt dem ganzen einen besonderen Geschmack.Statt Paprika und Knobi habe ich K\u00e4se und Rucola hinzuef\u00fcgt, den Salat erst nach dem Backen. Die wird sicherlich nochmal gemacht! Da ich nur eine Pizza gemacht habe, habe ich den restlichen Teig eingefroren. Foto ist unterwegs!",
|
||||
"datePublished": "2018-02-14",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Chiqryn"
|
||||
}
|
||||
}
|
||||
],
|
||||
"url": "https://www.chefkoch.de/rezepte/2235331358009600/Pizza-Knoblauch-Champignon-Paprika-vegan.html"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"@context": "http://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "The Best Homemade Salsa Recipe",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Sommer Collier"
|
||||
},
|
||||
"description": "How To Make Delicious Salsa: Secrets of making the Best Homemade Salsa Recipe! This restaurant style salsa recipe is loaded with flavor, has an amazing texture, and a secret ingredient.",
|
||||
"datePublished": "2020-02-01T00:00:30+00:00",
|
||||
"image": [
|
||||
"https://www.aspicyperspective.com/wp-content/uploads/2019/02/the-best-homemade-salsa-recipe-100.jpg",
|
||||
"https://www.aspicyperspective.com/wp-content/uploads/2019/02/the-best-homemade-salsa-recipe-100-500x500.jpg",
|
||||
"https://www.aspicyperspective.com/wp-content/uploads/2019/02/the-best-homemade-salsa-recipe-100-500x375.jpg",
|
||||
"https://www.aspicyperspective.com/wp-content/uploads/2019/02/the-best-homemade-salsa-recipe-100-480x270.jpg"
|
||||
],
|
||||
"video": {
|
||||
"name": "The Best Homemade Salsa Recipe",
|
||||
"description": "We\u2019re sharing our secrets for making The Best Homemade Salsa Recipe we\u2019ve ever tried. Healthy, fresh, and easy to adjust!",
|
||||
"thumbnailUrl": "https://content.jwplatform.com/thumbs/rPi8NdK6-720.jpg",
|
||||
"contentUrl": "https://content.jwplatform.com/videos/rPi8NdK6.mp4",
|
||||
"uploadDate": "2017-03-22T16:24:09.000Z",
|
||||
"@type": "VideoObject"
|
||||
},
|
||||
"recipeYield": [
|
||||
"20",
|
||||
"20 (5 cups)"
|
||||
],
|
||||
"prepTime": "PT5M",
|
||||
"totalTime": "PT5M",
|
||||
"recipeIngredient": [
|
||||
"4 ripe tomatoes, (cored and quartered)",
|
||||
"1 red onion, (peeled and quartered)",
|
||||
"3 garlic cloves, (peeled)",
|
||||
"3 jalapenos, (stemmed and seeded (you can\u00a0substitute 1-2 habanero or serrano peppers.))",
|
||||
"1/3 cup fresh cilantro",
|
||||
"3 tablespoons fresh lime juice",
|
||||
"2-3 teaspoons ground cumin",
|
||||
"2-3 teaspoons sugar ((optional))",
|
||||
"1 1/2 teaspoons salt",
|
||||
"15 ounces crushed San Marzano tomatoes ((1 can))",
|
||||
"4.5 ounces diced green chiles, (mild, medium, or hot (1 can))"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Place the fresh tomatoes, onion, garlic, peppers, cilantro, lime juice, 2 teaspoons cumin, 2 teaspoons sugar (if using), and salt in a food processor. Pulse until the contents are fine and well blended.",
|
||||
"name": "Place the fresh tomatoes, onion, garlic, peppers, cilantro, lime juice, 2 teaspoons cumin, 2 teaspoons sugar (if using), and salt in a food processor. Pulse until the contents are fine and well blended.",
|
||||
"url": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/#wprm-recipe-61842-step-0-0"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Pour in the crushed tomatoes and green chiles. Puree until mostly smooth. Taste, then add more cumin and sugar if desired. Refrigerate until ready to serve.",
|
||||
"name": "Pour in the crushed tomatoes and green chiles. Puree until mostly smooth. Taste, then add more cumin and sugar if desired. Refrigerate until ready to serve.",
|
||||
"url": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/#wprm-recipe-61842-step-0-1"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.98",
|
||||
"ratingCount": "201"
|
||||
},
|
||||
"recipeCategory": [
|
||||
"Appetizer",
|
||||
"Snack"
|
||||
],
|
||||
"recipeCuisine": [
|
||||
"American",
|
||||
"Mexican"
|
||||
],
|
||||
"keywords": "Homemade Salsa, Salsa, The Best Salsa Recipe",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"servingSize": "0.25 cup",
|
||||
"calories": "19 kcal",
|
||||
"carbohydrateContent": "4 g",
|
||||
"sodiumContent": "230 mg",
|
||||
"sugarContent": "2 g"
|
||||
},
|
||||
"@id": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/#recipe",
|
||||
"isPartOf": {
|
||||
"@id": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/#article"
|
||||
},
|
||||
"mainEntityOfPage": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/#webpage",
|
||||
"url": "https://www.aspicyperspective.com/best-homemade-salsa-recipe/"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"image": "https://www.cookingforkeeps.com/wp-content/uploads/2013/02/done-1.jpg",
|
||||
"aggregateRating": {
|
||||
"properties": {
|
||||
"ratingValue": "4.0",
|
||||
"ratingCount": "2"
|
||||
},
|
||||
"@type": "AggregateRating"
|
||||
},
|
||||
"name": "Blue Cheese Stuffed Turkey Meatballs with Raspberry Balsamic Glaze",
|
||||
"author": "Nicole-Cooking for Keeps",
|
||||
"recipeYield": "Makes 18-22 meatballs depending on size",
|
||||
"recipeInstructions": [
|
||||
"For the meatballs: Roll the blue cheese in small balls about the diameter of a dime. Freeze for 30 minutes. Preheat oven to 375 degrees. Mix the remaining ingredients together, until just combined. Roll sausage mixture into small balls. Place the blue cheese in the middle, enclosing with meat. Bake on a silt pad until golden brown and cooked through, about 25 min, turning halfway through to ensure even browning.",
|
||||
"For the Dipping Sauce:",
|
||||
"Combine all ingredients together in small sauce pan over medium high heat. Bring to a boil and then reduce heat and simmer about five minutes. Coat meatballs in sauce. Serve."
|
||||
],
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Recipe",
|
||||
"url": "https://www.cookingforkeeps.com/blue-cheese-stuffed-turkey-meatballs-with-raspberry-balsamic-glaze-2/",
|
||||
"recipeIngredient": [
|
||||
"Sausage Bites",
|
||||
"3 oz creamy gorgonzola cheese",
|
||||
"1 lb turkey Italian sausage (schmicas) with fennel seed",
|
||||
"\u00bd cup Italian style bread crumbs",
|
||||
"\u00bd onion grated",
|
||||
"1 egg white",
|
||||
"Salt to taste",
|
||||
"Dipping Sauce:",
|
||||
"\u00bd cup raspberry preserves",
|
||||
"\u215b cup balsamic vinegar",
|
||||
"3 teaspoons Dijon mustard",
|
||||
"Pinch of red pepper",
|
||||
"Pinch of Salt"
|
||||
]
|
||||
}
|
||||