mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	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:
		| @@ -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) | ||||||
| @@ -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"); | ||||||
|   | |||||||
| @@ -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> | ||||||
| @@ -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> | ||||||
| @@ -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)) { | ||||||
|   | |||||||
| @@ -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 { | ||||||
|   | |||||||
| @@ -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); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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}`); | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -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") | ||||||
|   | |||||||
| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user