Feature/image minify (#256)

* fix settings

* app info cleanup

* bottom-bar experiment

* remove dup key

* type hints

* add dependency

* updated image with query parameters

* read image options

* add image minification

* add image minification step

* alt image routes

* add image minification

* set mobile bar to top

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-04-02 21:54:46 -08:00 committed by GitHub
parent bc595d5cfa
commit 95213fa41b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 487 additions and 172 deletions

View File

@ -1,6 +1,6 @@
![Recipe Image](../../images/{{ recipe.image }}) ![Recipe Image](../../images/{{ recipe.slug }}/original.jpg)
# {{ recipe.name }} # {{ recipe.name }}
{{ recipe.description }} {{ recipe.description }}

View File

@ -1,35 +1,6 @@
<template> <template>
<v-app> <v-app>
<v-app-bar clipped-left dense app color="primary" dark class="d-print-none"> <TheAppBar />
<router-link v-if="!(isMobile && search)" to="/">
<v-btn icon>
<v-icon size="40"> mdi-silverware-variant </v-icon>
</v-btn>
</router-link>
<div v-if="!isMobile" btn class="pl-2">
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"
>Mealie
</v-toolbar-title>
</div>
<v-spacer></v-spacer>
<v-expand-x-transition>
<SearchBar
ref="mainSearchBar"
v-if="search"
:show-results="true"
@selected="navigateFromSearch"
:max-width="isMobile ? '100%' : '450px'"
/>
</v-expand-x-transition>
<v-btn icon @click="search = !search">
<v-icon>mdi-magnify</v-icon>
</v-btn>
<SiteMenu />
<LanguageMenu />
</v-app-bar>
<v-main> <v-main>
<v-banner v-if="demo" sticky <v-banner v-if="demo" sticky
><div class="text-center"> ><div class="text-center">
@ -47,10 +18,8 @@
</template> </template>
<script> <script>
import SiteMenu from "@/components/UI/SiteMenu"; import TheAppBar from "@/components/UI/TheAppBar";
import SearchBar from "@/components/UI/Search/SearchBar";
import AddRecipeFab from "@/components/UI/AddRecipeFab"; import AddRecipeFab from "@/components/UI/AddRecipeFab";
import LanguageMenu from "@/components/UI/LanguageMenu";
import Vuetify from "./plugins/vuetify"; import Vuetify from "./plugins/vuetify";
import { user } from "@/mixins/user"; import { user } from "@/mixins/user";
@ -58,23 +27,13 @@ export default {
name: "App", name: "App",
components: { components: {
SiteMenu, TheAppBar,
AddRecipeFab, AddRecipeFab,
SearchBar,
LanguageMenu,
}, },
mixins: [user], mixins: [user],
watch: {
$route() {
this.search = false;
},
},
computed: { computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
demo() { demo() {
const appInfo = this.$store.getters.getAppInfo; const appInfo = this.$store.getters.getAppInfo;
return appInfo.demoStatus; return appInfo.demoStatus;
@ -102,9 +61,6 @@ export default {
this.$store.dispatch("requestAppInfo"); this.$store.dispatch("requestAppInfo");
}, },
data: () => ({
search: false,
}),
methods: { methods: {
// For Later! // For Later!
@ -126,9 +82,6 @@ export default {
this.darkModeSystemCheck(); this.darkModeSystemCheck();
}); });
}, },
navigateFromSearch(slug) {
this.$router.push(`/recipe/${slug}`);
},
}, },
}; };
</script> </script>

View File

