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 <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-06-21 16:25:37 -07:00 committed by GitHub
parent 3220595a83
commit f5faff66d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 207 additions and 35 deletions

View File

@ -4,8 +4,6 @@ import i18n from "@/i18n.js";
export const utilsAPI = { export const utilsAPI = {
// import { api } from "@/api"; // import { api } from "@/api";
uploadFile(url, fileObject) { uploadFile(url, fileObject) {
console.log("API Called");
return apiReq.post( return apiReq.post(
url, url,
fileObject, fileObject,

View File

@ -18,6 +18,7 @@
allow-overflow allow-overflow
close-delay="125" close-delay="125"
open-on-hover open-on-hover
content-class="d-print-none"
> >
<template v-slot:activator="{ on, attrs }"> <template v-slot:activator="{ on, attrs }">
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent> <v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
@ -25,11 +26,7 @@
</v-btn> </v-btn>
</template> </template>
<v-list dense> <v-list dense>
<v-list-item <v-list-item v-for="(item, index) in displayedMenu" :key="index" @click="menuAction(item.action)">
v-for="(item, index) in loggedIn && cardMenu ? userMenu : defaultMenu"
:key="index"
@click="menuAction(item.action)"
>
<v-list-item-icon> <v-list-item-icon>
<v-icon v-text="item.icon" :color="item.color"></v-icon> <v-icon v-text="item.icon" :color="item.color"></v-icon>
</v-list-item-icon> </v-list-item-icon>
@ -53,6 +50,10 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showPrint: {
type: Boolean,
default: false,
},
fab: { fab: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -88,20 +89,28 @@ export default {
recipeURL() { recipeURL() {
return `${this.baseURL}/recipe/${this.slug}`; return `${this.baseURL}/recipe/${this.slug}`;
}, },
printerMenu() {
return {
title: this.$t("general.print"),
icon: this.$globals.icons.printer,
color: "accent",
action: "print",
};
},
defaultMenu() { defaultMenu() {
return [ return [
{
title: this.$t("general.print"),
icon: this.$globals.icons.printer,
color: "accent",
action: "print",
},
{ {
title: this.$t("general.share"), title: this.$t("general.share"),
icon: this.$globals.icons.shareVariant, icon: this.$globals.icons.shareVariant,
color: "accent", color: "accent",
action: "share", action: "share",
}, },
{
title: this.$t("general.download"),
icon: this.$globals.icons.download,
color: "accent",
action: "download",
},
]; ];
}, },
userMenu() { userMenu() {
@ -118,9 +127,18 @@ export default {
color: "accent", color: "accent",
action: "edit", 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() { recipeText() {
return this.$t("recipe.share-recipe-message", [this.name]); return this.$t("recipe.share-recipe-message", [this.name]);
}, },
@ -159,6 +177,9 @@ export default {
case "print": case "print":
this.$router.push(`/recipe/${this.slug}` + "?print=true"); this.$router.push(`/recipe/${this.slug}` + "?print=true");
break; break;
case "download":
window.open(`/api/recipes/${this.slug}/zip`);
break;
default: default:
break; break;
} }

View File

@ -20,13 +20,23 @@
<div v-if="!value" class="custom-btn-group ma-1"> <div v-if="!value" class="custom-btn-group ma-1">
<v-tooltip bottom color="info"> <v-tooltip bottom color="info">
<template v-slot:activator="{ on, attrs }"> <template v-slot:activator="{ on, attrs }">
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('input', true)"> <v-btn
v-if="loggedIn"
fab
small
class="mx-1"
color="info"
v-bind="attrs"
v-on="on"
@click="$emit('input', true)"
>
<v-icon> {{ $globals.icons.edit }} </v-icon> <v-icon> {{ $globals.icons.edit }} </v-icon>
</v-btn> </v-btn>
</template> </template>
<span>{{ $t("general.edit") }}</span> <span>{{ $t("general.edit") }}</span>
</v-tooltip> </v-tooltip>
<ContextMenu <ContextMenu
show-print
:menu-top="false" :menu-top="false"
:slug="slug" :slug="slug"
:name="name" :name="name"
@ -74,6 +84,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
loggedIn: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {

View File

@ -58,8 +58,10 @@ export default {
let formData = new FormData(); let formData = new FormData();
formData.append(this.fileName, this.file); formData.append(this.fileName, this.file);
if (await api.utils.uploadFile(this.url, formData)) { const response = await api.utils.uploadFile(this.url, formData);
this.$emit(UPLOAD_EVENT);
if (response) {
this.$emit(UPLOAD_EVENT, response);
} }
this.isSelecting = false; this.isSelecting = false;
} }

View File

@ -45,7 +45,7 @@
</template> </template>
<script> <script>
import i18n from "@/i18n"; import i18n from "@/i18n.js";
export default { export default {
props: { props: {
color: { color: {
@ -65,12 +65,14 @@ export default {
}, },
top: { top: {
default: null, default: null,
type: Boolean,
}, },
submitText: { submitText: {
default: () => i18n.t("general.create"), default: () => i18n.t("general.create"),
}, },
keepOpen: { keepOpen: {
default: false, default: false,
type: Boolean,
}, },
}, },
data() { data() {
@ -101,16 +103,17 @@ export default {
this.$emit("submit"); this.$emit("submit");
this.submitted = true; this.submitted = true;
}, },
deleteEvent() {
this.$emit("delete");
this.submitted = true;
},
open() { open() {
console.log("Open Dialog");
this.dialog = true; this.dialog = true;
}, },
close() { close() {
this.dialog = false; this.dialog = false;
}, },
deleteEvent() {
this.$emit("delete");
this.submitted = true;
},
}, },
}; };
</script> </script>

View File

@ -84,6 +84,26 @@
</v-form> </v-form>
</v-card> </v-card>
</v-dialog> </v-dialog>
<BaseDialog
title="Upload a Recipe"
:titleIcon="$globals.icons.zip"
:submit-text="$t('general.import')"
ref="uploadZipDialog"
@submit="uploadZip"
:loading="processing"
>
<v-card-text class="mt-1 pb-0">
{{ $t("new-recipe.upload-individual-zip-file") }}
<div class="headline mx-auto mb-0 pb-0 text-center">
{{ this.fileName }}
</div>
</v-card-text>
<v-card-actions>
<TheUploadBtn class="mx-auto" :text-btn="false" @uploaded="setFile" :post="false"> </TheUploadBtn>
</v-card-actions>
</BaseDialog>
<v-speed-dial v-model="fab" :open-on-hover="absolute" :fixed="absolute" :bottom="absolute" :right="absolute"> <v-speed-dial v-model="fab" :open-on-hover="absolute" :fixed="absolute" :bottom="absolute" :right="absolute">
<template v-slot:activator> <template v-slot:activator>
<v-btn v-model="fab" :color="absolute ? 'accent' : 'white'" dark :icon="!absolute" :fab="absolute"> <v-btn v-model="fab" :color="absolute ? 'accent' : 'white'" dark :icon="!absolute" :fab="absolute">
@ -106,13 +126,27 @@
</template> </template>
<span>{{ $t("general.new") }}</span> <span>{{ $t("general.new") }}</span>
</v-tooltip> </v-tooltip>
<v-tooltip left dark color="info">
<template v-slot:activator="{ on, attrs }">
<v-btn fab dark small color="info" v-bind="attrs" v-on="on" @click="openZipUploader">
<v-icon>{{ $globals.icons.zip }}</v-icon>
</v-btn>
</template>
<span>{{ $t("general.upload") }}</span>
</v-tooltip>
</v-speed-dial> </v-speed-dial>
</div> </div>
</template> </template>
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn.vue";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog.vue";
export default { export default {
components: {
TheUploadBtn,
BaseDialog,
},
props: { props: {
absolute: { absolute: {
default: false, default: false,
@ -124,6 +158,10 @@ export default {
fab: false, fab: false,
addRecipe: false, addRecipe: false,
processing: false, processing: false,
uploadData: {
fileName: "archive",
file: null,
},
}; };
}, },
@ -143,9 +181,34 @@ export default {
return this.$route.query.recipe_import_url || ""; return this.$route.query.recipe_import_url || "";
}, },
}, },
fileName() {
return this.uploadData.file?.name || "";
},
}, },
methods: { methods: {
resetVars() {
this.uploadData = {
fileName: "archive",
file: null,
};
},
setFile(file) {
this.uploadData.file = file;
console.log("Uploaded");
},
openZipUploader() {
this.resetVars();
this.$refs.uploadZipDialog.open();
},
async uploadZip() {
let formData = new FormData();
formData.append(this.uploadData.fileName, this.uploadData.file);
const response = await api.utils.uploadFile("/api/recipes/create-from-zip", formData);
this.$router.push(`/recipe/${response.data.slug}`);
},
async createRecipe() { async createRecipe() {
this.error = false; this.error = false;
if (this.$refs.urlForm === undefined || this.$refs.urlForm.validate()) { if (this.$refs.urlForm === undefined || this.$refs.urlForm.validate()) {
@ -161,7 +224,6 @@ export default {
} }
} }
}, },
reset() { reset() {
this.fab = false; this.fab = false;
this.error = false; this.error = false;
@ -173,10 +235,6 @@ export default {
let regEx = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,256}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/gm; let regEx = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,256}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/gm;
return regEx.test(url) ? true : "Must be a Valid URL"; return regEx.test(url) ? true : "Must be a Valid URL";
}, },
bookmark() {
return `javascript:(function()%7Bvar url %3D document.URL %3B%0Avar mealie %3D "http%3A%2F%2Flocalhost%3A8080%2F%23"%0Avar dest %3D mealie %2B "%2F%3Frecipe_import_url%3D" %2B url%0Awindow.open(dest%2C '_blank')%7D)()%3B`;
},
}, },
}; };
</script> </script>

View File

@ -37,9 +37,7 @@
"apprise-url": "Apprise URL", "apprise-url": "Apprise URL",
"database": "Database", "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-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", "notification": "Notification",
"refresh": "Refresh",
"scheduled": "Scheduled", "scheduled": "Scheduled",
"something-went-wrong": "Something Went Wrong!", "something-went-wrong": "Something Went Wrong!",
"subscribed-events": "Subscribed Events", "subscribed-events": "Subscribed Events",
@ -191,6 +189,7 @@
"from-url": "Import a Recipe", "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", "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", "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" "url-form-hint": "Copy and paste a link from your favorite recipe website"
}, },
"page": { "page": {

View File

@ -10,7 +10,13 @@
</v-img> </v-img>
<br v-else /> <br v-else />
<RecipePageActionMenu :value="true" @json="jsonEditor = true" @edit="jsonEditor = false" @save="createRecipe" /> <RecipePageActionMenu
logged-in
:value="true"
@json="jsonEditor = true"
@edit="jsonEditor = false"
@save="createRecipe"
/>
<div v-if="jsonEditor"> <div v-if="jsonEditor">
<!-- Probably not the best way, but it works! --> <!-- Probably not the best way, but it works! -->

View File

@ -24,7 +24,7 @@
:slug="recipeDetails.slug" :slug="recipeDetails.slug"
:name="recipeDetails.name" :name="recipeDetails.name"
v-model="form" v-model="form"
v-if="loggedIn" :logged-in="loggedIn"
:open="showIcons" :open="showIcons"
@close="form = false" @close="form = false"
@json="jsonEditor = !jsonEditor" @json="jsonEditor = !jsonEditor"

View File

@ -6,7 +6,7 @@ import { mealRoutes } from "./meal";
import { generalRoutes } from "./general"; import { generalRoutes } from "./general";
import { store } from "@/store"; import { store } from "@/store";
import VueRouter from "vue-router"; import VueRouter from "vue-router";
import { loadLanguageAsync } from "@/i18n" import { loadLanguageAsync } from "@/i18n";
import Vue from "vue"; import Vue from "vue";
import i18n from "@/i18n.js"; import i18n from "@/i18n.js";

View File

@ -91,6 +91,7 @@ import {
mdiArrowLeftBold, mdiArrowLeftBold,
mdiMinus, mdiMinus,
mdiWindowClose, mdiWindowClose,
mdiFolderZipOutline,
} from "@mdi/js"; } from "@mdi/js";
const icons = { const icons = {
@ -176,6 +177,7 @@ const icons = {
weatherSunny: mdiWeatherSunny, weatherSunny: mdiWeatherSunny,
webhook: mdiWebhook, webhook: mdiWebhook,
windowClose: mdiWindowClose, windowClose: mdiWindowClose,
zip: mdiFolderZipOutline,
// Crud // Crud
createAlt: mdiPlus, createAlt: mdiPlus,

View File

@ -4,7 +4,7 @@ from typing import Optional
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt 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.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.schema.auth import TokenData from mealie.schema.auth import TokenData
@ -100,3 +100,13 @@ def validate_file_token(token: Optional[str] = None) -> Path:
raise credentials_exception raise credentials_exception
return file_path 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)

View File

@ -1,4 +1,7 @@
import json
import shutil
from shutil import copyfileobj from shutil import copyfileobj
from zipfile import ZipFile
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, status from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile 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.core.root_logger import get_logger
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user, is_logged_in from mealie.routes.deps import get_current_user, is_logged_in, temporary_zip_path
from mealie.schema.recipe import Recipe, RecipeAsset, RecipeURLIn from mealie.schema.recipe import Recipe, RecipeAsset, RecipeImageTypes, RecipeURLIn
from mealie.schema.user import UserInDB from mealie.schema.user import UserInDB
from mealie.services.events import create_recipe_event from mealie.services.events import create_recipe_event
from mealie.services.image.image import scrape_image, write_image 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 scrape_schema_recipe import scrape_url
from slugify import slugify from slugify import slugify
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"]) router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"])
logger = get_logger() 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"}) 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)]) @router.put("/{recipe_slug}", dependencies=[Depends(get_current_user)])
def update_recipe( def update_recipe(
recipe_slug: str, recipe_slug: str,

View File

@ -1,4 +1,5 @@
import datetime import datetime
from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -11,6 +12,12 @@ from pydantic.utils import GetterDict
from slugify import slugify from slugify import slugify
class RecipeImageTypes(str, Enum):
original = "original.webp"
min = "min-original.webp"
tiny = "tiny-original.webp"
class RecipeSettings(CamelModel): class RecipeSettings(CamelModel):
public: bool = True public: bool = True
show_nutrition: bool = True show_nutrition: bool = True