From f5faff66d3bd2460ba3aa455d2964ed68977b3a3 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Mon, 21 Jun 2021 16:25:37 -0700 Subject: [PATCH] Feature/import export single recipe (#576) * remove duplicate keys * show context menu when not logged in * remove console.log * hide menu when printing * add response to event * add type definitions * show context menu always * add image name enums * upload/download single recipe * cleanup menu views+ localization Co-authored-by: hay-kot --- frontend/src/api/upload.js | 2 - .../src/components/Recipe/ContextMenu.vue | 45 ++++++++---- .../Recipe/RecipePageActionMenu.vue | 16 ++++- .../components/UI/Buttons/TheUploadBtn.vue | 6 +- .../src/components/UI/Dialogs/BaseDialog.vue | 13 ++-- frontend/src/components/UI/TheRecipeFab.vue | 68 +++++++++++++++++-- frontend/src/locales/messages/en-US.json | 3 +- frontend/src/pages/Recipe/NewRecipe.vue | 8 ++- frontend/src/pages/Recipe/ViewRecipe.vue | 2 +- frontend/src/routes/index.js | 2 +- frontend/src/utils/globals.js | 2 + mealie/routes/deps.py | 12 +++- mealie/routes/recipe/recipe_crud_routes.py | 56 ++++++++++++++- mealie/schema/recipe.py | 7 ++ 14 files changed, 207 insertions(+), 35 deletions(-) diff --git a/frontend/src/api/upload.js b/frontend/src/api/upload.js index 3bd9a02be725..efd9ada3e1fc 100644 --- a/frontend/src/api/upload.js +++ b/frontend/src/api/upload.js @@ -4,8 +4,6 @@ import i18n from "@/i18n.js"; export const utilsAPI = { // import { api } from "@/api"; uploadFile(url, fileObject) { - console.log("API Called"); - return apiReq.post( url, fileObject, diff --git a/frontend/src/components/Recipe/ContextMenu.vue b/frontend/src/components/Recipe/ContextMenu.vue index 5f73d5cff7b3..ccc6e53de8ed 100644 --- a/frontend/src/components/Recipe/ContextMenu.vue +++ b/frontend/src/components/Recipe/ContextMenu.vue @@ -18,6 +18,7 @@ allow-overflow close-delay="125" open-on-hover + content-class="d-print-none" > - + @@ -53,6 +50,10 @@ export default { type: Boolean, default: true, }, + showPrint: { + type: Boolean, + default: false, + }, fab: { type: Boolean, default: false, @@ -88,20 +89,28 @@ export default { recipeURL() { return `${this.baseURL}/recipe/${this.slug}`; }, + printerMenu() { + return { + title: this.$t("general.print"), + icon: this.$globals.icons.printer, + color: "accent", + action: "print", + }; + }, defaultMenu() { return [ - { - title: this.$t("general.print"), - icon: this.$globals.icons.printer, - color: "accent", - action: "print", - }, { title: this.$t("general.share"), icon: this.$globals.icons.shareVariant, color: "accent", action: "share", }, + { + title: this.$t("general.download"), + icon: this.$globals.icons.download, + color: "accent", + action: "download", + }, ]; }, userMenu() { @@ -118,9 +127,18 @@ export default { color: "accent", action: "edit", }, - ...this.defaultMenu, ]; }, + displayedMenu() { + let menu = this.defaultMenu; + if (this.loggedIn && this.cardMenu) { + menu = [...this.userMenu, ...menu]; + } + if (this.showPrint) { + menu = [this.printerMenu, ...menu]; + } + return menu; + }, recipeText() { return this.$t("recipe.share-recipe-message", [this.name]); }, @@ -159,6 +177,9 @@ export default { case "print": this.$router.push(`/recipe/${this.slug}` + "?print=true"); break; + case "download": + window.open(`/api/recipes/${this.slug}/zip`); + break; default: break; } diff --git a/frontend/src/components/Recipe/RecipePageActionMenu.vue b/frontend/src/components/Recipe/RecipePageActionMenu.vue index 903b864a0ed7..92f68f84ed7d 100644 --- a/frontend/src/components/Recipe/RecipePageActionMenu.vue +++ b/frontend/src/components/Recipe/RecipePageActionMenu.vue @@ -20,13 +20,23 @@
{{ $t("general.edit") }} diff --git a/frontend/src/components/UI/TheRecipeFab.vue b/frontend/src/components/UI/TheRecipeFab.vue index 1b12c5eec156..05e603df773e 100644 --- a/frontend/src/components/UI/TheRecipeFab.vue +++ b/frontend/src/components/UI/TheRecipeFab.vue @@ -84,6 +84,26 @@ + + + {{ $t("new-recipe.upload-individual-zip-file") }} + +
+ {{ this.fileName }} +
+
+ + + + +
{{ $t("general.new") }} + + + {{ $t("general.upload") }} +
diff --git a/frontend/src/locales/messages/en-US.json b/frontend/src/locales/messages/en-US.json index 7ce2e0e1533f..a23eda6c0109 100644 --- a/frontend/src/locales/messages/en-US.json +++ b/frontend/src/locales/messages/en-US.json @@ -37,9 +37,7 @@ "apprise-url": "Apprise URL", "database": "Database", "new-notification-form-description": "Mealie uses the Apprise library to generate notifications. They offer many options for services to use for notifications. Refer to their wiki for a comprehensive guide on how to create the URL for your service. If available, selecting the type of your notification may include extra features.", - "new-version": "New version available!", "notification": "Notification", - "refresh": "Refresh", "scheduled": "Scheduled", "something-went-wrong": "Something Went Wrong!", "subscribed-events": "Subscribed Events", @@ -191,6 +189,7 @@ "from-url": "Import a Recipe", "paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list", "recipe-url": "Recipe URL", + "upload-individual-zip-file": "Upload an individual .zip file exported from another Mealie instance.", "url-form-hint": "Copy and paste a link from your favorite recipe website" }, "page": { diff --git a/frontend/src/pages/Recipe/NewRecipe.vue b/frontend/src/pages/Recipe/NewRecipe.vue index f26881e39230..a8f890adc9ce 100644 --- a/frontend/src/pages/Recipe/NewRecipe.vue +++ b/frontend/src/pages/Recipe/NewRecipe.vue @@ -10,7 +10,13 @@
- +
diff --git a/frontend/src/pages/Recipe/ViewRecipe.vue b/frontend/src/pages/Recipe/ViewRecipe.vue index fa0842cc4209..73854b2ba51c 100644 --- a/frontend/src/pages/Recipe/ViewRecipe.vue +++ b/frontend/src/pages/Recipe/ViewRecipe.vue @@ -24,7 +24,7 @@ :slug="recipeDetails.slug" :name="recipeDetails.name" v-model="form" - v-if="loggedIn" + :logged-in="loggedIn" :open="showIcons" @close="form = false" @json="jsonEditor = !jsonEditor" diff --git a/frontend/src/routes/index.js b/frontend/src/routes/index.js index 54d6e34da633..9ac473e420b3 100644 --- a/frontend/src/routes/index.js +++ b/frontend/src/routes/index.js @@ -6,7 +6,7 @@ import { mealRoutes } from "./meal"; import { generalRoutes } from "./general"; import { store } from "@/store"; import VueRouter from "vue-router"; -import { loadLanguageAsync } from "@/i18n" +import { loadLanguageAsync } from "@/i18n"; import Vue from "vue"; import i18n from "@/i18n.js"; diff --git a/frontend/src/utils/globals.js b/frontend/src/utils/globals.js index 8e882af2c8d6..2152a9e7b6d9 100644 --- a/frontend/src/utils/globals.js +++ b/frontend/src/utils/globals.js @@ -91,6 +91,7 @@ import { mdiArrowLeftBold, mdiMinus, mdiWindowClose, + mdiFolderZipOutline, } from "@mdi/js"; const icons = { @@ -176,6 +177,7 @@ const icons = { weatherSunny: mdiWeatherSunny, webhook: mdiWebhook, windowClose: mdiWindowClose, + zip: mdiFolderZipOutline, // Crud createAlt: mdiPlus, diff --git a/mealie/routes/deps.py b/mealie/routes/deps.py index 333f9cac443d..0999f954779a 100644 --- a/mealie/routes/deps.py +++ b/mealie/routes/deps.py @@ -4,7 +4,7 @@ from typing import Optional from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt -from mealie.core.config import settings +from mealie.core.config import app_dirs, settings from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.schema.auth import TokenData @@ -100,3 +100,13 @@ def validate_file_token(token: Optional[str] = None) -> Path: raise credentials_exception return file_path + + +async def temporary_zip_path() -> Path: + temp_path = app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True) + temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip") + + try: + yield temp_path + finally: + temp_path.unlink(missing_ok=True) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index ccfd983a4503..742392bd557f 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -1,4 +1,7 @@ +import json +import shutil from shutil import copyfileobj +from zipfile import ZipFile from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, status from fastapi.datastructures import UploadFile @@ -6,8 +9,8 @@ from mealie.core.config import settings from mealie.core.root_logger import get_logger from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user, is_logged_in -from mealie.schema.recipe import Recipe, RecipeAsset, RecipeURLIn +from mealie.routes.deps import get_current_user, is_logged_in, temporary_zip_path +from mealie.schema.recipe import Recipe, RecipeAsset, RecipeImageTypes, RecipeURLIn from mealie.schema.user import UserInDB from mealie.services.events import create_recipe_event from mealie.services.image.image import scrape_image, write_image @@ -16,6 +19,7 @@ from mealie.services.scraper.scraper import create_from_url from scrape_schema_recipe import scrape_url from slugify import slugify from sqlalchemy.orm.session import Session +from starlette.responses import FileResponse router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"]) logger = get_logger() @@ -87,6 +91,54 @@ def get_recipe(recipe_slug: str, session: Session = Depends(generate_session), i raise HTTPException(status.HTTP_401_UNAUTHORIZED, {"details": "unauthorized"}) +@router.post("/create-from-zip", dependencies=[Depends(get_current_user)]) +async def create_recipe_from_zip( + session: Session = Depends(generate_session), + temp_path=Depends(temporary_zip_path), + archive: UploadFile = File(...), +): + """ Create recipe from archive """ + + with temp_path.open("wb") as buffer: + shutil.copyfileobj(archive.file, buffer) + + recipe_dict = None + recipe_image = None + + with ZipFile(temp_path) as myzip: + for file in myzip.namelist(): + if file.endswith(".json"): + with myzip.open(file) as myfile: + recipe_dict = json.loads(myfile.read()) + elif file.endswith(".webp"): + with myzip.open(file) as myfile: + recipe_image = myfile.read() + + recipe: Recipe = db.recipes.create(session, Recipe(**recipe_dict)) + + write_image(recipe.slug, recipe_image, "webp") + + return recipe + + +@router.get("/{recipe_slug}/zip") +async def get_recipe_as_zip( + recipe_slug: str, session: Session = Depends(generate_session), temp_path=Depends(temporary_zip_path) +): + """ Get a Recipe and It's Original Image as a Zip File """ + recipe: Recipe = db.recipes.get(session, recipe_slug) + + image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value) + + with ZipFile(temp_path, "w") as myzip: + myzip.writestr(f"{recipe_slug}.json", recipe.json()) + + if image_asset.is_file(): + myzip.write(image_asset, arcname=image_asset.name) + + return FileResponse(temp_path, filename=f"{recipe_slug}.zip") + + @router.put("/{recipe_slug}", dependencies=[Depends(get_current_user)]) def update_recipe( recipe_slug: str, diff --git a/mealie/schema/recipe.py b/mealie/schema/recipe.py index 0dd453e7c82a..4b04db092716 100644 --- a/mealie/schema/recipe.py +++ b/mealie/schema/recipe.py @@ -1,4 +1,5 @@ import datetime +from enum import Enum from pathlib import Path from typing import Any, Optional @@ -11,6 +12,12 @@ from pydantic.utils import GetterDict from slugify import slugify +class RecipeImageTypes(str, Enum): + original = "original.webp" + min = "min-original.webp" + tiny = "tiny-original.webp" + + class RecipeSettings(CamelModel): public: bool = True show_nutrition: bool = True