@ -5,27 +5,27 @@ import { store } from "@/store";
const prefix = baseURL + "categories"; const prefix = baseURL + "categories";
const categoryURLs = { const categoryURLs = {
get_all: `${prefix}`, getAll: `${prefix}`,
get_category: category => `${prefix}/${category}`, getCategory: category => `${prefix}/${category}`,
delete_category: category => `${prefix}/${category}`, deleteCategory: category => `${prefix}/${category}`,
}; };
export const categoryAPI = { export const categoryAPI = {
async getAll() { async getAll() {
let response = await apiReq.get(categoryURLs.get_all); let response = await apiReq.get(categoryURLs.getAll);
return response.data; return response.data;
}, },
async create(name) { async create(name) {
let response = await apiReq.post(categoryURLs.get_all, { name: name }); let response = await apiReq.post(categoryURLs.getAll, { name: name });
store.dispatch("requestCategories"); store.dispatch("requestCategories");
return response.data; return response.data;
}, },
async getRecipesInCategory(category) { async getRecipesInCategory(category) {
let response = await apiReq.get(categoryURLs.get_category(category)); let response = await apiReq.get(categoryURLs.getCategory(category));
return response.data; return response.data;
}, },
async delete(category) { async delete(category) {
let response = await apiReq.delete(categoryURLs.delete_category(category)); let response = await apiReq.delete(categoryURLs.deleteCategory(category));
store.dispatch("requestCategories"); store.dispatch("requestCategories");
return response.data; return response.data;
}, },

View File

@ -56,9 +56,7 @@ export const recipeAPI = {
const fd = new FormData(); const fd = new FormData();
fd.append("image", fileObject); fd.append("image", fileObject);
fd.append("extension", fileObject.name.split(".").pop()); fd.append("extension", fileObject.name.split(".").pop());
let response = apiReq.put(recipeURLs.updateImage(recipeSlug), fd); let response = apiReq.put(recipeURLs.updateImage(recipeSlug), fd);
return response; return response;
}, },
@ -87,4 +85,16 @@ export const recipeAPI = {
return response.data; return response.data;
}, },
recipeImage(recipeSlug) {
return `/api/recipes/${recipeSlug}/image?image_type=original`;
},
recipeSmallImage(recipeSlug) {
return `/api/recipes/${recipeSlug}/image?image_type=small`;
},
recipeTinyImage(recipeSlug) {
return `/api/recipes/${recipeSlug}/image?image_type=tiny`;
},
}; };

View File

