Basic nutrition editor (#288)

* Basic nutrition editor

* fix no image on scrape

* nutrition display

* add recipe images

* update by url

* new upload options

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-04-11 13:16:33 -08:00 committed by GitHub
parent 2a158ab290
commit 406dae6e97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 234 additions and 29 deletions

View File

@ -21,3 +21,4 @@
- Unify Logger across the backend - Unify Logger across the backend
- mealie.log and last_recipe.json are now downloadable from the frontend from the /admin/about - mealie.log and last_recipe.json are now downloadable from the frontend from the /admin/about
- New download schema where you request a token and then use that token to hit a single endpoint to download a file. This is a notable change if you are using the API to download backups. - New download schema where you request a token and then use that token to hit a single endpoint to download a file. This is a notable change if you are using the API to download backups.
- Recipe images can no be added directly from a URL - [See #177 for details](https://github.com/hay-kot/mealie/issues/117)

View File

@ -61,6 +61,11 @@ export const recipeAPI = {
return response; return response;
}, },
async updateImagebyURL(slug, url) {
const response = apiReq.post(recipeURLs.updateImage(slug), { url: url });
return response;
},
async update(data) { async update(data) {
let response = await apiReq.put(recipeURLs.update(data.slug), data); let response = await apiReq.put(recipeURLs.update(data.slug), data);
store.dispatch("requestRecentRecipes"); store.dispatch("requestRecentRecipes");

View File

@ -0,0 +1,76 @@
<template>
<div class="text-center">
<v-menu offset-y top nudge-top="6" :close-on-content-click="false">
<template v-slot:activator="{ on, attrs }">
<v-btn color="accent" dark v-bind="attrs" v-on="on">
Image
</v-btn>
</template>
<v-card width="400">
<v-card-title class="headline flex mb-0">
<div>
Recipe Image
</div>
<UploadBtn
class="ml-auto"
url="none"
file-name="image"
:text-btn="false"
@uploaded="uploadImage"
/>
</v-card-title>
<v-card-text class="mt-n5">
<div>
<v-text-field label="URL" class="pt-5" clearable v-model="url">
<template v-slot:append-outer>
<v-btn
class="ml-2"
color="primary"
@click="getImageFromURL"
:loading="loading"
>
Get
</v-btn>
</template>
</v-text-field>
</div>
</v-card-text>
</v-card>
</v-menu>
</div>
</template>
<script>
const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload";
import UploadBtn from "@/components/UI/UploadBtn";
import { api } from "@/api";
// import axios from "axios";
export default {
components: {
UploadBtn,
},
props: {
slug: String,
},
data: () => ({
items: [{ title: "Upload Image" }, { title: "From URL" }],
url: "",
loading: false,
}),
methods: {
uploadImage(fileObject) {
this.$emit(UPLOAD_EVENT, fileObject);
},
async getImageFromURL() {
this.loading = true;
const response = await api.recipes.updateImagebyURL(this.slug, this.url);
if (response) this.$emit(REFRESH_EVENT);
this.loading = false;
},
},
};
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,81 @@
<template>
<div v-if="valueNotNull || edit">
<h2 class="my-4">Nutrition</h2>
<div v-if="edit">
<div v-for="(item, key, index) in value" :key="index">
<v-text-field
dense
:value="value[key]"
:label="labels[key].label"
:suffix="labels[key].suffix"
type="number"
autocomplete="off"
@input="updateValue(key, $event)"
></v-text-field>
</div>
</div>
<div v-if="showViewer">
<v-list dense>
<v-list-item-group color="primary">
<v-list-item v-for="(item, key, index) in labels" :key="index">
<v-list-item-content>
<v-list-item-title class="pl-4 text-subtitle-1 flex row ">
<div>{{ item.label }}</div>
<div class="ml-auto mr-1">{{ value[key] }}</div>
<div>{{ item.suffix }}</div>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</div>
</div>
</template>
<script>
export default {
props: {
value: {},
edit: {
type: Boolean,
default: true,
},
},
data() {
return {
labels: {
calories: {
label: "Calories",
suffix: "calories",
},
fatContent: { label: "Fat Content", suffix: "grams" },
fiberContent: { label: "Fiber Content", suffix: "grams" },
proteinContent: { label: "Protein Content", suffix: "grams" },
sodiumContent: { label: "Sodium Content", suffix: "milligrams" },
sugarContent: { label: "Sugar Content", suffix: "grams" },
},
};
},
computed: {
showViewer() {
return !this.edit && this.valueNotNull;
},
valueNotNull() {
for (const property in this.value) {
const valueProperty = this.value[property];
if (valueProperty && valueProperty !== "") return true;
}
return false;
},
},
methods: {
updateValue(key, value) {
this.$emit("input", { ...this.value, [key]: value });
},
},
};
</script>
<style lang="scss" scoped>
</style>

View File

@ -2,16 +2,12 @@
<v-form ref="form"> <v-form ref="form">
<v-card-text> <v-card-text>
<v-row dense> <v-row dense>
<v-col cols="3"></v-col> <ImageUploadBtn
<v-col> class="mt-2"
<v-file-input @upload="uploadImage"
v-model="fileObject" :slug="value.slug"
:label="$t('general.image-file')" @refresh="$emit('upload')"
truncate-length="30" />
@change="uploadImage"
></v-file-input>
</v-col>
<v-col cols="3"></v-col>
</v-row> </v-row>
<v-row dense> <v-row dense>
<v-col> <v-col>
@ -92,7 +88,7 @@
auto-grow auto-grow
solo solo
dense dense
rows="2" rows="1"
> >
<v-icon <v-icon
class="mr-n1" class="mr-n1"
@ -165,6 +161,7 @@
<v-btn class="mt-1" color="secondary" fab dark small @click="addNote"> <v-btn class="mt-1" color="secondary" fab dark small @click="addNote">
<v-icon>mdi-plus</v-icon> <v-icon>mdi-plus</v-icon>
</v-btn> </v-btn>
<NutritionEditor v-model="value.nutrition" :edit="true" />
<ExtrasEditor :extras="value.extras" @save="saveExtras" /> <ExtrasEditor :extras="value.extras" @save="saveExtras" />
</v-col> </v-col>
@ -222,17 +219,20 @@
<script> <script>
import draggable from "vuedraggable"; import draggable from "vuedraggable";
import { api } from "@/api";
import utils from "@/utils"; import utils from "@/utils";
import BulkAdd from "./BulkAdd"; import BulkAdd from "./BulkAdd";
import ExtrasEditor from "./ExtrasEditor"; import ExtrasEditor from "./ExtrasEditor";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector"; import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import NutritionEditor from "./NutritionEditor";
import ImageUploadBtn from "./ImageUploadBtn.vue";
export default { export default {
components: { components: {
BulkAdd, BulkAdd,
ExtrasEditor, ExtrasEditor,
draggable, draggable,
CategoryTagSelector, CategoryTagSelector,
NutritionEditor,
ImageUploadBtn,
}, },
props: { props: {
value: Object, value: Object,
@ -251,12 +251,8 @@ export default {
}; };
}, },
methods: { methods: {
uploadImage() { uploadImage(fileObject) {
this.$emit("upload", this.fileObject); this.$emit("upload", fileObject);
},
async updateImage() {
const slug = this.value.slug;
api.recipes.updateImage(slug, this.fileObject);
}, },
toggleDisabled(stepIndex) { toggleDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) { if (this.disabledSteps.includes(stepIndex)) {

View File

@ -40,6 +40,7 @@
:isCategory="false" :isCategory="false"
/> />
<Notes :notes="notes" /> <Notes :notes="notes" />
<NutritionEditor :value="nutrition" :edit="false" />
</div> </div>
</v-col> </v-col>
<v-divider <v-divider
@ -56,6 +57,7 @@
<RecipeChips :title="$t('recipe.categories')" :items="categories" /> <RecipeChips :title="$t('recipe.categories')" :items="categories" />
<RecipeChips :title="$t('recipe.tags')" :items="tags" /> <RecipeChips :title="$t('recipe.tags')" :items="tags" />
<Notes :notes="notes" /> <Notes :notes="notes" />
<NutritionEditor :value="nutrition" :edit="false" />
</div> </div>
<v-row class="mt-2 mb-1"> <v-row class="mt-2 mb-1">
<v-col></v-col> <v-col></v-col>
@ -80,6 +82,7 @@
</template> </template>
<script> <script>
import NutritionEditor from "@/components/Recipe/RecipeEditor/NutritionEditor";
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import utils from "@/utils"; import utils from "@/utils";
import RecipeChips from "./RecipeChips"; import RecipeChips from "./RecipeChips";
@ -93,6 +96,7 @@ export default {
Steps, Steps,
Notes, Notes,
Ingredients, Ingredients,
NutritionEditor,
}, },
props: { props: {
name: String, name: String,
@ -105,6 +109,7 @@ export default {
rating: Number, rating: Number,
yields: String, yields: String,
orgURL: String, orgURL: String,
nutrition: Object,
}, },
data() { data() {
return { return {

View File

@ -1,7 +1,12 @@
<template> <template>
<v-form ref="file"> <v-form ref="file">
<input ref="uploader" class="d-none" type="file" @change="onFileChanged" /> <input ref="uploader" class="d-none" type="file" @change="onFileChanged" />
<v-btn :loading="isSelecting" @click="onButtonClick" color="accent" text> <v-btn
:loading="isSelecting"
@click="onButtonClick"
color="accent"
:text="textBtn"
>
<v-icon left> {{ icon }}</v-icon> <v-icon left> {{ icon }}</v-icon>
{{ text ? text : defaultText }} {{ text ? text : defaultText }}
</v-btn> </v-btn>
@ -13,10 +18,17 @@ const UPLOAD_EVENT = "uploaded";
import { api } from "@/api"; import { api } from "@/api";
export default { export default {
props: { props: {
post: {
type: Boolean,
default: true,
},
url: String, url: String,
text: { default: "Upload" }, text: { default: "Upload" },
icon: { default: "mdi-cloud-upload" }, icon: { default: "mdi-cloud-upload" },
fileName: { default: "archive" }, fileName: { default: "archive" },
textBtn: {
default: true,
},
}, },
data: () => ({ data: () => ({
file: null, file: null,
@ -33,6 +45,12 @@ export default {
async upload() { async upload() {
if (this.file != null) { if (this.file != null) {
this.isSelecting = true; this.isSelecting = true;
if (this.post) {
this.$emit(UPLOAD_EVENT, this.file);
this.isSelecting = false;
return;
}
let formData = new FormData(); let formData = new FormData();
formData.append(this.fileName, this.file); formData.append(this.fileName, this.file);

View File

@ -50,6 +50,7 @@
:rating="recipeDetails.rating" :rating="recipeDetails.rating"
:yields="recipeDetails.recipeYield" :yields="recipeDetails.recipeYield"
:orgURL="recipeDetails.orgURL" :orgURL="recipeDetails.orgURL"
:nutrition="recipeDetails.nutrition"
/> />
<VJsoneditor <VJsoneditor
@error="logError()" @error="logError()"
@ -151,6 +152,7 @@ export default {
methods: { methods: {
getImageFile(fileObject) { getImageFile(fileObject) {
this.fileObject = fileObject; this.fileObject = fileObject;
this.saveImage();
}, },
async getRecipeDetails() { async getRecipeDetails() {
this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe); this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe);
@ -172,19 +174,21 @@ export default {
return this.$refs.recipeEditor.validateRecipe(); return this.$refs.recipeEditor.validateRecipe();
} }
}, },
async saveImage() {
if (this.fileObject) {
await api.recipes.updateImage(this.recipeDetails.slug, this.fileObject);
}
this.imageKey += 1;
},
async saveRecipe() { async saveRecipe() {
if (this.validateRecipe()) { if (this.validateRecipe()) {
let slug = await api.recipes.update(this.recipeDetails); let slug = await api.recipes.update(this.recipeDetails);
if (this.fileObject) { if (this.fileObject) {
await api.recipes.updateImage( this.saveImage();
this.recipeDetails.slug,
this.fileObject
);
} }
this.form = false; this.form = false;
this.imageKey += 1;
if (slug != this.recipeDetails.slug) { if (slug != this.recipeDetails.slug) {
this.$router.push(`/recipe/${slug}`); this.$router.push(`/recipe/${slug}`);
} }

View File

@ -1,5 +1,7 @@
import shutil
from enum import Enum from enum import Enum
import requests
from fastapi import APIRouter, Depends, File, Form, HTTPException from fastapi import APIRouter, Depends, File, Form, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from mealie.db.database import db from mealie.db.database import db
@ -7,7 +9,7 @@ from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeURLIn from mealie.schema.recipe import Recipe, RecipeURLIn
from mealie.schema.snackbar import SnackResponse from mealie.schema.snackbar import SnackResponse
from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, rename_image, write_image from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, rename_image, scrape_image, write_image
from mealie.services.scraper.scraper import create_from_url from mealie.services.scraper.scraper import create_from_url
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -120,3 +122,16 @@ def update_recipe_image(
db.recipes.update_image(session, recipe_slug, extension) db.recipes.update_image(session, recipe_slug, extension)
return response return response
@router.post("/{recipe_slug}/image")
def scrape_image_url(
recipe_slug: str,
url: RecipeURLIn,
current_user=Depends(get_current_user),
):
""" Removes an existing image and replaces it with the incoming file. """
scrape_image(url.url, recipe_slug)
return SnackResponse.success("Recipe Image Updated")

View File

@ -62,12 +62,16 @@ def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path:
extension = extension.replace(".", "") extension = extension.replace(".", "")
image_path = image_dir.joinpath(f"original.{extension}") image_path = image_dir.joinpath(f"original.{extension}")
if isinstance(file_data, bytes): if isinstance(file_data, Path):
shutil.copy2(file_data, image_path)
elif isinstance(file_data, bytes):
with open(image_path, "ab") as f: with open(image_path, "ab") as f:
f.write(file_data) f.write(file_data)
else: else:
shutil.copy2(file_data, image_path) with open(image_path, "ab") as f:
shutil.copyfileobj(file_data, f)
print(image_path)
minify.minify_image(image_path) minify.minify_image(image_path)
return image_path return image_path
@ -105,7 +109,7 @@ def scrape_image(image_url: str, slug: str) -> Path:
write_image(slug, r.raw, filename.suffix) write_image(slug, r.raw, filename.suffix)
filename.unlink() filename.unlink(missing_ok=True)
return slug return slug