mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-27 00:04:23 -04:00 
			
		
		
		
	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:
		| @@ -1,6 +1,6 @@ | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| # {{ recipe.name }} | ||||
| {{ recipe.description }} | ||||
|   | ||||
| @@ -1,35 +1,6 @@ | ||||
| <template> | ||||
|   <v-app> | ||||
|     <v-app-bar 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 /> | ||||
|       <LanguageMenu /> | ||||
|     </v-app-bar> | ||||
|     <TheAppBar /> | ||||
|     <v-main> | ||||
|       <v-banner v-if="demo" sticky | ||||
|         ><div class="text-center"> | ||||
| @@ -47,10 +18,8 @@ | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import SiteMenu from "@/components/UI/SiteMenu"; | ||||
| import SearchBar from "@/components/UI/Search/SearchBar"; | ||||
| import TheAppBar from "@/components/UI/TheAppBar"; | ||||
| import AddRecipeFab from "@/components/UI/AddRecipeFab"; | ||||
| import LanguageMenu from "@/components/UI/LanguageMenu"; | ||||
| import Vuetify from "./plugins/vuetify"; | ||||
| import { user } from "@/mixins/user"; | ||||
|  | ||||
| @@ -58,23 +27,13 @@ export default { | ||||
|   name: "App", | ||||
|  | ||||
|   components: { | ||||
|     SiteMenu, | ||||
|     TheAppBar, | ||||
|     AddRecipeFab, | ||||
|     SearchBar, | ||||
|     LanguageMenu, | ||||
|   }, | ||||
|  | ||||
|   mixins: [user], | ||||
|  | ||||
|   watch: { | ||||
|     $route() { | ||||
|       this.search = false; | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     isMobile() { | ||||
|       return this.$vuetify.breakpoint.name === "xs"; | ||||
|     }, | ||||
|     demo() { | ||||
|       const appInfo = this.$store.getters.getAppInfo; | ||||
|       return appInfo.demoStatus; | ||||
| @@ -102,9 +61,6 @@ export default { | ||||
|     this.$store.dispatch("requestAppInfo"); | ||||
|   }, | ||||
|  | ||||
|   data: () => ({ | ||||
|     search: false, | ||||
|   }), | ||||
|   methods: { | ||||
|     // For Later! | ||||
|  | ||||
| @@ -126,9 +82,6 @@ export default { | ||||
|         this.darkModeSystemCheck(); | ||||
|       }); | ||||
|     }, | ||||
|     navigateFromSearch(slug) { | ||||
|       this.$router.push(`/recipe/${slug}`); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|   | ||||
| @@ -5,27 +5,27 @@ import { store } from "@/store"; | ||||
| const prefix = baseURL + "categories"; | ||||
|  | ||||
| const categoryURLs = { | ||||
|   get_all: `${prefix}`, | ||||
|   get_category: category => `${prefix}/${category}`, | ||||
|   delete_category: category => `${prefix}/${category}`, | ||||
|   getAll: `${prefix}`, | ||||
|   getCategory: category => `${prefix}/${category}`, | ||||
|   deleteCategory: category => `${prefix}/${category}`, | ||||
| }; | ||||
|  | ||||
| export const categoryAPI = { | ||||
|   async getAll() { | ||||
|     let response = await apiReq.get(categoryURLs.get_all); | ||||
|     let response = await apiReq.get(categoryURLs.getAll); | ||||
|     return response.data; | ||||
|   }, | ||||
|   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"); | ||||
|     return response.data; | ||||
|   }, | ||||
|   async getRecipesInCategory(category) { | ||||
|     let response = await apiReq.get(categoryURLs.get_category(category)); | ||||
|     let response = await apiReq.get(categoryURLs.getCategory(category)); | ||||
|     return response.data; | ||||
|   }, | ||||
|   async delete(category) { | ||||
|     let response = await apiReq.delete(categoryURLs.delete_category(category)); | ||||
|     let response = await apiReq.delete(categoryURLs.deleteCategory(category)); | ||||
|     store.dispatch("requestCategories"); | ||||
|     return response.data; | ||||
|   }, | ||||
|   | ||||
| @@ -56,9 +56,7 @@ export const recipeAPI = { | ||||
|     const fd = new FormData(); | ||||
|     fd.append("image", fileObject); | ||||
|     fd.append("extension", fileObject.name.split(".").pop()); | ||||
|  | ||||
|     let response = apiReq.put(recipeURLs.updateImage(recipeSlug), fd); | ||||
|  | ||||
|     return response; | ||||
|   }, | ||||
|  | ||||
| @@ -87,4 +85,16 @@ export const recipeAPI = { | ||||
|  | ||||
|     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`; | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -28,8 +28,8 @@ | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import utils from "@/utils"; | ||||
| import SearchDialog from "../UI/Search/SearchDialog"; | ||||
| import { api } from "@/api"; | ||||
| export default { | ||||
|   components: { | ||||
|     SearchDialog, | ||||
| @@ -47,7 +47,7 @@ export default { | ||||
|   methods: { | ||||
|     getImage(slug) { | ||||
|       if (slug) { | ||||
|         return utils.getImageURL(slug); | ||||
|         return api.recipes.recipeSmallImage(slug); | ||||
|       } | ||||
|     }, | ||||
|     setSlug(name, slug) { | ||||
|   | ||||
| @@ -223,7 +223,7 @@ export default { | ||||
|     }, | ||||
|  | ||||
|     getImage(image) { | ||||
|       return utils.getImageURL(image); | ||||
|       return api.recipes.recipeSmallImage(image); | ||||
|     }, | ||||
|  | ||||
|     formatDate(date) { | ||||
|   | ||||
| @@ -1,5 +1,10 @@ | ||||
| <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-avatar rounded size="125" class="mt-0 ml-n4"> | ||||
|         <v-img :src="getImage(image)"> </v-img> | ||||
| @@ -20,7 +25,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import utils from "@/utils"; | ||||
| import { api } from "@/api"; | ||||
| export default { | ||||
|   props: { | ||||
|     name: String, | ||||
| @@ -35,7 +40,7 @@ export default { | ||||
|  | ||||
|   methods: { | ||||
|     getImage(image) { | ||||
|       return utils.getImageURL(image); | ||||
|       return api.recipes.recipeSmallImage(image); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -42,7 +42,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import utils from "@/utils"; | ||||
| import { api } from "@/api"; | ||||
| export default { | ||||
|   props: { | ||||
|     name: String, | ||||
| @@ -57,7 +57,7 @@ export default { | ||||
|   }, | ||||
|   methods: { | ||||
|     getImage(image) { | ||||
|       return utils.getImageURL(image); | ||||
|       return api.recipes.recipeSmallImage(image); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -162,6 +162,7 @@ | ||||
|  | ||||
| <script> | ||||
| import utils from "@/utils"; | ||||
| import { api } from "@/api"; | ||||
|  | ||||
| export default { | ||||
|   props: { | ||||
| @@ -175,7 +176,7 @@ export default { | ||||
|   methods: { | ||||
|     getImage(image) { | ||||
|       if (image) { | ||||
|         return utils.getImageURL(image) + "?rnd=" + this.imageKey; | ||||
|         return api.recipes.recipeImage(image) + "?rnd=" + this.imageKey; | ||||
|       } | ||||
|     }, | ||||
|     generateKey(item, index) { | ||||
|   | ||||
| @@ -16,7 +16,7 @@ | ||||
|       > | ||||
|       </v-text-field> | ||||
|     </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-divider></v-divider> | ||||
|       <v-list scrollable> | ||||
| @@ -54,7 +54,7 @@ | ||||
|  | ||||
| <script> | ||||
| import Fuse from "fuse.js"; | ||||
| import utils from "@/utils"; | ||||
| import { api } from "@/api"; | ||||
|  | ||||
| export default { | ||||
|   props: { | ||||
| @@ -151,7 +151,7 @@ export default { | ||||
|       ); | ||||
|     }, | ||||
|     getImage(image) { | ||||
|       return utils.getImageURL(image); | ||||
|       return api.recipes.recipeTinyImage(image); | ||||
|     }, | ||||
|     selected(slug, name) { | ||||
|       this.$emit("selected", slug, name); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <template> | ||||
|   <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-app-bar dark color="primary"> | ||||
|           <v-toolbar-title class="headline">Search a Recipe</v-toolbar-title> | ||||
| @@ -9,13 +9,27 @@ | ||||
|           <SearchBar | ||||
|             @results="updateResults" | ||||
|             @selected="emitSelect" | ||||
|             :show-results="true" | ||||
|             :show-results="!isMobile" | ||||
|             max-width="550px" | ||||
|             :dense="false" | ||||
|             :nav-on-click="false" | ||||
|             :reset-search="dialog" | ||||
|             :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> | ||||
|     </v-dialog> | ||||
| @@ -24,16 +38,32 @@ | ||||
|  | ||||
| <script> | ||||
| import SearchBar from "./SearchBar"; | ||||
| import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard"; | ||||
| export default { | ||||
|   components: { | ||||
|     SearchBar, | ||||
|     MobileRecipeCard, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       searchResults: null, | ||||
|       searchResults: [], | ||||
|       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: { | ||||
|     updateResults(results) { | ||||
|       this.searchResults = results; | ||||
| @@ -44,15 +74,22 @@ export default { | ||||
|     }, | ||||
|     open() { | ||||
|       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> | ||||
|  | ||||
| <style scope> | ||||
| .search-dialog { | ||||
|   margin-top: 10%; | ||||
| .mobile-dialog { | ||||
|   align-items: flex-start; | ||||
|   justify-content: center; | ||||
|   justify-content: flex-start; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										114
									
								
								frontend/src/components/UI/TheAppBar.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								frontend/src/components/UI/TheAppBar.vue
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										7
									
								
								frontend/src/mixins/utilMixins.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/mixins/utilMixins.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| export const utilMixins = { | ||||
|   commputed: { | ||||
|     isMobile() { | ||||
|       return this.$vuetify.breakpoint.name === "xs"; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| @@ -117,7 +117,7 @@ export default { | ||||
|       return utils.getDateAsTextAlt(dateObject); | ||||
|     }, | ||||
|     getImage(image) { | ||||
|       return utils.getImageURL(image); | ||||
|       return api.recipes.recipeTinyImage(image); | ||||
|     }, | ||||
|  | ||||
|     editPlan(id) { | ||||
|   | ||||
| @@ -52,7 +52,6 @@ | ||||
|  | ||||
| <script> | ||||
| import { api } from "@/api"; | ||||
| import utils from "@/utils"; | ||||
| export default { | ||||
|   data() { | ||||
|     return { | ||||
| @@ -68,7 +67,7 @@ export default { | ||||
|       else return 0; | ||||
|     }, | ||||
|     getImage(image) { | ||||
|       return utils.getImageURL(image); | ||||
|       return api.recipes.recipeImage(image); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -14,7 +14,7 @@ | ||||
|     <v-card v-else id="myRecipe"> | ||||
|       <v-img | ||||
|         height="400" | ||||
|         :src="getImage(recipeDetails.image)" | ||||
|         :src="getImage(recipeDetails.slug)" | ||||
|         class="d-print-none" | ||||
|         :key="imageKey" | ||||
|       > | ||||
| @@ -71,7 +71,6 @@ | ||||
|  | ||||
| <script> | ||||
| import { api } from "@/api"; | ||||
| import utils from "@/utils"; | ||||
| import VJsoneditor from "v-jsoneditor"; | ||||
| import RecipeViewer from "@/components/Recipe/RecipeViewer"; | ||||
| import RecipeEditor from "@/components/Recipe/RecipeEditor"; | ||||
| @@ -160,7 +159,7 @@ export default { | ||||
|     }, | ||||
|     getImage(image) { | ||||
|       if (image) { | ||||
|         return utils.getImageURL(image) + "?rnd=" + this.imageKey; | ||||
|         return api.recipes.recipeImage(image) + "&rnd=" + this.imageKey; | ||||
|       } | ||||
|     }, | ||||
|     deleteRecipe() { | ||||
|   | ||||
| @@ -70,7 +70,7 @@ const actions = { | ||||
|  | ||||
|   async refreshToken({ commit, getters }) { | ||||
|     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"); | ||||
|       return; | ||||
|     } | ||||
|   | ||||
| @@ -50,7 +50,7 @@ const monthsShort = [ | ||||
|  | ||||
| export default { | ||||
|   getImageURL(image) { | ||||
|     return `/api/recipes/${image}/image`; | ||||
|     return `/api/recipes/${image}/image?image_type=small`; | ||||
|   }, | ||||
|   generateUniqueKey(item, index) { | ||||
|     const uniqueKey = `${item}-${index}`; | ||||
|   | ||||
							
								
								
									
										1
									
								
								makefile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								makefile
									
									
									
									
									
								
							| @@ -54,6 +54,7 @@ setup: ## Setup Development Instance | ||||
|  | ||||
| backend: ## Start Mealie Backend Development Server | ||||
| 	poetry run python mealie/db/init_db.py && \ | ||||
| 	poetry run python mealie/services/image/minify.py && \ | ||||
| 	poetry run python mealie/app.py | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -45,19 +45,19 @@ LOGGER_FILE = DATA_DIR.joinpath("mealie.log") | ||||
|  | ||||
| class AppDirectories: | ||||
|     def __init__(self, cwd, data_dir) -> None: | ||||
|         self.DATA_DIR = data_dir | ||||
|         self.WEB_PATH = cwd.joinpath("dist") | ||||
|         self.IMG_DIR = data_dir.joinpath("img") | ||||
|         self.BACKUP_DIR = data_dir.joinpath("backups") | ||||
|         self.DEBUG_DIR = data_dir.joinpath("debug") | ||||
|         self.MIGRATION_DIR = data_dir.joinpath("migration") | ||||
|         self.NEXTCLOUD_DIR = self.MIGRATION_DIR.joinpath("nextcloud") | ||||
|         self.CHOWDOWN_DIR = self.MIGRATION_DIR.joinpath("chowdown") | ||||
|         self.TEMPLATE_DIR = data_dir.joinpath("templates") | ||||
|         self.USER_DIR = data_dir.joinpath("users") | ||||
|         self.SQLITE_DIR = data_dir.joinpath("db") | ||||
|         self.RECIPE_DATA_DIR = data_dir.joinpath("recipes") | ||||
|         self.TEMP_DIR = data_dir.joinpath(".temp") | ||||
|         self.DATA_DIR: Path = data_dir | ||||
|         self.WEB_PATH: Path = cwd.joinpath("dist") | ||||
|         self.IMG_DIR: Path = data_dir.joinpath("img") | ||||
|         self.BACKUP_DIR: Path = data_dir.joinpath("backups") | ||||
|         self.DEBUG_DIR: Path = data_dir.joinpath("debug") | ||||
|         self.MIGRATION_DIR: Path = data_dir.joinpath("migration") | ||||
|         self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud") | ||||
|         self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown") | ||||
|         self.TEMPLATE_DIR: Path = data_dir.joinpath("templates") | ||||
|         self.USER_DIR: Path = data_dir.joinpath("users") | ||||
|         self.SQLITE_DIR: Path = data_dir.joinpath("db") | ||||
|         self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes") | ||||
|         self.TEMP_DIR: Path = data_dir.joinpath(".temp") | ||||
|  | ||||
|         self.ensure_directories() | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| from enum import Enum | ||||
|  | ||||
| from fastapi import APIRouter, Depends, File, Form, HTTPException | ||||
| from fastapi.responses import FileResponse | ||||
| 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.schema.recipe import Recipe, RecipeURLIn | ||||
| 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 sqlalchemy.orm.session import Session | ||||
|  | ||||
| @@ -72,20 +74,35 @@ def delete_recipe( | ||||
|  | ||||
|     try: | ||||
|         db.recipes.delete(session, recipe_slug) | ||||
|         delete_image(recipe_slug) | ||||
|     except: | ||||
|         raise HTTPException(status_code=404, detail=SnackResponse.error("Unable to Delete Recipe")) | ||||
|  | ||||
|     return SnackResponse.error(f"Recipe {recipe_slug} Deleted") | ||||
|  | ||||
|  | ||||
| class ImageType(str, Enum): | ||||
|     original = "original" | ||||
|     small = "small" | ||||
|     tiny = "tiny" | ||||
|  | ||||
|  | ||||
| @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 """ | ||||
|     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: | ||||
|         return FileResponse(recipe_image) | ||||
|     else: | ||||
|         return | ||||
|         raise HTTPException(404, "file not found") | ||||
|  | ||||
|  | ||||
| @router.put("/{recipe_slug}/image") | ||||
|   | ||||
| @@ -65,8 +65,7 @@ class ExportDatabase: | ||||
|                     f.write(content) | ||||
|  | ||||
|     def export_images(self): | ||||
|         for file in app_dirs.IMG_DIR.iterdir(): | ||||
|             shutil.copy(file, self.img_dir.joinpath(file.name)) | ||||
|         shutil.copytree(app_dirs.IMG_DIR, self.img_dir, dirs_exist_ok=True) | ||||
|  | ||||
|     def export_items(self, items: list[BaseModel], folder_name: str, export_list=True): | ||||
|         items = [x.dict() for x in items] | ||||
|   | ||||
| @@ -11,6 +11,7 @@ from mealie.schema.restore import CustomPageImport, GroupImport, RecipeImport, S | ||||
| from mealie.schema.settings import CustomPageOut, SiteSettings | ||||
| from mealie.schema.theme import SiteTheme | ||||
| from mealie.schema.user import UpdateGroup, UserInDB | ||||
| from mealie.services.image import minify | ||||
| from pydantic.main import BaseModel | ||||
| from sqlalchemy.orm.session import Session | ||||
|  | ||||
| @@ -108,7 +109,13 @@ class ImportDatabase: | ||||
|         image_dir = self.import_dir.joinpath("images") | ||||
|         for image in image_dir.iterdir(): | ||||
|             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): | ||||
|         themes_file = self.import_dir.joinpath("themes", "themes.json") | ||||
|   | ||||
							
								
								
									
										101
									
								
								mealie/services/image/image.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								mealie/services/image/image.py
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										84
									
								
								mealie/services/image/minify.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								mealie/services/image/minify.py
									
									
									
									
									
										Normal 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() | ||||
| @@ -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 | ||||
| @@ -52,7 +52,7 @@ def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe: | ||||
|     Returns: | ||||
|         Recipe: Pydantic Recipe Object | ||||
|     """ | ||||
|     session = session if session else create_session() | ||||
|     session = session or create_session() | ||||
|  | ||||
|     if isinstance(group, int): | ||||
|         group: GroupInDB = db.groups.get(session, group) | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import requests | ||||
| import scrape_schema_recipe | ||||
| from mealie.core.config import app_dirs | ||||
| 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.services.scraper import open_graph | ||||
| from mealie.services.scraper.cleaner import Cleaner | ||||
|   | ||||
							
								
								
									
										45
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										45
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @@ -606,6 +606,14 @@ category = "dev" | ||||
| optional = false | ||||
| 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]] | ||||
| name = "pluggy" | ||||
| version = "0.13.1" | ||||
| @@ -1154,7 +1162,7 @@ python-versions = "*" | ||||
| [metadata] | ||||
| lock-version = "1.1" | ||||
| python-versions = "^3.9" | ||||
| content-hash = "a6c10e179bc15efc30627c9793218bb944f43dce5e624a7bcabcc47545e661e8" | ||||
| content-hash = "32bff6a472fd8564106e2cfa20161a47d271d89ed44a5f2f2483f419fe259c92" | ||||
|  | ||||
| [metadata.files] | ||||
| aiofiles = [ | ||||
| @@ -1603,6 +1611,41 @@ pathspec = [ | ||||
|     {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, | ||||
|     {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 = [ | ||||
|     {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, | ||||
|     {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, | ||||
|   | ||||
| @@ -39,6 +39,7 @@ pytest-cov = "^2.11.0" | ||||
| mkdocs-material = "^7.0.2" | ||||
| flake8 = "^3.9.0" | ||||
| coverage = "^5.5" | ||||
| Pillow = "^8.2.0" | ||||
|  | ||||
| [build-system] | ||||
| requires = ["poetry-core>=1.0.0"] | ||||
|   | ||||
		Reference in New Issue
	
	Block a user