@ -28,8 +28,8 @@
</template> </template>
<script> <script>
import utils from "@/utils";
import SearchDialog from "../UI/Search/SearchDialog"; import SearchDialog from "../UI/Search/SearchDialog";
import { api } from "@/api";
export default { export default {
components: { components: {
SearchDialog, SearchDialog,
@ -47,7 +47,7 @@ export default {
methods: { methods: {
getImage(slug) { getImage(slug) {
if (slug) { if (slug) {
return utils.getImageURL(slug); return api.recipes.recipeSmallImage(slug);
} }
}, },
setSlug(name, slug) { setSlug(name, slug) {

View File

@ -223,7 +223,7 @@ export default {
}, },
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeSmallImage(image);
}, },
formatDate(date) { formatDate(date) {

View File

@ -1,5 +1,10 @@
<template> <template>
<v-card hover :to="`/recipe/${slug}`" max-height="125"> <v-card
hover
:to="`/recipe/${slug}`"
max-height="125"
@click="$emit('selected')"
>
<v-list-item> <v-list-item>
<v-list-item-avatar rounded size="125" class="mt-0 ml-n4"> <v-list-item-avatar rounded size="125" class="mt-0 ml-n4">
<v-img :src="getImage(image)"> </v-img> <v-img :src="getImage(image)"> </v-img>
@ -20,7 +25,7 @@
</template> </template>
<script> <script>
import utils from "@/utils"; import { api } from "@/api";
export default { export default {
props: { props: {
name: String, name: String,
@ -35,7 +40,7 @@ export default {
methods: { methods: {
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeSmallImage(image);
}, },
}, },
}; };

View File

@ -42,7 +42,7 @@
</template> </template>
<script> <script>
import utils from "@/utils"; import { api } from "@/api";
export default { export default {
props: { props: {
name: String, name: String,
@ -57,7 +57,7 @@ export default {
}, },
methods: { methods: {
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeSmallImage(image);
}, },
}, },
}; };

View File

@ -162,6 +162,7 @@
<script> <script>
import utils from "@/utils"; import utils from "@/utils";
import { api } from "@/api";
export default { export default {
props: { props: {
@ -175,7 +176,7 @@ export default {
methods: { methods: {
getImage(image) { getImage(image) {
if (image) { if (image) {
return utils.getImageURL(image) + "?rnd=" + this.imageKey; return api.recipes.recipeImage(image) + "?rnd=" + this.imageKey;
} }
}, },
generateKey(item, index) { generateKey(item, index) {

View File

@ -16,7 +16,7 @@
> >
</v-text-field> </v-text-field>
</template> </template>
<v-card v-if="showResults" max-height="500" :max-width="maxWidth"> <v-card v-if="showResults" max-height="500" :max-width="maxWidth">
<v-card-text class="py-1">Results</v-card-text> <v-card-text class="py-1">Results</v-card-text>
<v-divider></v-divider> <v-divider></v-divider>
<v-list scrollable> <v-list scrollable>
@ -54,7 +54,7 @@
<script> <script>
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import utils from "@/utils"; import { api } from "@/api";
export default { export default {
props: { props: {
@ -151,7 +151,7 @@ export default {
); );
}, },
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeTinyImage(image);
}, },
selected(slug, name) { selected(slug, name) {
this.$emit("selected", slug, name); this.$emit("selected", slug, name);

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="text-center "> <div class="text-center ">
<v-dialog v-model="dialog" class="search-dialog" width="600px" height="0"> <v-dialog v-model="dialog" width="600px" height="0" :fullscreen="isMobile">
<v-card> <v-card>
<v-app-bar dark color="primary"> <v-app-bar dark color="primary">
<v-toolbar-title class="headline">Search a Recipe</v-toolbar-title> <v-toolbar-title class="headline">Search a Recipe</v-toolbar-title>
@ -9,13 +9,27 @@
<SearchBar <SearchBar
@results="updateResults" @results="updateResults"
@selected="emitSelect" @selected="emitSelect"
:show-results="true" :show-results="!isMobile"
max-width="550px" max-width="550px"
:dense="false" :dense="false"
:nav-on-click="false" :nav-on-click="false"
:reset-search="dialog" :reset-search="dialog"
:solo="false" :solo="false"
/> />
<div v-if="isMobile">
<div v-for="recipe in searchResults.slice(0, 7)" :key="recipe.name">
<MobileRecipeCard
class="ma-1 px-0"
:name="recipe.item.name"
:description="recipe.item.description"
:slug="recipe.item.slug"
:rating="recipe.item.rating"
:image="recipe.item.image"
:route="true"
@selected="dialog = false"
/>
</div>
</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-dialog> </v-dialog>
@ -24,16 +38,32 @@
<script> <script>
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
export default { export default {
components: { components: {
SearchBar, SearchBar,
MobileRecipeCard,
}, },
data() { data() {
return { return {
searchResults: null, searchResults: [],
dialog: false, dialog: false,
}; };
}, },
computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
},
watch: {
"$route.hash"(newHash, oldHash) {
if (newHash === "#mobile-search") {
this.dialog = true;
} else if (oldHash === "#mobile-search") {
this.dialog = false;
}
},
},
methods: { methods: {
updateResults(results) { updateResults(results) {
this.searchResults = results; this.searchResults = results;
@ -44,15 +74,22 @@ export default {
}, },
open() { open() {
this.dialog = true; this.dialog = true;
this.$router.push("#mobile-search");
},
toggleDialog(open) {
if (open) {
this.$router.push("#mobile-search");
} else {
this.$router.back(); // 😎 back button click
}
}, },
}, },
}; };
</script> </script>
<style scope> <style scope>
.search-dialog { .mobile-dialog {
margin-top: 10%;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: flex-start;
} }
</style> </style>

View File

@ -0,0 +1,114 @@
<template>
<div>
<v-app-bar
v-if="!isMobile"
clipped-left
dense
app
color="primary"
dark
class="d-print-none"
>
<router-link v-if="!(isMobile && search)" to="/">
<v-btn icon>
<v-icon size="40"> mdi-silverware-variant </v-icon>
</v-btn>
</router-link>
<div v-if="!isMobile" btn class="pl-2">
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"
>Mealie
</v-toolbar-title>
</div>
<v-spacer></v-spacer>
<v-expand-x-transition>
<SearchBar
ref="mainSearchBar"
v-if="search"
:show-results="true"
@selected="navigateFromSearch"
:max-width="isMobile ? '100%' : '450px'"
/>
</v-expand-x-transition>
<v-btn icon @click="search = !search">
<v-icon>mdi-magnify</v-icon>
</v-btn>
<SiteMenu />
</v-app-bar>
<v-app-bar
v-else
bottom
clipped-left
dense
app
color="primary"
dark
class="d-print-none"
>
<router-link to="/">
<v-btn icon>
<v-icon size="40"> mdi-silverware-variant </v-icon>
</v-btn>
</router-link>
<div v-if="!isMobile" btn class="pl-2">
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"
>Mealie
</v-toolbar-title>
</div>
<v-spacer></v-spacer>
<v-expand-x-transition>
<SearchDialog ref="mainSearchDialog" />
</v-expand-x-transition>
<v-btn icon @click="$refs.mainSearchDialog.open()">
<v-icon>mdi-magnify</v-icon>
</v-btn>
<SiteMenu />
</v-app-bar>
</div>
</template>
<script>
import SiteMenu from "@/components/UI/SiteMenu";
import SearchBar from "@/components/UI/Search/SearchBar";
import SearchDialog from "@/components/UI/Search/SearchDialog";
import { user } from "@/mixins/user";
export default {
name: "AppBar",
mixins: [user],
components: {
SiteMenu,
SearchBar,
SearchDialog,
},
data() {
return {
search: false,
isMobile: false,
};
},
watch: {
$route() {
this.search = false;
},
},
computed: {
// isMobile() {
// return this.$vuetify.breakpoint.name === "xs";
// },
},
methods: {
navigateFromSearch(slug) {
this.$router.push(`/recipe/${slug}`);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,7 @@
export const utilMixins = {
commputed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
},
};

View File

@ -117,7 +117,7 @@ export default {
return utils.getDateAsTextAlt(dateObject); return utils.getDateAsTextAlt(dateObject);
}, },
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeTinyImage(image);
}, },
editPlan(id) { editPlan(id) {

View File

@ -52,7 +52,6 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import utils from "@/utils";
export default { export default {
data() { data() {
return { return {
@ -68,7 +67,7 @@ export default {
else return 0; else return 0;
}, },
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeImage(image);
}, },
}, },
}; };

View File

@ -14,7 +14,7 @@
<v-card v-else id="myRecipe"> <v-card v-else id="myRecipe">
<v-img <v-img
height="400" height="400"
:src="getImage(recipeDetails.image)" :src="getImage(recipeDetails.slug)"
class="d-print-none" class="d-print-none"
:key="imageKey" :key="imageKey"
> >
@ -71,7 +71,6 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import utils from "@/utils";
import VJsoneditor from "v-jsoneditor"; import VJsoneditor from "v-jsoneditor";
import RecipeViewer from "@/components/Recipe/RecipeViewer"; import RecipeViewer from "@/components/Recipe/RecipeViewer";
import RecipeEditor from "@/components/Recipe/RecipeEditor"; import RecipeEditor from "@/components/Recipe/RecipeEditor";
@ -160,7 +159,7 @@ export default {
}, },
getImage(image) { getImage(image) {
if (image) { if (image) {
return utils.getImageURL(image) + "?rnd=" + this.imageKey; return api.recipes.recipeImage(image) + "&rnd=" + this.imageKey;
} }
}, },
deleteRecipe() { deleteRecipe() {

View File

@ -70,7 +70,7 @@ const actions = {
async refreshToken({ commit, getters }) { async refreshToken({ commit, getters }) {
if (!getters.getIsLoggedIn) { if (!getters.getIsLoggedIn) {
commit("setIsLoggedIn", false); // This is to be here... for some reasons? ¯\_(ツ)_/¯ commit("setIsLoggedIn", false); // This has to be here... for some reasons? ¯\_(ツ)_/¯
console.log("Not Logged In"); console.log("Not Logged In");
return; return;
} }

View File

@ -50,7 +50,7 @@ const monthsShort = [
export default { export default {
getImageURL(image) { getImageURL(image) {
return `/api/recipes/${image}/image`; return `/api/recipes/${image}/image?image_type=small`;
}, },
generateUniqueKey(item, index) { generateUniqueKey(item, index) {
const uniqueKey = `${item}-${index}`; const uniqueKey = `${item}-${index}`;

View File

@ -54,6 +54,7 @@ setup: ## Setup Development Instance
backend: ## Start Mealie Backend Development Server backend: ## Start Mealie Backend Development Server
poetry run python mealie/db/init_db.py && \ poetry run python mealie/db/init_db.py && \
poetry run python mealie/services/image/minify.py && \
poetry run python mealie/app.py poetry run python mealie/app.py

View File

@ -45,19 +45,19 @@ LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
class AppDirectories: class AppDirectories:
def __init__(self, cwd, data_dir) -> None: def __init__(self, cwd, data_dir) -> None:
self.DATA_DIR = data_dir self.DATA_DIR: Path = data_dir
self.WEB_PATH = cwd.joinpath("dist") self.WEB_PATH: Path = cwd.joinpath("dist")
self.IMG_DIR = data_dir.joinpath("img") self.IMG_DIR: Path = data_dir.joinpath("img")
self.BACKUP_DIR = data_dir.joinpath("backups") self.BACKUP_DIR: Path = data_dir.joinpath("backups")
self.DEBUG_DIR = data_dir.joinpath("debug") self.DEBUG_DIR: Path = data_dir.joinpath("debug")
self.MIGRATION_DIR = data_dir.joinpath("migration") self.MIGRATION_DIR: Path = data_dir.joinpath("migration")
self.NEXTCLOUD_DIR = self.MIGRATION_DIR.joinpath("nextcloud") self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud")
self.CHOWDOWN_DIR = self.MIGRATION_DIR.joinpath("chowdown") self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown")
self.TEMPLATE_DIR = data_dir.joinpath("templates") self.TEMPLATE_DIR: Path = data_dir.joinpath("templates")
self.USER_DIR = data_dir.joinpath("users") self.USER_DIR: Path = data_dir.joinpath("users")
self.SQLITE_DIR = data_dir.joinpath("db") self.SQLITE_DIR: Path = data_dir.joinpath("db")
self.RECIPE_DATA_DIR = data_dir.joinpath("recipes") self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes")
self.TEMP_DIR = data_dir.joinpath(".temp") self.TEMP_DIR: Path = data_dir.joinpath(".temp")
self.ensure_directories() self.ensure_directories()

View File

@ -1,3 +1,5 @@
from enum import Enum
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
@ -5,7 +7,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_services import read_image, write_image from mealie.services.image.image import IMG_OPTIONS, delete_image, read_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
@ -72,20 +74,35 @@ def delete_recipe(
try: try:
db.recipes.delete(session, recipe_slug) db.recipes.delete(session, recipe_slug)
delete_image(recipe_slug)
except: except:
raise HTTPException(status_code=404, detail=SnackResponse.error("Unable to Delete Recipe")) raise HTTPException(status_code=404, detail=SnackResponse.error("Unable to Delete Recipe"))
return SnackResponse.error(f"Recipe {recipe_slug} Deleted") return SnackResponse.error(f"Recipe {recipe_slug} Deleted")
class ImageType(str, Enum):
original = "original"
small = "small"
tiny = "tiny"
@router.get("/{recipe_slug}/image") @router.get("/{recipe_slug}/image")
async def get_recipe_img(recipe_slug: str): async def get_recipe_img(recipe_slug: str, image_type: ImageType = ImageType.original):
""" Takes in a recipe slug, returns the static image """ """ Takes in a recipe slug, returns the static image """
recipe_image = read_image(recipe_slug) if image_type == ImageType.original:
which_image = IMG_OPTIONS.ORIGINAL_IMAGE
elif image_type == ImageType.small:
which_image = IMG_OPTIONS.MINIFIED_IMAGE
elif image_type == ImageType.tiny:
which_image = IMG_OPTIONS.TINY_IMAGE
recipe_image = read_image(recipe_slug, image_type=which_image)
print(recipe_image)
if recipe_image: if recipe_image:
return FileResponse(recipe_image) return FileResponse(recipe_image)
else: else:
return raise HTTPException(404, "file not found")
@router.put("/{recipe_slug}/image") @router.put("/{recipe_slug}/image")

View File

@ -65,8 +65,7 @@ class ExportDatabase:
f.write(content) f.write(content)
def export_images(self): def export_images(self):
for file in app_dirs.IMG_DIR.iterdir(): shutil.copytree(app_dirs.IMG_DIR, self.img_dir, dirs_exist_ok=True)
shutil.copy(file, self.img_dir.joinpath(file.name))
def export_items(self, items: list[BaseModel], folder_name: str, export_list=True): def export_items(self, items: list[BaseModel], folder_name: str, export_list=True):
items = [x.dict() for x in items] items = [x.dict() for x in items]

View File

@ -11,6 +11,7 @@ from mealie.schema.restore import CustomPageImport, GroupImport, RecipeImport, S
from mealie.schema.settings import CustomPageOut, SiteSettings from mealie.schema.settings import CustomPageOut, SiteSettings
from mealie.schema.theme import SiteTheme from mealie.schema.theme import SiteTheme
from mealie.schema.user import UpdateGroup, UserInDB from mealie.schema.user import UpdateGroup, UserInDB
from mealie.services.image import minify
from pydantic.main import BaseModel from pydantic.main import BaseModel
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -108,7 +109,13 @@ class ImportDatabase:
image_dir = self.import_dir.joinpath("images") image_dir = self.import_dir.joinpath("images")
for image in image_dir.iterdir(): for image in image_dir.iterdir():
if image.stem in successful_imports: if image.stem in successful_imports:
shutil.copy(image, app_dirs.IMG_DIR) if image.is_dir():
dest = app_dirs.IMG_DIR.joinpath(image.stem)
shutil.copytree(image, dest, dirs_exist_ok=True)
if image.is_file():
shutil.copy(image, app_dirs.IMG_DIR)
minify.migrate_images()
def import_themes(self): def import_themes(self):
themes_file = self.import_dir.joinpath("themes", "themes.json") themes_file = self.import_dir.joinpath("themes", "themes.json")

View File

@ -0,0 +1,101 @@
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Union
import requests
from fastapi.logger import logger
from mealie.core.config import app_dirs
from mealie.services.image import minify
@dataclass
class ImageOptions:
ORIGINAL_IMAGE: str = "original*"
MINIFIED_IMAGE: str = "min-original*"
TINY_IMAGE: str = "tiny-original*"
IMG_OPTIONS = ImageOptions()
def read_image(recipe_slug: str, image_type: str = "original") -> Path:
"""returns the path to the image file for the recipe base of image_type
Args:
recipe_slug (str): Recipe Slug
image_type (str, optional): Glob Style Matcher "original*" | "min-original* | "tiny-original*"
Returns:
Path: [description]
"""
print(image_type)
recipe_slug = recipe_slug.split(".")[0] # Incase of File Name
recipe_image_dir = app_dirs.IMG_DIR.joinpath(recipe_slug)
for file in recipe_image_dir.glob(image_type):
return file
return None
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path.name:
try:
delete_image(recipe_slug)
except:
pass
image_dir = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}"))
image_dir.mkdir()
extension = extension.replace(".", "")
image_path = image_dir.joinpath(f"original.{extension}")
if isinstance(file_data, bytes):
with open(image_path, "ab") as f:
f.write(file_data)
else:
with open(image_path, "ab") as f:
shutil.copyfileobj(file_data, f)
minify.migrate_images()
return image_path
def delete_image(recipe_slug: str) -> str:
recipe_slug = recipe_slug.split(".")[0]
for file in app_dirs.IMG_DIR.glob(f"{recipe_slug}*"):
return shutil.rmtree(file)
def scrape_image(image_url: str, slug: str) -> Path:
if isinstance(image_url, str): # Handles String Types
image_url = image_url
if isinstance(image_url, list): # Handles List Types
image_url = image_url[0]
if isinstance(image_url, dict): # Handles Dictionary Types
for key in image_url:
if key == "url":
image_url = image_url.get("url")
filename = slug + "." + image_url.split(".")[-1]
filename = app_dirs.IMG_DIR.joinpath(filename)
try:
r = requests.get(image_url, stream=True)
except:
logger.exception("Fatal Image Request Exception")
return None
if r.status_code == 200:
r.raw.decode_content = True
write_image(slug, r.raw, filename.suffix)
filename.unlink()
return filename
return None

View File

@ -0,0 +1,84 @@
from pathlib import Path
from mealie.core.config import app_dirs
from PIL import Image, UnidentifiedImageError
def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
"""Minifies an image in it's original file format. Quality is lost
Args:
my_path (Path): Source Files
min_dest (Path): FULL Destination File Path
tiny_dest (Path): FULL Destination File Path
"""
try:
img = Image.open(image_file)
basewidth = 720
wpercent = basewidth / float(img.size[0])
hsize = int((float(img.size[1]) * float(wpercent)))
img = img.resize((basewidth, hsize), Image.ANTIALIAS)
img.save(min_dest, quality=70)
tiny_image = crop_center(img)
tiny_image.save(tiny_dest, quality=70)
except UnidentifiedImageError:
pass
def crop_center(pil_img, crop_width=300, crop_height=300):
img_width, img_height = pil_img.size
return pil_img.crop(
(
(img_width - crop_width) // 2,
(img_height - crop_height) // 2,
(img_width + crop_width) // 2,
(img_height + crop_height) // 2,
)
)
def sizeof_fmt(size, decimal_places=2):
for unit in ["B", "kB", "MB", "GB", "TB", "PB"]:
if size < 1024.0 or unit == "PiB":
break
size /= 1024.0
return f"{size:.{decimal_places}f} {unit}"
def move_all_images():
for image_file in app_dirs.IMG_DIR.iterdir():
if image_file.is_file():
if image_file.name == ".DS_Store":
continue
new_folder = app_dirs.IMG_DIR.joinpath(image_file.stem)
new_folder.mkdir(parents=True, exist_ok=True)
image_file.rename(new_folder.joinpath(f"original{image_file.suffix}"))
def migrate_images():
print("Checking for Images to Minify...")
move_all_images()
# Minify Loop
for image in app_dirs.IMG_DIR.glob("*/original.*"):
min_dest = image.parent.joinpath(f"min-original{image.suffix}")
tiny_dest = image.parent.joinpath(f"tiny-original{image.suffix}")
if min_dest.exists() and tiny_dest.exists():
continue
minify_image(image, min_dest, tiny_dest)
org_size = sizeof_fmt(image.stat().st_size)
dest_size = sizeof_fmt(min_dest.stat().st_size)
tiny_size = sizeof_fmt(tiny_dest.stat().st_size)
print(f"{image.name} Minified: {org_size} -> {dest_size} -> {tiny_size}")
print("Finished Minification Check")
if __name__ == "__main__":
migrate_images()

View File

@ -1,63 +0,0 @@
import shutil
from pathlib import Path
import requests
from fastapi.logger import logger
from mealie.core.config import app_dirs
def read_image(recipe_slug: str) -> Path:
if app_dirs.IMG_DIR.joinpath(recipe_slug).is_file():
return app_dirs.IMG_DIR.joinpath(recipe_slug)
recipe_slug = recipe_slug.split(".")[0]
for file in app_dirs.IMG_DIR.glob(f"{recipe_slug}*"):
return file
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path.name:
delete_image(recipe_slug)
image_path = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}.{extension}"))
with open(image_path, "ab") as f:
f.write(file_data)
return image_path
def delete_image(recipe_slug: str) -> str:
recipe_slug = recipe_slug.split(".")[0]
for file in app_dirs.IMG_DIR.glob(f"{recipe_slug}*"):
return file.unlink()
def scrape_image(image_url: str, slug: str) -> Path:
if isinstance(image_url, str): # Handles String Types
image_url = image_url
if isinstance(image_url, list): # Handles List Types
image_url = image_url[0]
if isinstance(image_url, dict): # Handles Dictionary Types
for key in image_url:
if key == "url":
image_url = image_url.get("url")
filename = slug + "." + image_url.split(".")[-1]
filename = app_dirs.IMG_DIR.joinpath(filename)
try:
r = requests.get(image_url, stream=True)
except:
logger.exception("Fatal Image Request Exception")
return None
if r.status_code == 200:
r.raw.decode_content = True
with open(filename, "wb") as f:
shutil.copyfileobj(r.raw, f)
return filename
return None

View File

@ -52,7 +52,7 @@ def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe:
Returns: Returns:
Recipe: Pydantic Recipe Object Recipe: Pydantic Recipe Object
""" """
session = session if session else create_session() session = session or create_session()
if isinstance(group, int): if isinstance(group, int):
group: GroupInDB = db.groups.get(session, group) group: GroupInDB = db.groups.get(session, group)

View File

@ -5,7 +5,7 @@ import requests
import scrape_schema_recipe import scrape_schema_recipe
from mealie.core.config import app_dirs from mealie.core.config import app_dirs
from fastapi.logger import logger from fastapi.logger import logger
from mealie.services.image_services import scrape_image from mealie.services.image.image import scrape_image
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.services.scraper import open_graph from mealie.services.scraper import open_graph
from mealie.services.scraper.cleaner import Cleaner from mealie.services.scraper.cleaner import Cleaner

45
poetry.lock generated
View File

@ -606,6 +606,14 @@ category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pillow"
version = "8.2.0"
description = "Python Imaging Library (Fork)"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "0.13.1" version = "0.13.1"
@ -1154,7 +1162,7 @@ python-versions = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "a6c10e179bc15efc30627c9793218bb944f43dce5e624a7bcabcc47545e661e8" content-hash = "32bff6a472fd8564106e2cfa20161a47d271d89ed44a5f2f2483f419fe259c92"
[metadata.files] [metadata.files]
aiofiles = [ aiofiles = [
@ -1603,6 +1611,41 @@ pathspec = [
{file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
{file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
] ]
pillow = [
{file = "Pillow-8.2.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:dc38f57d8f20f06dd7c3161c59ca2c86893632623f33a42d592f097b00f720a9"},
{file = "Pillow-8.2.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a013cbe25d20c2e0c4e85a9daf438f85121a4d0344ddc76e33fd7e3965d9af4b"},
{file = "Pillow-8.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8bb1e155a74e1bfbacd84555ea62fa21c58e0b4e7e6b20e4447b8d07990ac78b"},
{file = "Pillow-8.2.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c5236606e8570542ed424849f7852a0ff0bce2c4c8d0ba05cc202a5a9c97dee9"},
{file = "Pillow-8.2.0-cp36-cp36m-win32.whl", hash = "sha256:12e5e7471f9b637762453da74e390e56cc43e486a88289995c1f4c1dc0bfe727"},
{file = "Pillow-8.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5afe6b237a0b81bd54b53f835a153770802f164c5570bab5e005aad693dab87f"},
{file = "Pillow-8.2.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:cb7a09e173903541fa888ba010c345893cd9fc1b5891aaf060f6ca77b6a3722d"},
{file = "Pillow-8.2.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0d19d70ee7c2ba97631bae1e7d4725cdb2ecf238178096e8c82ee481e189168a"},
{file = "Pillow-8.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:083781abd261bdabf090ad07bb69f8f5599943ddb539d64497ed021b2a67e5a9"},
{file = "Pillow-8.2.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:c6b39294464b03457f9064e98c124e09008b35a62e3189d3513e5148611c9388"},
{file = "Pillow-8.2.0-cp37-cp37m-win32.whl", hash = "sha256:01425106e4e8cee195a411f729cff2a7d61813b0b11737c12bd5991f5f14bcd5"},
{file = "Pillow-8.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3b570f84a6161cf8865c4e08adf629441f56e32f180f7aa4ccbd2e0a5a02cba2"},
{file = "Pillow-8.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:031a6c88c77d08aab84fecc05c3cde8414cd6f8406f4d2b16fed1e97634cc8a4"},
{file = "Pillow-8.2.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:66cc56579fd91f517290ab02c51e3a80f581aba45fd924fcdee01fa06e635812"},
{file = "Pillow-8.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c32cc3145928c4305d142ebec682419a6c0a8ce9e33db900027ddca1ec39178"},
{file = "Pillow-8.2.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:624b977355cde8b065f6d51b98497d6cd5fbdd4f36405f7a8790e3376125e2bb"},
{file = "Pillow-8.2.0-cp38-cp38-win32.whl", hash = "sha256:5cbf3e3b1014dddc45496e8cf38b9f099c95a326275885199f427825c6522232"},
{file = "Pillow-8.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:463822e2f0d81459e113372a168f2ff59723e78528f91f0bd25680ac185cf797"},
{file = "Pillow-8.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:95d5ef984eff897850f3a83883363da64aae1000e79cb3c321915468e8c6add5"},
{file = "Pillow-8.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b91c36492a4bbb1ee855b7d16fe51379e5f96b85692dc8210831fbb24c43e484"},
{file = "Pillow-8.2.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d68cb92c408261f806b15923834203f024110a2e2872ecb0bd2a110f89d3c602"},
{file = "Pillow-8.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f217c3954ce5fd88303fc0c317af55d5e0204106d86dea17eb8205700d47dec2"},
{file = "Pillow-8.2.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5b70110acb39f3aff6b74cf09bb4169b167e2660dabc304c1e25b6555fa781ef"},
{file = "Pillow-8.2.0-cp39-cp39-win32.whl", hash = "sha256:a7d5e9fad90eff8f6f6106d3b98b553a88b6f976e51fce287192a5d2d5363713"},
{file = "Pillow-8.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:238c197fc275b475e87c1453b05b467d2d02c2915fdfdd4af126145ff2e4610c"},
{file = "Pillow-8.2.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:0e04d61f0064b545b989126197930807c86bcbd4534d39168f4aa5fda39bb8f9"},
{file = "Pillow-8.2.0-pp36-pypy36_pp73-manylinux2010_i686.whl", hash = "sha256:63728564c1410d99e6d1ae8e3b810fe012bc440952168af0a2877e8ff5ab96b9"},
{file = "Pillow-8.2.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c03c07ed32c5324939b19e36ae5f75c660c81461e312a41aea30acdd46f93a7c"},
{file = "Pillow-8.2.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:4d98abdd6b1e3bf1a1cbb14c3895226816e666749ac040c4e2554231068c639b"},
{file = "Pillow-8.2.0-pp37-pypy37_pp73-manylinux2010_i686.whl", hash = "sha256:aac00e4bc94d1b7813fe882c28990c1bc2f9d0e1aa765a5f2b516e8a6a16a9e4"},
{file = "Pillow-8.2.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:22fd0f42ad15dfdde6c581347eaa4adb9a6fc4b865f90b23378aa7914895e120"},
{file = "Pillow-8.2.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:e98eca29a05913e82177b3ba3d198b1728e164869c613d76d0de4bde6768a50e"},
{file = "Pillow-8.2.0.tar.gz", hash = "sha256:a787ab10d7bb5494e5f76536ac460741788f1fbce851068d73a87ca7c35fc3e1"},
]
pluggy = [ pluggy = [
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},

View File

@ -39,6 +39,7 @@ pytest-cov = "^2.11.0"
mkdocs-material = "^7.0.2" mkdocs-material = "^7.0.2"
flake8 = "^3.9.0" flake8 = "^3.9.0"
coverage = "^5.5" coverage = "^5.5"
Pillow = "^8.2.0"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]