@@ -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 @@
-
+
{{ $globals.icons.edit }}
{{ $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 }}
+
+
+
+
+
+
+
@@ -106,13 +126,27 @@
{{ $t("general.new") }}
+
+
+
+ {{ $globals.icons.zip }}
+
+
+ {{ $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